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
package/src/document.ts
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documents as the package and the editor see them, and how they're parsed from
|
|
3
|
+
* a content file. Four kinds (M5a → Core framing), one per kind of collection:
|
|
4
|
+
*
|
|
5
|
+
* SiteDocument a section builder (Pages, Journal): `{ title, blocks }`, and its
|
|
6
|
+
* page fields' values as `data` when the collection has some (M6c G2)
|
|
7
|
+
* FieldsDocument a list-of-fields document (case studies): `{ kind: 'fields', title, data }`
|
|
8
|
+
* SharedDocument a shared block: `{ kind: 'shared', title, block }`
|
|
9
|
+
* SettingsDocument Site Settings: `{ kind: 'settings', title, data }`
|
|
10
|
+
*
|
|
11
|
+
* A SiteDocument has no `kind` (or `'sections'`): M3's shape, unchanged.
|
|
12
|
+
* `parseAnyDocument(raw, site, collection)` reads any of them by its collection's kind.
|
|
13
|
+
*
|
|
14
|
+
* Block identity: every block carries `source`, its index in the file's `blocks`
|
|
15
|
+
* array as read, or `null` for a block the editor added. The editor keeps `source`
|
|
16
|
+
* on a block through reorders, and drops the block to remove it (bringing it back
|
|
17
|
+
* with Undo restores the same `source`). The serializer (serialize.ts) uses it to
|
|
18
|
+
* find each block's raw form in the file, so an unknown block, or any block the
|
|
19
|
+
* editor didn't change, is written back byte for byte wherever it moves (Decision 7).
|
|
20
|
+
* `source` is never written to the file.
|
|
21
|
+
*/
|
|
22
|
+
import type { ResolvedCollection, ResolvedSite } from './config.ts';
|
|
23
|
+
import { fieldAtPath, firstImagePath, getAtPath, headingPaths, isEmptyValue, list, parseField, text, type FieldValue, type Field, type Fields } from './fields.ts';
|
|
24
|
+
import { parseSource, type ParsedSource } from './json-source.ts';
|
|
25
|
+
import { isFileKey } from './files.ts';
|
|
26
|
+
import { parseMedia } from './media-value.ts';
|
|
27
|
+
import { parseRedirects, type Redirect } from './redirects.ts';
|
|
28
|
+
import { parseFormRecipients, parseGoogleTag, parseSeo, type Seo } from './seo.ts';
|
|
29
|
+
|
|
30
|
+
export interface DocumentBlock {
|
|
31
|
+
/** Index of the block in the file's `blocks` as read; `null` for a block the editor added. */
|
|
32
|
+
source: number | null;
|
|
33
|
+
/** The block's `type` (`''` when the file's block has none). */
|
|
34
|
+
type: string;
|
|
35
|
+
/** Whether the site's config knows this type. An unknown block is skipped in the render (Decision 7). */
|
|
36
|
+
known: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Known: the parsed value of every field in the block's schema, keyed by field.
|
|
39
|
+
* Unknown: the block's raw keys (minus `type`) as read, so the editor can
|
|
40
|
+
* summarise what's kept (Decision 21); the serializer never writes these back,
|
|
41
|
+
* it copies the raw block instead.
|
|
42
|
+
*/
|
|
43
|
+
data: Record<string, unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* A shared-block entry (M5a Decision 8: `{ "shared": "footer" }`): the shared
|
|
46
|
+
* block's path in the `shared` collection. Such an entry is never `known` (it's
|
|
47
|
+
* selected, moved and removed like a block, never edited on the page) and is
|
|
48
|
+
* copied from the file as read; the render resolves it to the shared block.
|
|
49
|
+
*/
|
|
50
|
+
shared?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SiteDocument {
|
|
54
|
+
kind?: 'sections';
|
|
55
|
+
title: string;
|
|
56
|
+
blocks: DocumentBlock[];
|
|
57
|
+
/**
|
|
58
|
+
* M6 Decision 13: the page's search and sharing settings (seo.ts). Present when the
|
|
59
|
+
* file has the key (or the editor sent it); a write with it absent leaves the file's
|
|
60
|
+
* own `seo` as it is.
|
|
61
|
+
*/
|
|
62
|
+
seo?: Seo;
|
|
63
|
+
/**
|
|
64
|
+
* M9 Decision 7: the page is hidden from the site (the file's top-level `"hidden": true`;
|
|
65
|
+
* absent means shown). Not served, not in the sitemap, not offered by the link picker; still
|
|
66
|
+
* edited, previewed and published in /admin. The editor sends `false` to unhide, which takes
|
|
67
|
+
* the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
|
|
68
|
+
*/
|
|
69
|
+
hidden?: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* M6c (G2): the page fields' parsed values, keyed by field, every field of the
|
|
72
|
+
* collection's `fields` present (the file's top-level keys after `title`). Present
|
|
73
|
+
* only when the collection has page fields; a write with it absent leaves the
|
|
74
|
+
* file's as they are.
|
|
75
|
+
*/
|
|
76
|
+
data?: Record<string, unknown>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A list-of-fields document (Decision 7): its title and its fields' parsed values,
|
|
81
|
+
* keyed by field, every field of the collection's schema present. Keys the schema
|
|
82
|
+
* doesn't know aren't here; the serializer keeps them in the file.
|
|
83
|
+
*/
|
|
84
|
+
export interface FieldsDocument {
|
|
85
|
+
kind: 'fields';
|
|
86
|
+
title: string;
|
|
87
|
+
data: Record<string, unknown>;
|
|
88
|
+
/** M6 Decision 13, as on a section builder's document. */
|
|
89
|
+
seo?: Seo;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A shared block (Decision 8): `{ "title", "block": { "type", ...fields } }`. */
|
|
93
|
+
export interface SharedDocument {
|
|
94
|
+
kind: 'shared';
|
|
95
|
+
title: string;
|
|
96
|
+
/** The block, parsed as a page's block is (`source` 0). An unknown type is kept and skipped, as on a page. */
|
|
97
|
+
block: DocumentBlock;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A brand slot's image (M5b Decision 11): image-only, no alt (the logo's text alternative is the brand's name). */
|
|
101
|
+
export interface BrandImage {
|
|
102
|
+
key: string;
|
|
103
|
+
w?: number;
|
|
104
|
+
h?: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Site Settings' brand (Decision 10), as typed: `accent` may not be a colour yet (Decision 15). M5b adds the logo and icon. */
|
|
108
|
+
export interface SettingsBrand {
|
|
109
|
+
name: string;
|
|
110
|
+
accent: string;
|
|
111
|
+
logo: BrandImage | null;
|
|
112
|
+
icon: BrandImage | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Site Settings (Decision 10): `data` holds `editors` (a list of strings), `brand`
|
|
117
|
+
* (`{ name, accent }`) and the site's own `settings.fields`, parsed. M6 Decision 14 adds
|
|
118
|
+
* `seo` (the defaults), `googleTag` and `formRecipients`, each present when the file
|
|
119
|
+
* has it (or the editor sent it); a write without one leaves the file's as it is.
|
|
120
|
+
*/
|
|
121
|
+
export interface SettingsDocument {
|
|
122
|
+
kind: 'settings';
|
|
123
|
+
title: string;
|
|
124
|
+
data: Record<string, unknown> & { editors: string[]; brand: SettingsBrand; seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** M6: Site Settings' search and sharing keys, parsed, each only when `raw` has it. */
|
|
128
|
+
function settingsM6(raw: Record<string, unknown>): { seo?: Seo; googleTag?: string; formRecipients?: Record<string, string[]>; redirects?: Redirect[] } {
|
|
129
|
+
return {
|
|
130
|
+
// Pre-M7 item 8: the redirects changing an address adds (redirects.ts).
|
|
131
|
+
...(Object.hasOwn(raw, 'redirects') ? { redirects: parseRedirects(raw.redirects) } : {}),
|
|
132
|
+
...(Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {}),
|
|
133
|
+
...(Object.hasOwn(raw, 'googleTag') ? { googleTag: parseGoogleTag(raw.googleTag) } : {}),
|
|
134
|
+
...(Object.hasOwn(raw, 'formRecipients') ? { formRecipients: parseFormRecipients(raw.formRecipients) } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** M6: a page's `seo`, parsed, only when `raw` has it. */
|
|
139
|
+
const seoOf = (raw: Record<string, unknown>): { seo?: Seo } => (Object.hasOwn(raw, 'seo') ? { seo: parseSeo(raw.seo) } : {});
|
|
140
|
+
|
|
141
|
+
/** Any document. */
|
|
142
|
+
export type AnyDocument = SiteDocument | FieldsDocument | SharedDocument | SettingsDocument;
|
|
143
|
+
/** What a page renders: a section builder, a fields document, or a shared block on its own (its preview). */
|
|
144
|
+
export type PageDocument = SiteDocument | FieldsDocument | SharedDocument;
|
|
145
|
+
export type DocumentKind = 'sections' | 'fields' | 'shared' | 'settings';
|
|
146
|
+
|
|
147
|
+
/** A document's kind (a SiteDocument has none, or `'sections'`). */
|
|
148
|
+
export function documentKind(document: AnyDocument): DocumentKind {
|
|
149
|
+
return document.kind ?? 'sections';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const isObject = (raw: unknown): raw is Record<string, unknown> => !!raw && typeof raw === 'object' && !Array.isArray(raw);
|
|
153
|
+
const asObject = (raw: unknown): Record<string, unknown> => (isObject(raw) ? raw : {});
|
|
154
|
+
const titleOf = (doc: Record<string, unknown>) => (typeof doc.title === 'string' ? doc.title : '');
|
|
155
|
+
|
|
156
|
+
/** Every field's parsed value from a raw object (a missing key parses as empty). */
|
|
157
|
+
export function parseFields(fields: Fields, raw: Record<string, unknown>): Record<string, unknown> {
|
|
158
|
+
const data: Record<string, unknown> = {};
|
|
159
|
+
for (const [key, field] of Object.entries(fields)) data[key] = parseField(field, raw[key]);
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** A brand slot's raw value as an image (`{ key, w, h }`), or null: a clip, or anything else, is no image. */
|
|
164
|
+
export function parseBrandImage(raw: unknown): BrandImage | null {
|
|
165
|
+
const v = parseMedia(raw);
|
|
166
|
+
if (!v || !('key' in v) || isFileKey(v.key)) return null;
|
|
167
|
+
const out: BrandImage = { key: v.key };
|
|
168
|
+
if (v.w !== undefined) out.w = v.w;
|
|
169
|
+
if (v.h !== undefined) out.h = v.h;
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Site Settings' brand from its raw value: two strings, trimmed (`''` when missing), and the logo and icon (null when unset). */
|
|
174
|
+
export function parseSettingsBrand(raw: unknown): SettingsBrand {
|
|
175
|
+
const o = asObject(raw);
|
|
176
|
+
return {
|
|
177
|
+
name: typeof o.name === 'string' ? o.name.trim() : '',
|
|
178
|
+
accent: typeof o.accent === 'string' ? o.accent.trim() : '',
|
|
179
|
+
logo: parseBrandImage(o.logo),
|
|
180
|
+
icon: parseBrandImage(o.icon),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Site Settings' `editors` (Decision 10): `list(text())`, each an address or an `@domain`. */
|
|
185
|
+
export const EDITORS_FIELD = list(text(), { label: 'Editors' });
|
|
186
|
+
|
|
187
|
+
/** Site Settings' editors, parsed as the field reads them (blank entries kept: they're the editor's to drop). */
|
|
188
|
+
export function parseEditors(raw: unknown): string[] {
|
|
189
|
+
return parseField(EDITORS_FIELD, raw) as string[];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Any document's plain JSON value, by its collection's kind. Never throws on content:
|
|
194
|
+
* a value of the wrong shape parses as empty (and is kept in the file, Decision 7).
|
|
195
|
+
*/
|
|
196
|
+
export function parseAnyDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>, collection: Pick<ResolvedCollection, 'kind' | 'fields'>): AnyDocument {
|
|
197
|
+
const doc = asObject(raw);
|
|
198
|
+
switch (collection.kind) {
|
|
199
|
+
case 'fields':
|
|
200
|
+
return { kind: 'fields', title: titleOf(doc), data: parseFields(collection.fields ?? {}, doc), ...seoOf(doc) };
|
|
201
|
+
case 'shared':
|
|
202
|
+
return { kind: 'shared', title: titleOf(doc), block: parseBlock(doc.block, 0, site) };
|
|
203
|
+
case 'settings':
|
|
204
|
+
return {
|
|
205
|
+
kind: 'settings',
|
|
206
|
+
title: titleOf(doc),
|
|
207
|
+
data: { ...parseFields(collection.fields ?? {}, doc), editors: parseEditors(doc.editors), brand: parseSettingsBrand(doc.brand), ...settingsM6(doc) },
|
|
208
|
+
};
|
|
209
|
+
default:
|
|
210
|
+
return { ...parseDocument(raw, site), ...pageDataOf(collection, doc) };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** M6c (G2): a section builder's page fields parsed from `raw`, only when its collection has some. */
|
|
215
|
+
function pageDataOf(collection: Pick<ResolvedCollection, 'fields'>, raw: Record<string, unknown>): { data?: Record<string, unknown> } {
|
|
216
|
+
return collection.fields && Object.keys(collection.fields).length ? { data: parseFields(collection.fields, raw) } : {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Parse a document's plain JSON value. Never throws on content: odd blocks become unknown ones. */
|
|
220
|
+
export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>): SiteDocument {
|
|
221
|
+
const doc = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
222
|
+
const rawBlocks = Array.isArray(doc.blocks) ? doc.blocks : [];
|
|
223
|
+
return {
|
|
224
|
+
title: typeof doc.title === 'string' ? doc.title : '',
|
|
225
|
+
blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
|
|
226
|
+
...seoOf(doc),
|
|
227
|
+
...(doc.hidden === true ? { hidden: true } : {}),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Parse a content file's text. Throws JSON.parse's SyntaxError on invalid JSON. */
|
|
232
|
+
export function parseDocumentText(text: string, site: Pick<ResolvedSite, 'blocks'>): { source: ParsedSource; document: SiteDocument } {
|
|
233
|
+
const source = parseSource(text);
|
|
234
|
+
return { source, document: parseDocument(source.value, site) };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `record[key]` for the record's own keys only. Block types, collections, text styles
|
|
239
|
+
* and field names come from content files and requests, so a type such as
|
|
240
|
+
* "constructor", "toString" or "__proto__" must read as unknown, never as Object's
|
|
241
|
+
* built-ins (which would make a whole file fail to parse).
|
|
242
|
+
*/
|
|
243
|
+
export function own<T>(record: Readonly<Record<string, T>>, key: string): T | undefined {
|
|
244
|
+
return Object.hasOwn(record, key) ? record[key] : undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function parseBlock(raw: unknown, source: number | null, site: Pick<ResolvedSite, 'blocks'>): DocumentBlock {
|
|
248
|
+
const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : null;
|
|
249
|
+
// A shared-block entry (Decision 8): kept, never edited here; the render resolves it.
|
|
250
|
+
if (obj && typeof obj.shared === 'string') {
|
|
251
|
+
const { type: _t, ...rest } = obj;
|
|
252
|
+
return { source, type: typeof obj.type === 'string' ? obj.type : '', known: false, data: rest, shared: obj.shared };
|
|
253
|
+
}
|
|
254
|
+
const type = obj && typeof obj.type === 'string' ? obj.type : '';
|
|
255
|
+
const schema = obj ? own(site.blocks, type) : undefined;
|
|
256
|
+
if (!obj || !schema) {
|
|
257
|
+
const { type: _type, ...rest } = obj ?? {};
|
|
258
|
+
return { source, type, known: false, data: rest };
|
|
259
|
+
}
|
|
260
|
+
const data: Record<string, unknown> = {};
|
|
261
|
+
for (const [key, field] of Object.entries(schema.fields)) data[key] = parseField(field, obj[key]);
|
|
262
|
+
return { source, type, known: true, data };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Make a document that came from outside (the editor's POST to the preview route)
|
|
267
|
+
* safe to render: every known block's fields re-parsed by their schema, so a value
|
|
268
|
+
* of the wrong shape renders as empty instead of throwing. Sources are kept.
|
|
269
|
+
*/
|
|
270
|
+
export function normaliseDocument(input: unknown, site: Pick<ResolvedSite, 'blocks'>): SiteDocument {
|
|
271
|
+
const doc = input && typeof input === 'object' ? (input as Partial<SiteDocument>) : {};
|
|
272
|
+
const blocks = Array.isArray(doc.blocks) ? doc.blocks : [];
|
|
273
|
+
return {
|
|
274
|
+
title: typeof doc.title === 'string' ? doc.title : '',
|
|
275
|
+
blocks: blocks.map((b): DocumentBlock => {
|
|
276
|
+
const block = b && typeof b === 'object' ? (b as Partial<DocumentBlock>) : {};
|
|
277
|
+
const source = typeof block.source === 'number' ? block.source : null;
|
|
278
|
+
const type = typeof block.type === 'string' ? block.type : '';
|
|
279
|
+
const data = block.data && typeof block.data === 'object' ? block.data : {};
|
|
280
|
+
// A shared entry: `shared` on the block, or (from an editor that keeps only source, type, known and data) in its raw keys.
|
|
281
|
+
const shared = typeof block.shared === 'string' ? block.shared : block.known !== true && typeof data.shared === 'string' ? data.shared : undefined;
|
|
282
|
+
if (shared !== undefined) return { source, type, known: false, data, shared };
|
|
283
|
+
const schema = own(site.blocks, type);
|
|
284
|
+
if (!schema) return { source, type, known: false, data };
|
|
285
|
+
const parsed: Record<string, unknown> = {};
|
|
286
|
+
for (const [key, field] of Object.entries(schema.fields)) parsed[key] = parseField(field, data[key]);
|
|
287
|
+
return { source, type, known: true, data: parsed };
|
|
288
|
+
}),
|
|
289
|
+
...seoOf(asObject(input)),
|
|
290
|
+
// M9 Decision 7: true hides, false unhides, absent leaves the file's as it is.
|
|
291
|
+
...(typeof doc.hidden === 'boolean' ? { hidden: doc.hidden } : {}),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Any document that came from outside (the editor's POST to the preview or write
|
|
297
|
+
* route), made safe by its collection's kind: every field re-parsed by its schema.
|
|
298
|
+
* A shape that doesn't match the collection's kind reads as an empty one of that kind.
|
|
299
|
+
*/
|
|
300
|
+
export function normaliseAnyDocument(input: unknown, site: Pick<ResolvedSite, 'blocks'>, collection: Pick<ResolvedCollection, 'kind' | 'fields'>): AnyDocument {
|
|
301
|
+
const doc = asObject(input);
|
|
302
|
+
switch (collection.kind) {
|
|
303
|
+
case 'fields':
|
|
304
|
+
return { kind: 'fields', title: titleOf(doc), data: parseFields(collection.fields ?? {}, asObject(doc.data)), ...seoOf(doc) };
|
|
305
|
+
case 'shared': {
|
|
306
|
+
const [block] = normaliseDocument({ blocks: [doc.block ?? {}] }, site).blocks;
|
|
307
|
+
return { kind: 'shared', title: titleOf(doc), block: { ...block!, source: 0 } };
|
|
308
|
+
}
|
|
309
|
+
case 'settings': {
|
|
310
|
+
const data = asObject(doc.data);
|
|
311
|
+
return {
|
|
312
|
+
kind: 'settings',
|
|
313
|
+
title: titleOf(doc),
|
|
314
|
+
data: { ...parseFields(collection.fields ?? {}, data), editors: parseEditors(data.editors), brand: parseSettingsBrand(data.brand), ...settingsM6(data) },
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
default:
|
|
318
|
+
// `data` absent (or not an object) means "leave the file's page fields as they are"
|
|
319
|
+
// (SiteDocument.data): parsing an empty object would write every field's default.
|
|
320
|
+
return { ...normaliseDocument(input, site), ...(isObject(doc.data) ? pageDataOf(collection, doc.data) : {}) };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The shared block a block-list entry names (Decision 8), or undefined: `shared` on
|
|
326
|
+
* the block, or, for a block the editor holds as `{ source, type, known, data }`, the
|
|
327
|
+
* `shared` key among its raw keys.
|
|
328
|
+
*/
|
|
329
|
+
export function sharedEntryOf(block: Pick<DocumentBlock, 'known' | 'data' | 'shared'>): string | undefined {
|
|
330
|
+
if (typeof block.shared === 'string') return block.shared;
|
|
331
|
+
return !block.known && typeof block.data?.shared === 'string' ? block.data.shared : undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** A new block of a type, with every field empty (its parsed default). For the editor's picker. */
|
|
335
|
+
export function emptyBlock(type: string, site: Pick<ResolvedSite, 'blocks'>): DocumentBlock {
|
|
336
|
+
return parseBlock({ type }, null, site);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Rendering contract ────────────────────────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
/** A document another component reads through the render context (Decision 9). */
|
|
342
|
+
export interface ReadEntry {
|
|
343
|
+
collection: string;
|
|
344
|
+
path: string;
|
|
345
|
+
/** Its page's URL, or null (a shared block, Site Settings). */
|
|
346
|
+
url: string | null;
|
|
347
|
+
title: string;
|
|
348
|
+
document: AnyDocument;
|
|
349
|
+
/**
|
|
350
|
+
* A fields document's (or Site Settings') render data: each field as a component
|
|
351
|
+
* gets it (fields.ts → renderData; a reference to a document that doesn't exist is
|
|
352
|
+
* null). For a section builder with page fields (M6c G2), those fields' render data
|
|
353
|
+
* (a game's tile details, read by a block on another page). Empty otherwise.
|
|
354
|
+
*/
|
|
355
|
+
data: Record<string, unknown>;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Read access to the site's other documents from a render (M5a Decision 9), **from
|
|
360
|
+
* the source the page itself is rendered from**: the disk at build (`main` as
|
|
361
|
+
* deployed), and in a preview the editor's store with autosaves on top. The same
|
|
362
|
+
* parsing either way. Documents that can't be opened (invalid JSON, no title) aren't
|
|
363
|
+
* there.
|
|
364
|
+
*/
|
|
365
|
+
export interface DocumentReader {
|
|
366
|
+
/** One document by collection and path, or null when it doesn't exist (or can't be opened). */
|
|
367
|
+
document(collection: string, path: string): Promise<ReadEntry | null>;
|
|
368
|
+
/** A collection's documents in the editor's order (a flat collection: alphabetical by title). */
|
|
369
|
+
documents(collection: string): Promise<ReadEntry[]>;
|
|
370
|
+
/** Whether a document exists (and opens): what a reference's render checks. */
|
|
371
|
+
exists(collection: string, path: unknown): Promise<boolean>;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* What the Blocks loop gives each block component, beside its data. Built per
|
|
376
|
+
* render from the document (never module state, so concurrent preview renders
|
|
377
|
+
* can't leak into each other: M3 → Watch-outs).
|
|
378
|
+
*/
|
|
379
|
+
/** M9 Decision 9: `<Blocks wrap>`'s rule and its runs, for a site's page component (`import type { WrapBlocks } from 'caracms/document'`). */
|
|
380
|
+
export type { BlockRun, WrapBlock, WrapBlocks } from './block-runs.ts';
|
|
381
|
+
|
|
382
|
+
export interface BlockContext {
|
|
383
|
+
/**
|
|
384
|
+
* The block's position in the document being rendered: the editor's block index (0 for a
|
|
385
|
+
* fields document's or a shared block's own render). It counts every entry in the file's
|
|
386
|
+
* `blocks`, rendered or not, so `index === 0` isn't "the first block on the page" when the
|
|
387
|
+
* first entry doesn't render (an unknown type, a shared block that's gone): use `first`.
|
|
388
|
+
*/
|
|
389
|
+
index: number;
|
|
390
|
+
/**
|
|
391
|
+
* MEC trial item 2: this is the first block the page renders (unknown blocks and shared
|
|
392
|
+
* entries that don't resolve are skipped; a shared block counts where it's placed). A fields
|
|
393
|
+
* document's render, and a shared block's own preview, are always first. Its page's other
|
|
394
|
+
* blocks are never told about it: anything page-wide goes through the page component.
|
|
395
|
+
*/
|
|
396
|
+
first: boolean;
|
|
397
|
+
/** The block's type; for a fields document's render, its collection's name. */
|
|
398
|
+
type: string;
|
|
399
|
+
/**
|
|
400
|
+
* A preview render (Decision 17): only then do the package's components emit
|
|
401
|
+
* `data-cms-*` markers and make plain text fields `contenteditable`.
|
|
402
|
+
*/
|
|
403
|
+
preview: boolean;
|
|
404
|
+
/**
|
|
405
|
+
* The key of this block's field that holds the page's `h1` (Decision 16), or
|
|
406
|
+
* null. Set on the first block, in render order, with a non-empty heading.
|
|
407
|
+
* The package's Lockup and Text use it; `headingTag()` reads it for a block's own markup.
|
|
408
|
+
*/
|
|
409
|
+
h1Field: string | null;
|
|
410
|
+
/**
|
|
411
|
+
* A fields document's render (M5a Decision 7): its collection's name. The package's
|
|
412
|
+
* components then find a field's descriptor in the collection's fields, and the
|
|
413
|
+
* root's marker is `data-cms-document` in place of `data-cms-block` (Decision 16).
|
|
414
|
+
* Null for a block.
|
|
415
|
+
*/
|
|
416
|
+
collection?: string | null;
|
|
417
|
+
/**
|
|
418
|
+
* A shared block rendered on a page through a `{ "shared": … }` entry (Decision 8).
|
|
419
|
+
* It's never edited on the page, so its fields carry no markers; in a preview its
|
|
420
|
+
* root still says where it is (`data-cms-block`, `data-cms-shared`). Null otherwise.
|
|
421
|
+
*/
|
|
422
|
+
shared?: { name: string; preview: boolean } | null;
|
|
423
|
+
/** Read the site's other documents (Decision 9): the case-study list reads its collection here. */
|
|
424
|
+
read: DocumentReader;
|
|
425
|
+
/**
|
|
426
|
+
* Posts and tags, Decision 12: the page of a paged listing this render is (1, 2, …), as the
|
|
427
|
+
* route passes it to RenderDocument. 1 for every page that isn't paged, and always 1 in a preview.
|
|
428
|
+
*/
|
|
429
|
+
listPage: number;
|
|
430
|
+
/**
|
|
431
|
+
* M5b Decision 4's loading rule: the block is one of the first two *rendered* blocks
|
|
432
|
+
* (unknown and skipped ones don't count), so its images load eagerly. A fields
|
|
433
|
+
* document's render is its page's first. Absent: lazy.
|
|
434
|
+
*/
|
|
435
|
+
eager?: boolean;
|
|
436
|
+
/**
|
|
437
|
+
* The path of the one image that gets `fetchpriority="high"`: the first image of the
|
|
438
|
+
* first rendered block (fields.ts → firstImagePath), or a fields document's first
|
|
439
|
+
* (its cover). Null on every other block.
|
|
440
|
+
*/
|
|
441
|
+
priority?: string | null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Props of a block component: `const { block, cms } = Astro.props as BlockProps<…>`. */
|
|
445
|
+
export interface BlockProps<D = Record<string, unknown>> {
|
|
446
|
+
block: D;
|
|
447
|
+
cms: BlockContext;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Props of a fields collection's component (Decision 7): `document` is the
|
|
452
|
+
* document's render data (each field as fields.ts → renderData gives it, a missing
|
|
453
|
+
* reference as null) plus its `title`; `cms` is its render context, as a block's.
|
|
454
|
+
*/
|
|
455
|
+
export interface FieldsDocumentProps<D = Record<string, unknown>> {
|
|
456
|
+
document: D & { title: string };
|
|
457
|
+
cms: BlockContext;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Props of the site's page component. */
|
|
461
|
+
export interface PageProps {
|
|
462
|
+
/** The document: a section builder's, a fields document's, or a shared block alone (its own preview, Decision 8). */
|
|
463
|
+
document: PageDocument;
|
|
464
|
+
/** The collection the document belongs to. */
|
|
465
|
+
collection: string;
|
|
466
|
+
/**
|
|
467
|
+
* MEC trial item 2: the document's address on the site (`/`, `/about`, `/work/harbour-coffee`),
|
|
468
|
+
* the same in a public render and in the editor's preview, so the page component never reads
|
|
469
|
+
* `Astro.url` or `?path=` itself. No trailing slash, not URL-encoded; a paged listing's page 2
|
|
470
|
+
* (`/journal/page/2`) is its document's `/journal` (`listPage` says which page). Null when the
|
|
471
|
+
* document has no page of its own: a shared block's preview, a collection without pages.
|
|
472
|
+
* RenderDocument always gives it (head.ts → documentPath).
|
|
473
|
+
*/
|
|
474
|
+
path: string | null;
|
|
475
|
+
/** A preview render (Decision 17). Pass it on to `<Blocks>`. */
|
|
476
|
+
preview?: boolean;
|
|
477
|
+
/**
|
|
478
|
+
* M6c (G2): the document's own fields' render data, as a component gets them
|
|
479
|
+
* (fields.ts → renderData, a reference to a missing document as null): a section
|
|
480
|
+
* builder's page fields (the header's "side" picked on the page), or a fields
|
|
481
|
+
* document's fields. Empty for a shared block and for a section builder with no page
|
|
482
|
+
* fields. RenderDocument always gives it.
|
|
483
|
+
*/
|
|
484
|
+
data: Record<string, unknown>;
|
|
485
|
+
/**
|
|
486
|
+
* M7 Decision 13: Site Settings' render data, for the page's shell (its menu, its footer):
|
|
487
|
+
* the site's own `settings: { fields }` as a block's are rendered (links as render values,
|
|
488
|
+
* media resolved, a reference to a missing document as null), plus `brand` (`name`, `logo`,
|
|
489
|
+
* `icon`) and `seo` (`title`, `description`, `image`, and the config's `titleSeparator`). `brand.name` is the site's name (the
|
|
490
|
+
* brand's, else the default search title, else "Site"). Read from the render's source, so a
|
|
491
|
+
* preview shows the draft's and a build the published. Never undefined; never `editors`,
|
|
492
|
+
* `formRecipients` or `googleTag`. RenderDocument always gives it (read.ts → settingsRenderData).
|
|
493
|
+
*/
|
|
494
|
+
settings: Record<string, unknown>;
|
|
495
|
+
/**
|
|
496
|
+
* Posts and tags, Decision 12: the page of a paged listing this render is (1 by default, always 1
|
|
497
|
+
* in a preview). Pass it on to `<Blocks>` and `<Head>`, so a paged block shows its page and the
|
|
498
|
+
* title says " — page N".
|
|
499
|
+
*/
|
|
500
|
+
listPage: number;
|
|
501
|
+
/**
|
|
502
|
+
* Posts and tags, Decision 12: the site's other documents, read from the render's source, the
|
|
503
|
+
* same reader a block's `cms.read` is (a post's page finds its neighbours here).
|
|
504
|
+
*/
|
|
505
|
+
read: DocumentReader;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** `h1` if this field holds the page's first heading, otherwise `h2`. */
|
|
509
|
+
export function headingTag(cms: BlockContext, field: string): 'h1' | 'h2' {
|
|
510
|
+
return cms.h1Field === field ? 'h1' : 'h2';
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Is a field one that can hold the page's heading (Decision 16): a lockup, or a text or rich-text field (M9 Decision 11) marked `heading`? */
|
|
514
|
+
export const isHeadingField = (field: Field): boolean => (field.kind === 'lockup' || field.kind === 'text' || field.kind === 'richText') && !!field.heading;
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Decision 16: the index of the first block, in render order (skipping unknown
|
|
518
|
+
* blocks), whose heading field is non-empty; -1 if none. A shared entry counts by
|
|
519
|
+
* the block it resolves to, when the caller gives them (`resolved`, by index).
|
|
520
|
+
*/
|
|
521
|
+
export function firstHeadingIndex(document: SiteDocument, site: Pick<ResolvedSite, 'blocks'>, resolved: ReadonlyMap<number, DocumentBlock> = new Map()): number {
|
|
522
|
+
return document.blocks.findIndex((entry, index) => {
|
|
523
|
+
const block = resolved.get(index) ?? entry;
|
|
524
|
+
if (!block.known) return false;
|
|
525
|
+
const schema = own(site.blocks, block.type);
|
|
526
|
+
if (!schema?.headingField) return false;
|
|
527
|
+
// The heading field may sit in a group (`left.heading`).
|
|
528
|
+
const field = fieldAtPath(schema.fields, schema.headingField)?.field;
|
|
529
|
+
return !!field && !isEmptyValue(field, getAtPath(block.data, schema.headingField) as FieldValue<Field>);
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* The render context for every block of a document, in order (null for unknown
|
|
535
|
+
* blocks, which don't render). `resolved` gives a shared entry's block by its index
|
|
536
|
+
* (Blocks.astro resolves them through `read`); an entry without one doesn't render.
|
|
537
|
+
*/
|
|
538
|
+
export function blockContexts(
|
|
539
|
+
document: SiteDocument,
|
|
540
|
+
site: Pick<ResolvedSite, 'blocks'>,
|
|
541
|
+
preview: boolean,
|
|
542
|
+
read: DocumentReader = EMPTY_READER,
|
|
543
|
+
resolved: ReadonlyMap<number, DocumentBlock> = new Map(),
|
|
544
|
+
listPage = 1,
|
|
545
|
+
): (BlockContext | null)[] {
|
|
546
|
+
const first = firstHeadingIndex(document, site, resolved);
|
|
547
|
+
let rendered = 0;
|
|
548
|
+
return document.blocks.map((entry, index) => {
|
|
549
|
+
const block = resolved.get(index) ?? entry;
|
|
550
|
+
if (!block.known) return null;
|
|
551
|
+
const schema = own(site.blocks, block.type)!;
|
|
552
|
+
const h1Field = index === first ? schema.headingField : null;
|
|
553
|
+
const order = rendered++;
|
|
554
|
+
const media = { first: order === 0, eager: order < 2, priority: order === 0 ? firstImagePath(schema.fields, block.data) : null };
|
|
555
|
+
if (entry.shared !== undefined) return { index, type: block.type, preview: false, h1Field, collection: null, shared: { name: entry.shared, preview }, read, listPage, ...media };
|
|
556
|
+
return { index, type: block.type, preview, h1Field, collection: null, shared: null, read, listPage, ...media };
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* A fields document's heading field (Decision 7): the first heading field with a
|
|
562
|
+
* non-empty value, top-level ones in schema order first, then those inside groups
|
|
563
|
+
* (`left.heading`, fields.ts → headingPaths), or null (the page has no `h1`, as M3
|
|
564
|
+
* accepted). One path at most, so two groups' headings never make two h1s.
|
|
565
|
+
*/
|
|
566
|
+
export function fieldsHeading(fields: Fields, data: Record<string, unknown>): string | null {
|
|
567
|
+
for (const path of headingPaths(fields)) {
|
|
568
|
+
const field = fieldAtPath(fields, path)?.field;
|
|
569
|
+
if (field && !isEmptyValue(field, getAtPath(data, path) as FieldValue<Field>)) return path;
|
|
570
|
+
}
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** A reader with nothing in it (a render with no source: tests, and the default). */
|
|
575
|
+
export const EMPTY_READER: DocumentReader = {
|
|
576
|
+
document: async () => null,
|
|
577
|
+
documents: async () => [],
|
|
578
|
+
exists: async () => false,
|
|
579
|
+
};
|