caracms 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The toast (M2 Decision 14; Decision 18): the newest Undo entry, for a few seconds,
|
|
3
|
+
* with an Undo button. ⌘Z (outside a field) undoes too (App.tsx). The editor's own
|
|
4
|
+
* messages (`editor.toast`, M4: "Aoife's done. About is yours to edit now." when a lock
|
|
5
|
+
* lapses) use the same toast, with an icon and no Undo, for four seconds, as the
|
|
6
|
+
* mockup's `toast(text, false, icon)`. Whichever came last shows.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useState } from 'react';
|
|
9
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
10
|
+
import { Icon } from './Icons.tsx';
|
|
11
|
+
|
|
12
|
+
type Shown = { kind: 'undo'; id: number } | { kind: 'message'; id: number } | null;
|
|
13
|
+
|
|
14
|
+
export function Toast() {
|
|
15
|
+
const editor = useEditor();
|
|
16
|
+
const undo = useStore(editor.doc.state, (s) => s.undo);
|
|
17
|
+
const latest = undo.at(-1) ?? null;
|
|
18
|
+
const message = useStore(editor.message);
|
|
19
|
+
const [shown, setShown] = useState<Shown>(null);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!latest) {
|
|
22
|
+
setShown((s) => (s?.kind === 'undo' ? null : s));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
setShown({ kind: 'undo', id: latest.id });
|
|
26
|
+
const timer = setTimeout(() => setShown((s) => (s?.kind === 'undo' && s.id === latest.id ? null : s)), 8000);
|
|
27
|
+
return () => clearTimeout(timer);
|
|
28
|
+
}, [latest]);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!message) return;
|
|
31
|
+
setShown({ kind: 'message', id: message.id });
|
|
32
|
+
const timer = setTimeout(() => setShown((s) => (s?.kind === 'message' && s.id === message.id ? null : s)), 4000);
|
|
33
|
+
return () => clearTimeout(timer);
|
|
34
|
+
}, [message]);
|
|
35
|
+
const dismiss = (
|
|
36
|
+
<button type="button" className="ib" aria-label="Dismiss" style={{ color: 'var(--bar-muted)' }} onClick={() => setShown(null)}>
|
|
37
|
+
<Icon name="x" size="sm" />
|
|
38
|
+
</button>
|
|
39
|
+
);
|
|
40
|
+
if (shown?.kind === 'message' && message && shown.id === message.id) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="toast" role="status">
|
|
43
|
+
{message.icon ? <Icon name={message.icon} size="sm" /> : null}
|
|
44
|
+
<span className="t">{message.text}</span>
|
|
45
|
+
{dismiss}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!latest || shown?.kind !== 'undo' || shown.id !== latest.id) return null;
|
|
50
|
+
// As the mockup's undo toast: the text, Undo with its key, and a quiet dismiss.
|
|
51
|
+
return (
|
|
52
|
+
<div className="toast" role="status">
|
|
53
|
+
<span className="t">{latest.label}</span>
|
|
54
|
+
<button type="button" className="btn sm" onClick={() => editor.doc.undo()}>
|
|
55
|
+
<Icon name="undo" size="sm" />
|
|
56
|
+
Undo <kbd>⌘Z</kbd>
|
|
57
|
+
</button>
|
|
58
|
+
{dismiss}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open document's editing state, and every operation that changes it. Phases 3
|
|
3
|
+
* and 4 call these and nothing else changes the document:
|
|
4
|
+
*
|
|
5
|
+
* setField(key, path, value, { origin, undoLabel }) a field, or a value inside one by dotted path
|
|
6
|
+
* (M5a Decision 16: `credits.1.name`, `cta.label`)
|
|
7
|
+
* addBlock(type, index) / removeBlock(key) /
|
|
8
|
+
* moveBlock(key, to) / restoreBlock(block, index) the block list (Phase 4)
|
|
9
|
+
* removeItem(key, path, index, { label }) M5a Decision 14: a list's row (or a button, or a
|
|
10
|
+
* reference in a list) removed, with Undo
|
|
11
|
+
* undo() Decision 18: brings back a removed block, a
|
|
12
|
+
* removed list row (back in place), or a field
|
|
13
|
+
* change made with an undoLabel (dropping a
|
|
14
|
+
* lockup's phone lines)
|
|
15
|
+
*
|
|
16
|
+
* **Every kind of document** (M5a Phase 5) is held as a list of blocks, so a field is
|
|
17
|
+
* always set by a key and a path, and Undo, the keeper and the write path work the
|
|
18
|
+
* same for all four:
|
|
19
|
+
* - a section builder: its blocks (`document.blocks`), as M3 had it;
|
|
20
|
+
* - a list-of-fields document and Site Settings: one block, the form, whose `data`
|
|
21
|
+
* is the document's `data` (`type` '', `known`; `formKey(session)` is its key);
|
|
22
|
+
* - a shared block: one block, the shared block itself (its own type and fields).
|
|
23
|
+
* `wire()` gives each back in its own shape (`{ kind: 'fields', title, data }`…).
|
|
24
|
+
* Adding, moving and removing blocks only apply to a section builder.
|
|
25
|
+
*
|
|
26
|
+
* **A page's own `seo`** (M6 Decision 13), on a section builder and a fields document, is
|
|
27
|
+
* held beside the blocks (`document.seo`, present only when the file had it or it's been
|
|
28
|
+
* set) and set through the same calls under the scope `SEO_SCOPE`: `setField('seo',
|
|
29
|
+
* 'title', …)`, `block('seo')` (a stand-in whose `data` is the seo object). So the media
|
|
30
|
+
* picker, Undo and the keeper take it as they take a field. `wire()` sends it only when
|
|
31
|
+
* present: absent leaves the file's as it is, emptied removes it (serialize.ts).
|
|
32
|
+
*
|
|
33
|
+
* **A section builder's page fields** (M6c G2) are held the same way: `document.data`
|
|
34
|
+
* (present when the collection has page fields), set under `PAGE_SCOPE`
|
|
35
|
+
* (`setField('page', 'side', …)`, `block('page')`, `removeItem('page', …)`), sent as the
|
|
36
|
+
* wire's `data`.
|
|
37
|
+
*
|
|
38
|
+
* Blocks are addressed by `key`, a client-only id that stays with a block through
|
|
39
|
+
* moves (EditorBlock). `source` follows the serializer's model (serialize.ts): it's
|
|
40
|
+
* the block's index in the file as first read, `null` for an added block, kept
|
|
41
|
+
* through moves, gone when removed, and the same `source` back when restored, so
|
|
42
|
+
* the write puts an untouched or restored block back byte for byte.
|
|
43
|
+
*
|
|
44
|
+
* Every change makes a new immutable snapshot (React reads it with useStore) and
|
|
45
|
+
* tells subscribers what changed: the keeper writes it (keeper.ts), the preview
|
|
46
|
+
* renders it (preview/driver.ts; a field typed on the page, `origin: 'page'`, is
|
|
47
|
+
* already on the page, so it isn't re-rendered, Decision 13).
|
|
48
|
+
*/
|
|
49
|
+
import type { DocumentEntry } from '../../../content.ts';
|
|
50
|
+
import { documentKind, emptyBlock, own, type AnyDocument, type DocumentBlock } from '../../../document.ts';
|
|
51
|
+
import { getAtPath, setAtPath } from '../../../fields.ts';
|
|
52
|
+
import type { Seo } from '../../../seo.ts';
|
|
53
|
+
import type { DocRef, EditorBlock, EditorDocument, EditorSite } from '../../types.ts';
|
|
54
|
+
import { createStore, type Store } from './store.ts';
|
|
55
|
+
|
|
56
|
+
/** Where a field change came from. `page`: typed on the page (the preview already shows it). */
|
|
57
|
+
export type ChangeOrigin = 'sidebar' | 'page';
|
|
58
|
+
|
|
59
|
+
export type DocumentChange =
|
|
60
|
+
/** A document was opened. `adopted`: its unsent edits came back from the browser's storage. */
|
|
61
|
+
| { kind: 'load'; adopted: boolean }
|
|
62
|
+
/** `field` is the path that was set: a field's key, or a dotted path into it (`credits.1.name`). */
|
|
63
|
+
| { kind: 'field'; key: string; field: string; origin: ChangeOrigin }
|
|
64
|
+
| { kind: 'title' }
|
|
65
|
+
/** M9 Decision 7: the page hidden from the site, or shown again. */
|
|
66
|
+
| { kind: 'hidden' }
|
|
67
|
+
| { kind: 'add'; key: string }
|
|
68
|
+
| { kind: 'remove'; key: string }
|
|
69
|
+
| { kind: 'move'; key: string }
|
|
70
|
+
| { kind: 'restore'; key: string };
|
|
71
|
+
|
|
72
|
+
/** Something Undo can bring back (Decision 18). Newest last. */
|
|
73
|
+
export type UndoEntry =
|
|
74
|
+
| { id: number; label: string; kind: 'block'; block: EditorBlock; index: number }
|
|
75
|
+
| { id: number; label: string; kind: 'field'; key: string; field: string; value: unknown }
|
|
76
|
+
/** M5a Decision 14: a list row removed from the list at `field` (a dotted path), put back at `index`. */
|
|
77
|
+
| { id: number; label: string; kind: 'item'; key: string; field: string; item: unknown; index: number };
|
|
78
|
+
|
|
79
|
+
export interface DocumentSession {
|
|
80
|
+
ref: DocRef;
|
|
81
|
+
entry: DocumentEntry;
|
|
82
|
+
/** The serializer's base: the file's text as first opened, or as carried by unsent edits (keeper.ts). */
|
|
83
|
+
base: string;
|
|
84
|
+
document: EditorDocument;
|
|
85
|
+
/** 0 when opened; +1 for every change. */
|
|
86
|
+
revision: number;
|
|
87
|
+
/** What Undo can bring back in this document, newest last. Cleared when another document opens. */
|
|
88
|
+
undo: readonly UndoEntry[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface DocumentStore {
|
|
92
|
+
/** The open document. */
|
|
93
|
+
get(): DocumentSession;
|
|
94
|
+
/** The snapshot store, for React (useStore). */
|
|
95
|
+
readonly state: Store<DocumentSession>;
|
|
96
|
+
/** Called after every change with the new snapshot and what changed. */
|
|
97
|
+
subscribe(listener: (session: DocumentSession, change: DocumentChange) => void): () => void;
|
|
98
|
+
|
|
99
|
+
/** Open a document: a new session (new keys, empty Undo). */
|
|
100
|
+
load(input: { ref: DocRef; entry: DocumentEntry; base: string; document: AnyDocument }, options?: { adopted?: boolean }): void;
|
|
101
|
+
|
|
102
|
+
block(key: string): EditorBlock | undefined;
|
|
103
|
+
indexOf(key: string): number;
|
|
104
|
+
keyAt(index: number): string | undefined;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Set one field of a known block (unknown blocks aren't editable, Decision 21).
|
|
108
|
+
* `field` is the field's key, or a dotted path into its value (M5a Decision 16):
|
|
109
|
+
* `credits.1.name` sets one list item's field, `cta.label` a link's label,
|
|
110
|
+
* `buttons.0.href` one button's link; the rest of the value is kept (only the
|
|
111
|
+
* objects along the path are copied). A missing step is made (an array for an
|
|
112
|
+
* index, an object otherwise). `origin: 'page'` for text typed on the page. With
|
|
113
|
+
* `undoLabel`, the old value at that path goes on the Undo stack under that label
|
|
114
|
+
* (the toast's text): Decision 18's dropped phone lines. A value equal to the
|
|
115
|
+
* current one is no change.
|
|
116
|
+
*/
|
|
117
|
+
setField(key: string, field: string, value: unknown, options?: { origin?: ChangeOrigin; undoLabel?: string }): void;
|
|
118
|
+
setTitle(title: string): void;
|
|
119
|
+
/** M9 Decision 7: hide a section builder's page from the site, or show it again (never the home page's). */
|
|
120
|
+
setHidden(hidden: boolean): void;
|
|
121
|
+
/** Add a new, empty block of a known type at `index` (default: the end). Returns its key. */
|
|
122
|
+
addBlock(type: string, index?: number): string;
|
|
123
|
+
/**
|
|
124
|
+
* Add a shared block's entry, `{ "shared": name }`, at `index` (M5a Decision 8: the
|
|
125
|
+
* picker's Shared blocks section). It's selected, moved and removed like a block, never
|
|
126
|
+
* edited here. Returns its key.
|
|
127
|
+
*/
|
|
128
|
+
addShared(name: string, index?: number): string;
|
|
129
|
+
/** Remove a block at once (M2 Decision 14), with Undo. Returns the Undo entry. */
|
|
130
|
+
removeBlock(key: string, options?: { label?: string }): UndoEntry | null;
|
|
131
|
+
/** Move a block to `toIndex` (its index after the move). */
|
|
132
|
+
moveBlock(key: string, toIndex: number): void;
|
|
133
|
+
/** Put a block back at `index` (clamped), keeping its key and source. */
|
|
134
|
+
restoreBlock(block: EditorBlock, index: number): void;
|
|
135
|
+
/**
|
|
136
|
+
* Remove item `index` from the list at `field` (a field's key, or a dotted path to a
|
|
137
|
+
* list inside it) of a known block, with Undo (M5a Decision 14, as a removed block
|
|
138
|
+
* is): Undo puts the item back at the same index. Lists, buttons and lists of
|
|
139
|
+
* references all remove this way. Returns the Undo entry.
|
|
140
|
+
*/
|
|
141
|
+
removeItem(key: string, field: string, index: number, options?: { label?: string }): UndoEntry | null;
|
|
142
|
+
/** Undo the newest Undo entry. Returns it, or null when there's nothing to undo. */
|
|
143
|
+
undo(): UndoEntry | null;
|
|
144
|
+
|
|
145
|
+
/** The document as the server takes it, in its kind's shape (keys dropped): the write's and the preview's `document`. */
|
|
146
|
+
wire(): AnyDocument;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let nextUndo = 1;
|
|
150
|
+
|
|
151
|
+
/** M6: the scope a page's own `seo` is set under (block keys are `b1`, `b2`…, so it never clashes). */
|
|
152
|
+
export const SEO_SCOPE = 'seo';
|
|
153
|
+
/** Which kinds of document have their own `seo` (Decision 13: not shared blocks or Site Settings). */
|
|
154
|
+
const hasSeo = (kind: EditorDocument['kind']) => kind === 'sections' || kind === 'fields';
|
|
155
|
+
/** M6c (G2): the scope a section builder's page fields are set under (never a block key either). */
|
|
156
|
+
export const PAGE_SCOPE = 'page';
|
|
157
|
+
|
|
158
|
+
/** The document-level scopes (held beside the blocks, not in one): which property of the document each sets. */
|
|
159
|
+
type Scoped = 'seo' | 'data';
|
|
160
|
+
const scopedProp = (key: string): Scoped | null => (key === SEO_SCOPE ? 'seo' : key === PAGE_SCOPE ? 'data' : null);
|
|
161
|
+
/** Whether the document has that scope: `seo` by its kind, page fields when its collection has them. */
|
|
162
|
+
const hasScope = (document: EditorDocument, prop: Scoped) => (prop === 'seo' ? hasSeo(document.kind) : document.kind === 'sections' && document.data !== undefined);
|
|
163
|
+
|
|
164
|
+
const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
165
|
+
|
|
166
|
+
/** The key of a fields document's or Site Settings' form (its one block), or of a shared block; null for a section builder. */
|
|
167
|
+
export function formKey(session: Pick<DocumentSession, 'document'>): string | null {
|
|
168
|
+
return session.document.kind === 'sections' ? null : (session.document.blocks[0]?.key ?? null);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function blockLabel(site: Pick<EditorSite, 'blocks'>, block: { type: string; known: boolean }): string {
|
|
172
|
+
return block.known ? (own(site.blocks, block.type)?.label ?? block.type) : 'Unknown block';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function createDocumentStore(
|
|
176
|
+
site: Pick<EditorSite, 'blocks'>,
|
|
177
|
+
initial: { ref: DocRef; entry: DocumentEntry; base: string; document: AnyDocument },
|
|
178
|
+
/**
|
|
179
|
+
* M4: `canEdit` false makes every change a no-op (someone else's lock, held edits,
|
|
180
|
+
* signed out: Decision 9). The UI disables its controls too; this is the backstop,
|
|
181
|
+
* so no key or stray event can change a read-only document.
|
|
182
|
+
*/
|
|
183
|
+
options: { canEdit?: () => boolean } = {},
|
|
184
|
+
): DocumentStore {
|
|
185
|
+
const editable = () => options.canEdit?.() ?? true;
|
|
186
|
+
// Keys are per session and deterministic (b1, b2… in file order), so the server's
|
|
187
|
+
// render and the browser's hydration agree.
|
|
188
|
+
let keys = 0;
|
|
189
|
+
const newKey = () => `b${++keys}`;
|
|
190
|
+
const toSession = (input: typeof initial): DocumentSession => {
|
|
191
|
+
keys = 0;
|
|
192
|
+
const doc = input.document;
|
|
193
|
+
const kind = documentKind(doc);
|
|
194
|
+
const keyed = (b: DocumentBlock): EditorBlock => ({ source: b.source, type: b.type, known: b.known, data: b.data, ...(b.shared !== undefined ? { shared: b.shared } : {}), key: newKey() });
|
|
195
|
+
const blocks: EditorBlock[] =
|
|
196
|
+
doc.kind === 'fields' || doc.kind === 'settings'
|
|
197
|
+
? [{ source: 0, type: '', known: true, data: doc.data as Record<string, unknown>, key: newKey() }]
|
|
198
|
+
: doc.kind === 'shared'
|
|
199
|
+
? [keyed(doc.block)]
|
|
200
|
+
: doc.blocks.map(keyed);
|
|
201
|
+
const seo = (doc.kind === undefined || doc.kind === 'sections' || doc.kind === 'fields') && doc.seo !== undefined ? { seo: doc.seo as Record<string, unknown> } : {};
|
|
202
|
+
const data = (doc.kind === undefined || doc.kind === 'sections') && doc.data !== undefined ? { data: doc.data } : {};
|
|
203
|
+
const hidden = (doc.kind === undefined || doc.kind === 'sections') && doc.hidden ? { hidden: true } : {};
|
|
204
|
+
return {
|
|
205
|
+
ref: { collection: input.ref.collection, path: input.ref.path },
|
|
206
|
+
entry: input.entry,
|
|
207
|
+
base: input.base,
|
|
208
|
+
document: { kind, title: doc.title, blocks, ...seo, ...data, ...hidden },
|
|
209
|
+
revision: 0,
|
|
210
|
+
undo: [],
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
const state = createStore<DocumentSession>(toSession(initial));
|
|
214
|
+
const listeners = new Set<(session: DocumentSession, change: DocumentChange) => void>();
|
|
215
|
+
|
|
216
|
+
const commit = (next: DocumentSession, change: DocumentChange) => {
|
|
217
|
+
state.set(next);
|
|
218
|
+
for (const listener of [...listeners]) listener(next, change);
|
|
219
|
+
};
|
|
220
|
+
/** A change to the document: a new snapshot with the revision bumped. */
|
|
221
|
+
const change = (blocks: EditorBlock[], change: DocumentChange, extra: Partial<DocumentSession> = {}) => {
|
|
222
|
+
const current = state.get();
|
|
223
|
+
commit({ ...current, ...extra, document: { ...current.document, blocks }, revision: current.revision + 1 }, change);
|
|
224
|
+
};
|
|
225
|
+
const blocks = () => state.get().document.blocks;
|
|
226
|
+
const indexOf = (key: string) => blocks().findIndex((b) => b.key === key);
|
|
227
|
+
const clamp = (index: number, length: number) => Math.max(0, Math.min(length, Number.isFinite(index) ? Math.trunc(index) : length));
|
|
228
|
+
const pushUndo = (entry: UndoEntry): UndoEntry[] => [...state.get().undo, entry].slice(-50);
|
|
229
|
+
/** Only a section builder's blocks are added, moved and removed. */
|
|
230
|
+
const builder = () => state.get().document.kind === 'sections';
|
|
231
|
+
|
|
232
|
+
const store: DocumentStore = {
|
|
233
|
+
get: () => state.get(),
|
|
234
|
+
state,
|
|
235
|
+
subscribe(listener) {
|
|
236
|
+
listeners.add(listener);
|
|
237
|
+
return () => listeners.delete(listener);
|
|
238
|
+
},
|
|
239
|
+
load(input, options = {}) {
|
|
240
|
+
commit(toSession(input), { kind: 'load', adopted: !!options.adopted });
|
|
241
|
+
},
|
|
242
|
+
block: (key) => {
|
|
243
|
+
const prop = scopedProp(key);
|
|
244
|
+
return prop ? scopedBlock(key, prop) : blocks().find((b) => b.key === key);
|
|
245
|
+
},
|
|
246
|
+
indexOf,
|
|
247
|
+
keyAt: (index) => blocks()[index]?.key,
|
|
248
|
+
|
|
249
|
+
setField(key, field, value, options = {}) {
|
|
250
|
+
if (!editable()) return;
|
|
251
|
+
const prop = scopedProp(key);
|
|
252
|
+
if (prop) {
|
|
253
|
+
setScoped(key, prop, field, value, options.undoLabel);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const index = indexOf(key);
|
|
257
|
+
const block = blocks()[index];
|
|
258
|
+
if (!block || !block.known || field === '') return;
|
|
259
|
+
const before = getAtPath(block.data, field);
|
|
260
|
+
if (same(before, value)) return;
|
|
261
|
+
const next = [...blocks()];
|
|
262
|
+
next[index] = { ...block, data: setAtPath(block.data, field, value) as Record<string, unknown> };
|
|
263
|
+
const undo = options.undoLabel
|
|
264
|
+
? pushUndo({ id: nextUndo++, label: options.undoLabel, kind: 'field', key, field, value: before })
|
|
265
|
+
: state.get().undo;
|
|
266
|
+
change(next, { kind: 'field', key, field, origin: options.origin ?? 'sidebar' }, { undo });
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
setTitle(title) {
|
|
270
|
+
if (!editable()) return;
|
|
271
|
+
const current = state.get();
|
|
272
|
+
if (current.document.title === title) return;
|
|
273
|
+
commit({ ...current, document: { ...current.document, title }, revision: current.revision + 1 }, { kind: 'title' });
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
setHidden(hidden) {
|
|
277
|
+
if (!editable() || !builder()) return;
|
|
278
|
+
const current = state.get();
|
|
279
|
+
if (!!current.document.hidden === hidden) return;
|
|
280
|
+
commit({ ...current, document: { ...current.document, hidden }, revision: current.revision + 1 }, { kind: 'hidden' });
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
addBlock(type, index) {
|
|
284
|
+
if (!editable() || !builder()) return '';
|
|
285
|
+
const created = emptyBlock(type, site as Parameters<typeof emptyBlock>[1]);
|
|
286
|
+
if (!created.known) throw new Error(`addBlock: "${type}" isn't a block type this site declares`);
|
|
287
|
+
const block: EditorBlock = { ...created, key: newKey() };
|
|
288
|
+
const next = [...blocks()];
|
|
289
|
+
next.splice(clamp(index ?? next.length, next.length), 0, block);
|
|
290
|
+
change(next, { kind: 'add', key: block.key });
|
|
291
|
+
return block.key;
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
addShared(name, index) {
|
|
295
|
+
if (!editable() || !builder()) return '';
|
|
296
|
+
const block: EditorBlock = { source: null, type: '', known: false, data: { shared: name }, shared: name, key: newKey() };
|
|
297
|
+
const next = [...blocks()];
|
|
298
|
+
next.splice(clamp(index ?? next.length, next.length), 0, block);
|
|
299
|
+
change(next, { kind: 'add', key: block.key });
|
|
300
|
+
return block.key;
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
removeBlock(key, options = {}) {
|
|
304
|
+
if (!editable() || !builder()) return null;
|
|
305
|
+
const index = indexOf(key);
|
|
306
|
+
const block = blocks()[index];
|
|
307
|
+
if (!block) return null;
|
|
308
|
+
const entry: UndoEntry = { id: nextUndo++, label: options.label ?? `Removed ${blockLabel(site, block)}`, kind: 'block', block, index };
|
|
309
|
+
const next = blocks().filter((b) => b.key !== key);
|
|
310
|
+
change(next, { kind: 'remove', key }, { undo: pushUndo(entry) });
|
|
311
|
+
return entry;
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
moveBlock(key, toIndex) {
|
|
315
|
+
if (!editable() || !builder()) return;
|
|
316
|
+
const from = indexOf(key);
|
|
317
|
+
if (from < 0) return;
|
|
318
|
+
const next = [...blocks()];
|
|
319
|
+
const [block] = next.splice(from, 1);
|
|
320
|
+
const to = clamp(toIndex, next.length);
|
|
321
|
+
if (to === from) return;
|
|
322
|
+
next.splice(to, 0, block!);
|
|
323
|
+
change(next, { kind: 'move', key });
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
removeItem(key, field, index, options = {}) {
|
|
327
|
+
if (!editable()) return null;
|
|
328
|
+
const prop = scopedProp(key);
|
|
329
|
+
if (prop) {
|
|
330
|
+
const current = state.get();
|
|
331
|
+
if (!hasScope(current.document, prop) || field === '') return null;
|
|
332
|
+
const list = getAtPath(current.document[prop] ?? {}, field);
|
|
333
|
+
if (!Array.isArray(list) || !Number.isInteger(index) || index < 0 || index >= list.length) return null;
|
|
334
|
+
const entry: UndoEntry = { id: nextUndo++, label: options.label ?? 'Removed an item', kind: 'item', key, field, item: list[index], index };
|
|
335
|
+
const value = setAtPath(current.document[prop] ?? {}, field, list.filter((_, i) => i !== index)) as Record<string, unknown>;
|
|
336
|
+
commit({ ...current, undo: pushUndo(entry), document: { ...current.document, [prop]: value }, revision: current.revision + 1 }, { kind: 'field', key, field, origin: 'sidebar' });
|
|
337
|
+
return entry;
|
|
338
|
+
}
|
|
339
|
+
const at = indexOf(key);
|
|
340
|
+
const block = blocks()[at];
|
|
341
|
+
if (!block || !block.known || field === '') return null;
|
|
342
|
+
const list = getAtPath(block.data, field);
|
|
343
|
+
if (!Array.isArray(list) || !Number.isInteger(index) || index < 0 || index >= list.length) return null;
|
|
344
|
+
const entry: UndoEntry = { id: nextUndo++, label: options.label ?? 'Removed an item', kind: 'item', key, field, item: list[index], index };
|
|
345
|
+
const next = [...blocks()];
|
|
346
|
+
next[at] = { ...block, data: setAtPath(block.data, field, list.filter((_, i) => i !== index)) as Record<string, unknown> };
|
|
347
|
+
change(next, { kind: 'field', key, field, origin: 'sidebar' }, { undo: pushUndo(entry) });
|
|
348
|
+
return entry;
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
restoreBlock(block, index) {
|
|
352
|
+
if (!editable() || !builder()) return;
|
|
353
|
+
if (indexOf(block.key) >= 0) return;
|
|
354
|
+
const next = [...blocks()];
|
|
355
|
+
next.splice(clamp(index, next.length), 0, block);
|
|
356
|
+
change(next, { kind: 'restore', key: block.key });
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
undo() {
|
|
360
|
+
if (!editable()) return null;
|
|
361
|
+
const stack = state.get().undo;
|
|
362
|
+
const entry = stack.at(-1);
|
|
363
|
+
if (!entry) return null;
|
|
364
|
+
const rest = stack.slice(0, -1);
|
|
365
|
+
if (entry.kind === 'block') {
|
|
366
|
+
if (indexOf(entry.block.key) >= 0) {
|
|
367
|
+
state.set({ ...state.get(), undo: rest });
|
|
368
|
+
return entry;
|
|
369
|
+
}
|
|
370
|
+
const next = [...blocks()];
|
|
371
|
+
next.splice(clamp(entry.index, next.length), 0, entry.block);
|
|
372
|
+
change(next, { kind: 'restore', key: entry.block.key }, { undo: rest });
|
|
373
|
+
} else if (entry.kind === 'item' && scopedProp(entry.key)) {
|
|
374
|
+
const prop = scopedProp(entry.key)!;
|
|
375
|
+
const current = state.get();
|
|
376
|
+
const list = hasScope(current.document, prop) ? getAtPath(current.document[prop] ?? {}, entry.field) : undefined;
|
|
377
|
+
if (!Array.isArray(list)) {
|
|
378
|
+
state.set({ ...current, undo: rest });
|
|
379
|
+
return entry;
|
|
380
|
+
}
|
|
381
|
+
const items = [...list];
|
|
382
|
+
items.splice(clamp(entry.index, items.length), 0, entry.item);
|
|
383
|
+
const value = setAtPath(current.document[prop] ?? {}, entry.field, items) as Record<string, unknown>;
|
|
384
|
+
commit({ ...current, undo: rest, document: { ...current.document, [prop]: value }, revision: current.revision + 1 }, { kind: 'field', key: entry.key, field: entry.field, origin: 'sidebar' });
|
|
385
|
+
} else if (entry.kind === 'item') {
|
|
386
|
+
const index = indexOf(entry.key);
|
|
387
|
+
const block = blocks()[index];
|
|
388
|
+
const list = block ? getAtPath(block.data, entry.field) : undefined;
|
|
389
|
+
if (!block || !Array.isArray(list)) {
|
|
390
|
+
state.set({ ...state.get(), undo: rest });
|
|
391
|
+
return entry;
|
|
392
|
+
}
|
|
393
|
+
const items = [...list];
|
|
394
|
+
items.splice(clamp(entry.index, items.length), 0, entry.item);
|
|
395
|
+
const next = [...blocks()];
|
|
396
|
+
next[index] = { ...block, data: setAtPath(block.data, entry.field, items) as Record<string, unknown> };
|
|
397
|
+
change(next, { kind: 'field', key: entry.key, field: entry.field, origin: 'sidebar' }, { undo: rest });
|
|
398
|
+
} else if (scopedProp(entry.key)) {
|
|
399
|
+
const prop = scopedProp(entry.key)!;
|
|
400
|
+
const current = state.get();
|
|
401
|
+
if (!hasScope(current.document, prop)) {
|
|
402
|
+
state.set({ ...current, undo: rest });
|
|
403
|
+
return entry;
|
|
404
|
+
}
|
|
405
|
+
const value = setAtPath(current.document[prop] ?? {}, entry.field, entry.value) as Record<string, unknown>;
|
|
406
|
+
commit({ ...current, undo: rest, document: { ...current.document, [prop]: value }, revision: current.revision + 1 }, { kind: 'field', key: entry.key, field: entry.field, origin: 'sidebar' });
|
|
407
|
+
} else {
|
|
408
|
+
const index = indexOf(entry.key);
|
|
409
|
+
const block = blocks()[index];
|
|
410
|
+
if (!block) {
|
|
411
|
+
state.set({ ...state.get(), undo: rest });
|
|
412
|
+
return entry;
|
|
413
|
+
}
|
|
414
|
+
const next = [...blocks()];
|
|
415
|
+
next[index] = { ...block, data: setAtPath(block.data, entry.field, entry.value) as Record<string, unknown> };
|
|
416
|
+
change(next, { kind: 'field', key: entry.key, field: entry.field, origin: 'sidebar' }, { undo: rest });
|
|
417
|
+
}
|
|
418
|
+
return entry;
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
wire() {
|
|
422
|
+
const { document } = state.get();
|
|
423
|
+
const plain = ({ source, type, known, data, shared }: EditorBlock): DocumentBlock => ({ source, type, known, data, ...(shared !== undefined ? { shared } : {}) });
|
|
424
|
+
const first = document.blocks[0];
|
|
425
|
+
switch (document.kind) {
|
|
426
|
+
case 'fields':
|
|
427
|
+
return { kind: 'fields', title: document.title, data: first?.data ?? {}, ...seoWire() };
|
|
428
|
+
case 'settings':
|
|
429
|
+
return { kind: 'settings', title: document.title, data: (first?.data ?? {}) as Extract<AnyDocument, { kind: 'settings' }>['data'] };
|
|
430
|
+
case 'shared':
|
|
431
|
+
return { kind: 'shared', title: document.title, block: first ? plain(first) : { source: 0, type: '', known: false, data: {} } };
|
|
432
|
+
default:
|
|
433
|
+
return {
|
|
434
|
+
title: document.title,
|
|
435
|
+
blocks: document.blocks.map(plain),
|
|
436
|
+
...seoWire(),
|
|
437
|
+
...(document.data !== undefined ? { data: document.data } : {}),
|
|
438
|
+
...(document.hidden !== undefined ? { hidden: document.hidden } : {}),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
/** The page's `seo`, or its page fields, as a stand-in block (undefined for a document without that scope). */
|
|
445
|
+
function scopedBlock(key: string, prop: Scoped): EditorBlock | undefined {
|
|
446
|
+
const { document } = state.get();
|
|
447
|
+
if (!hasScope(document, prop)) return undefined;
|
|
448
|
+
return { key, source: null, type: '', known: true, data: document[prop] ?? {} };
|
|
449
|
+
}
|
|
450
|
+
function setScoped(key: string, prop: Scoped, field: string, value: unknown, undoLabel?: string) {
|
|
451
|
+
const current = state.get();
|
|
452
|
+
if (!hasScope(current.document, prop) || field === '') return;
|
|
453
|
+
const before = getAtPath(current.document[prop] ?? {}, field);
|
|
454
|
+
if (same(before, value)) return;
|
|
455
|
+
const next = setAtPath(current.document[prop] ?? {}, field, value) as Record<string, unknown>;
|
|
456
|
+
const undo = undoLabel ? pushUndo({ id: nextUndo++, label: undoLabel, kind: 'field', key, field, value: before }) : current.undo;
|
|
457
|
+
commit({ ...current, undo, document: { ...current.document, [prop]: next }, revision: current.revision + 1 }, { kind: 'field', key, field, origin: 'sidebar' });
|
|
458
|
+
}
|
|
459
|
+
/** `seo` for the wire, only when the document has one (absent: the file's stays as it is). */
|
|
460
|
+
function seoWire(): { seo?: Seo } {
|
|
461
|
+
const seo = state.get().document.seo;
|
|
462
|
+
return seo !== undefined ? { seo: seo as Seo } : {};
|
|
463
|
+
}
|
|
464
|
+
return store;
|
|
465
|
+
}
|