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/fields.ts
ADDED
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The field helpers a block's schema is built from (Decision 12; Spec → Fields).
|
|
3
|
+
*
|
|
4
|
+
* A field is a plain descriptor, not a validator: JSON-serialisable data the
|
|
5
|
+
* integration hands to the site's routes and the editor (which reads `kind`,
|
|
6
|
+
* `label`, `options` to draw the field), plus the parse and write rules below.
|
|
7
|
+
*
|
|
8
|
+
* text() one line
|
|
9
|
+
* text({ multiline: true }) several lines; a break is stored as `\n` (the line-break rule)
|
|
10
|
+
* text({ lines: true }) several lines, each drawn as its own element (M9 Decision 10)
|
|
11
|
+
* text({ max: 200 }) M6: a soft limit, counted in the editor and amber past it (nothing refused)
|
|
12
|
+
* lockup({ style }) display text set as lines, optionally with phone lines (Decisions 10–11)
|
|
13
|
+
* select({ options }) one of a fixed list
|
|
14
|
+
*
|
|
15
|
+
* M5a (Decision 2) adds:
|
|
16
|
+
*
|
|
17
|
+
* richText({ headings?, links? }) `[{ "p": "…" }, { "ul": ["…"] }]` (rich-text.ts, Decision 3)
|
|
18
|
+
* link() `{ "label", "href" }` (links.ts, Decision 4)
|
|
19
|
+
* buttons({ styles, max? }) `[{ "label", "href", "style" }]`; a button shows only when whole
|
|
20
|
+
* toggle() `true` / `false`
|
|
21
|
+
* number({ min?, max?, step? }) a JSON number, or `null` when cleared
|
|
22
|
+
* date() `"2026-10-15"`, or `null`
|
|
23
|
+
* list(field | fields) `["…"]` for one field, `[{ … }]` for several (Decision 5)
|
|
24
|
+
* reference({ to }) the path of a document in collection `to` (Decision 6)
|
|
25
|
+
*
|
|
26
|
+
* M5b (Decision 3) adds:
|
|
27
|
+
*
|
|
28
|
+
* media({ only?, play?, format? }) an image `{ key, alt, … }` or a Mux clip `{ video, play, … }` (media-value.ts)
|
|
29
|
+
*
|
|
30
|
+
* M6c adds `help` (G8: plain text under the field in the sidebar) to every helper, and
|
|
31
|
+
* `link({ addressOnly: true })` (G9): `{ "href" }`, a link with no words of its own.
|
|
32
|
+
*
|
|
33
|
+
* The editor-friction batch (notes/2026-10-18-editor-friction.md) adds:
|
|
34
|
+
*
|
|
35
|
+
* group({ … }, { label?, help? }) named fields kept together, stored as one object
|
|
36
|
+
* `{ "heading": "…", "text": [ … ] }`; one section in the sidebar
|
|
37
|
+
* buttons({ …, allowNoLink }) a button with words and no link is a plain label ("Coming soon"), not half a button
|
|
38
|
+
*
|
|
39
|
+
* Every value has three layers (Core framing): the descriptor here, the value the
|
|
40
|
+
* file stores and the editor holds (parseField / fieldToJson), and the render's
|
|
41
|
+
* value (renderValue: what a block component gets). A value that breaks a field's
|
|
42
|
+
* rule is kept as typed and flagged (fieldWarnings), and the render gets `null` or
|
|
43
|
+
* text for it (Decision 15).
|
|
44
|
+
*
|
|
45
|
+
* Parsing normalises (text is trimmed, a lockup's blank lines are dropped, an
|
|
46
|
+
* unknown select value falls back to the default). That's why the serializer never
|
|
47
|
+
* writes a parsed value back unless the editor changed it (Decision 7).
|
|
48
|
+
*/
|
|
49
|
+
import { fileAddress, fileKeyOf, fileTypeOf, isFileKey } from './files.ts';
|
|
50
|
+
import { codeRouteAt, type CodeRoute } from './code-routes.ts';
|
|
51
|
+
import { hiddenLinkTarget, hiddenReferenceTarget, internalLinkPath, isLinkHref, missingLinkTarget, validateLink } from './links.ts';
|
|
52
|
+
import { MISSING_ALT_MESSAGE, mediaKind, mediaToJson, parseMedia, renderMedia, type MediaOnly, type MediaValue, type RenderMedia } from './media-value.ts';
|
|
53
|
+
import { readRichText, richTextHrefs, serializeRichText, type HeadingKind, type RichTextOptions, type RichTextValue } from './rich-text.ts';
|
|
54
|
+
|
|
55
|
+
export type { RichTextValue } from './rich-text.ts';
|
|
56
|
+
export type { ClipValue, ImageValue, MediaOnly, MediaValue, PlayAs, RenderClip, RenderImage, RenderMedia } from './media-value.ts';
|
|
57
|
+
|
|
58
|
+
interface FieldBase {
|
|
59
|
+
/** Shown in the editor. Defaults to the field's key, title-cased. */
|
|
60
|
+
label?: string;
|
|
61
|
+
/**
|
|
62
|
+
* M6c (G8): plain text under the field's label in the editor's sidebar (an item
|
|
63
|
+
* field's under its sub-label), saying what it's for or what it does where the
|
|
64
|
+
* preview can't show it. Never on the page.
|
|
65
|
+
*/
|
|
66
|
+
help?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Every helper's own `help` (G8), set on the descriptor only when given, so a field without one is as before. */
|
|
70
|
+
const withHelp = <F extends FieldBase>(field: F, options: { help?: string }): F => (options.help !== undefined ? { ...field, help: options.help } : field);
|
|
71
|
+
|
|
72
|
+
export interface TextField extends FieldBase {
|
|
73
|
+
kind: 'text';
|
|
74
|
+
multiline: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* This field is the block's heading, so it can hold the page's `h1` (Decision 16).
|
|
77
|
+
* Lockups are headings by default; a text field only when it says so.
|
|
78
|
+
*/
|
|
79
|
+
heading: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* M6: a soft limit (the Form block's intro, Decision 17): the editor counts the
|
|
82
|
+
* characters and turns amber past it. Nothing is refused: it's kept as written.
|
|
83
|
+
*/
|
|
84
|
+
max?: number; /**
|
|
85
|
+
* M9 Decision 10: the lines rendering. Each typed line draws as its own element (Text's
|
|
86
|
+
* `lineClass`), blank lines draw nothing, and a single line draws unwrapped. Always
|
|
87
|
+
* multiline: a break is still stored as `\n`.
|
|
88
|
+
*/
|
|
89
|
+
lines?: true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LockupField extends FieldBase {
|
|
93
|
+
kind: 'lockup';
|
|
94
|
+
/** The text style's name in the site's config. */
|
|
95
|
+
style: string;
|
|
96
|
+
heading: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SelectOption<V extends string = string> {
|
|
100
|
+
value: V;
|
|
101
|
+
label: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface SelectField<V extends string = string> extends FieldBase {
|
|
105
|
+
kind: 'select';
|
|
106
|
+
options: readonly SelectOption<V>[];
|
|
107
|
+
/** Used when the file has no value or one the options don't know. Defaults to the first option. */
|
|
108
|
+
default: V;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface RichTextField extends FieldBase {
|
|
112
|
+
kind: 'richText';
|
|
113
|
+
/** The heading kinds allowed: `[]` (default), `['h2']` or `['h2', 'h3']`. */
|
|
114
|
+
headings: readonly HeadingKind[];
|
|
115
|
+
/** Links allowed (default true). */
|
|
116
|
+
links: boolean;
|
|
117
|
+
/**
|
|
118
|
+
* M9 Decision 11: the block's heading, typed on the page with bold and line breaks only.
|
|
119
|
+
* It can hold the page's `h1` (M3 Decision 16) and draws inside its heading tag, its
|
|
120
|
+
* paragraphs as lines joined by `<br>`. No headings, no links, no lists, no italic.
|
|
121
|
+
*/
|
|
122
|
+
heading?: true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface LinkField extends FieldBase {
|
|
126
|
+
kind: 'link';
|
|
127
|
+
/**
|
|
128
|
+
* M6c (G9): a link that's only an address (an image's link, a website, a social
|
|
129
|
+
* profile): stored as `{ "href" }`, no words of its own. Its render value is
|
|
130
|
+
* `{ href }`, or null when it's empty or fails the link rule. Set only when true.
|
|
131
|
+
*/
|
|
132
|
+
addressOnly?: true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `link({ addressOnly: true })` (G9). */
|
|
136
|
+
export interface AddressLinkField extends LinkField {
|
|
137
|
+
addressOnly: true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** An address-only link's value (G9): where it goes, no label. */
|
|
141
|
+
export interface AddressValue {
|
|
142
|
+
href: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ButtonsField<S extends string = string> extends FieldBase {
|
|
146
|
+
kind: 'buttons';
|
|
147
|
+
/** The site's button styles; a new button starts on the first. */
|
|
148
|
+
styles: readonly SelectOption<S>[];
|
|
149
|
+
/** At most this many show (default 2); more is kept, with a warning. */
|
|
150
|
+
max: number;
|
|
151
|
+
/**
|
|
152
|
+
* A button with words and no link is meant ("Coming soon"): it isn't warned, and
|
|
153
|
+
* `<Buttons>` draws it as a plain label with the button's classes. Set only when true.
|
|
154
|
+
*/
|
|
155
|
+
allowNoLink?: true;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface ToggleField extends FieldBase {
|
|
159
|
+
kind: 'toggle';
|
|
160
|
+
/** The value when the file has none (default false). */
|
|
161
|
+
default: boolean;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface NumberField extends FieldBase {
|
|
165
|
+
kind: 'number';
|
|
166
|
+
min?: number;
|
|
167
|
+
max?: number;
|
|
168
|
+
step?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface DateField extends FieldBase {
|
|
172
|
+
kind: 'date';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A list (Decision 5): of one field (`list(text())`, stored as a flat array), or
|
|
177
|
+
* of several (`list({ role: text(), name: text() })`, stored as objects with keys
|
|
178
|
+
* in schema order). Exactly one of `item` and `fields` is set. One level of list inside a
|
|
179
|
+
* list (M7 Decision 14): a list of several fields may have a list among its item fields
|
|
180
|
+
* (`list({ label: text(), links: list(link()) })`), and that inner list can't hold a list,
|
|
181
|
+
* directly or through a group. A list of one field can't be a list.
|
|
182
|
+
*/
|
|
183
|
+
export interface ListField<I extends Field | Fields = Field | Fields> extends FieldBase {
|
|
184
|
+
kind: 'list';
|
|
185
|
+
item: I extends Field ? I : null;
|
|
186
|
+
fields: I extends Fields ? I : null;
|
|
187
|
+
/** The item field whose value labels a collapsed row. Default: the first text field (or the item itself). */
|
|
188
|
+
itemLabel?: string;
|
|
189
|
+
/** M5b Decision 10: fewer items than this is warned (amber), never blocked. */
|
|
190
|
+
min?: number;
|
|
191
|
+
/** M5b Decision 10: more items than this is warned; Add stays on. */
|
|
192
|
+
max?: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A group: named fields kept together (`group({ heading: text(), button: buttons(…) })`),
|
|
197
|
+
* stored as one object with its keys in schema order, and drawn as one section of the
|
|
198
|
+
* sidebar that opens and closes. It holds any field but another group, and a list can't
|
|
199
|
+
* be of a group alone (a list of several fields is that already).
|
|
200
|
+
*/
|
|
201
|
+
export interface GroupField<F extends Fields = Fields> extends FieldBase {
|
|
202
|
+
kind: 'group';
|
|
203
|
+
fields: F;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ReferenceField extends FieldBase {
|
|
207
|
+
kind: 'reference';
|
|
208
|
+
/** The collection the referenced document is in. */
|
|
209
|
+
to: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A media slot (M5b Decision 3): an image or a Mux clip. `only` limits it to one
|
|
214
|
+
* kind (a value of the other is kept and warned); `play: 'loop'` makes it loop-only
|
|
215
|
+
* (the hero): any clip in it renders as a loop. `format: 'png'` serves its images as
|
|
216
|
+
* PNG instead of WebP (M9 Decision 8: WebP smears flat artwork).
|
|
217
|
+
*/
|
|
218
|
+
export interface MediaField extends FieldBase {
|
|
219
|
+
kind: 'media';
|
|
220
|
+
only?: MediaOnly;
|
|
221
|
+
play?: 'loop';
|
|
222
|
+
format?: ImageFormat;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** The format a resized image is served in (M9 Decision 8). WebP unless a media field asks for PNG. */
|
|
226
|
+
export type ImageFormat = 'webp' | 'png';
|
|
227
|
+
|
|
228
|
+
export type Field =
|
|
229
|
+
| TextField
|
|
230
|
+
| LockupField
|
|
231
|
+
| SelectField
|
|
232
|
+
| RichTextField
|
|
233
|
+
| LinkField
|
|
234
|
+
| ButtonsField
|
|
235
|
+
| ToggleField
|
|
236
|
+
| NumberField
|
|
237
|
+
| DateField
|
|
238
|
+
| ListField
|
|
239
|
+
| GroupField
|
|
240
|
+
| ReferenceField
|
|
241
|
+
| MediaField;
|
|
242
|
+
export type Fields = Record<string, Field>;
|
|
243
|
+
|
|
244
|
+
/** Every field kind the package knows. */
|
|
245
|
+
export const FIELD_KINDS = ['text', 'lockup', 'select', 'richText', 'link', 'buttons', 'toggle', 'number', 'date', 'list', 'group', 'reference', 'media'] as const;
|
|
246
|
+
export type FieldKind = (typeof FIELD_KINDS)[number];
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* A lockup's value. `phone: null` means "no phone lines": the main lines show at
|
|
250
|
+
* every width, and the file holds the plain list (Decision 10).
|
|
251
|
+
*/
|
|
252
|
+
export interface LockupValue {
|
|
253
|
+
lines: string[];
|
|
254
|
+
phone: string[] | null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A link's value. `href` is kept as typed, even when it fails the link rule (Decision 15). */
|
|
258
|
+
export interface LinkValue {
|
|
259
|
+
label: string;
|
|
260
|
+
href: string;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface ButtonValue<S extends string = string> {
|
|
264
|
+
label: string;
|
|
265
|
+
href: string;
|
|
266
|
+
style: S;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export type FieldValue<F extends Field> = F extends TextField
|
|
270
|
+
? string
|
|
271
|
+
: F extends LockupField
|
|
272
|
+
? LockupValue
|
|
273
|
+
: F extends SelectField<infer V>
|
|
274
|
+
? V
|
|
275
|
+
: F extends RichTextField
|
|
276
|
+
? RichTextValue
|
|
277
|
+
: F extends LinkField
|
|
278
|
+
? F extends { addressOnly: true }
|
|
279
|
+
? AddressValue | null
|
|
280
|
+
: LinkValue
|
|
281
|
+
: F extends ButtonsField<infer S>
|
|
282
|
+
? ButtonValue<S>[]
|
|
283
|
+
: F extends ToggleField
|
|
284
|
+
? boolean
|
|
285
|
+
: F extends NumberField
|
|
286
|
+
? number | null
|
|
287
|
+
: F extends DateField
|
|
288
|
+
? string | null
|
|
289
|
+
: F extends ReferenceField
|
|
290
|
+
? string | null
|
|
291
|
+
: F extends MediaField
|
|
292
|
+
? RenderMedia | null
|
|
293
|
+
: F extends ListField<infer I>
|
|
294
|
+
? I extends Field
|
|
295
|
+
? FieldValue<I>[]
|
|
296
|
+
: I extends Fields
|
|
297
|
+
? { [K in keyof I]: FieldValue<I[K]> }[]
|
|
298
|
+
: unknown[]
|
|
299
|
+
: F extends GroupField<infer G>
|
|
300
|
+
? { [K in keyof G]: FieldValue<G[K]> }
|
|
301
|
+
: never;
|
|
302
|
+
|
|
303
|
+
/** A block's parsed data, typed from its fields: `BlockData<typeof hero.fields>`. */
|
|
304
|
+
export type BlockData<F extends Fields> = { [K in keyof F]: FieldValue<F[K]> };
|
|
305
|
+
|
|
306
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
export function text(options: { label?: string; help?: string; multiline?: boolean; heading?: boolean; max?: number; lines?: boolean } = {}): TextField {
|
|
309
|
+
const field: TextField = { kind: 'text', label: options.label, multiline: options.multiline ?? options.lines ?? false, heading: options.heading ?? false };
|
|
310
|
+
if (options.max !== undefined) field.max = options.max;
|
|
311
|
+
if (options.lines) field.lines = true;
|
|
312
|
+
return withHelp(field, options);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function lockup(options: { style: string; label?: string; help?: string; heading?: boolean }): LockupField {
|
|
316
|
+
return withHelp({ kind: 'lockup', style: options.style, label: options.label, heading: options.heading ?? true }, options);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const toOptions = <V extends string>(options: readonly (V | SelectOption<V>)[]): SelectOption<V>[] =>
|
|
320
|
+
options.map((o) => (typeof o === 'string' ? { value: o, label: titleCase(o) } : o));
|
|
321
|
+
|
|
322
|
+
export function select<const V extends string>(options: {
|
|
323
|
+
options: readonly (V | SelectOption<V>)[];
|
|
324
|
+
default?: NoInfer<V>;
|
|
325
|
+
label?: string;
|
|
326
|
+
help?: string;
|
|
327
|
+
}): SelectField<V> {
|
|
328
|
+
const list = toOptions(options.options);
|
|
329
|
+
if (list.length === 0) throw new Error('select(): needs at least one option');
|
|
330
|
+
const fallback = options.default ?? list[0]!.value;
|
|
331
|
+
if (!list.some((o) => o.value === fallback)) {
|
|
332
|
+
throw new Error(`select(): the default "${fallback}" isn't one of its options`);
|
|
333
|
+
}
|
|
334
|
+
return withHelp({ kind: 'select', label: options.label, options: list, default: fallback }, options);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Rich text (Decision 3). `headings`: `['h2']` or `['h2', 'h3']`, off by default. `links`: on by default.
|
|
339
|
+
* `heading: true` (M9 Decision 11): the block's heading, bold and line breaks only (links off).
|
|
340
|
+
*/
|
|
341
|
+
export function richText(options: { label?: string; help?: string; headings?: readonly HeadingKind[]; links?: boolean; heading?: boolean } = {}): RichTextField {
|
|
342
|
+
const field: RichTextField = { kind: 'richText', label: options.label, headings: [...(options.headings ?? [])], links: options.links ?? !options.heading };
|
|
343
|
+
if (options.heading) field.heading = true;
|
|
344
|
+
return withHelp(field, options);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* A link: `{ "label", "href" }` (Decision 4). `addressOnly: true` (M6c G9): only where
|
|
349
|
+
* it goes, `{ "href" }`, for a link whose words are the site's (an image, an icon).
|
|
350
|
+
*/
|
|
351
|
+
export function link(options: { label?: string; help?: string; addressOnly: true }): AddressLinkField;
|
|
352
|
+
export function link(options?: { label?: string; help?: string; addressOnly?: false }): LinkField;
|
|
353
|
+
export function link(options: { label?: string; help?: string; addressOnly?: boolean } = {}): LinkField {
|
|
354
|
+
const field: LinkField = { kind: 'link', label: options.label };
|
|
355
|
+
if (options.addressOnly) field.addressOnly = true;
|
|
356
|
+
return withHelp(field, options);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The template's one button shape: `[{ "label", "href", "style" }]`, at most `max` (default 2).
|
|
361
|
+
* `allowNoLink: true`: a button with words and no link is drawn as a plain label, never warned.
|
|
362
|
+
*/
|
|
363
|
+
export function buttons<const S extends string>(options: { styles: readonly (S | SelectOption<S>)[]; max?: number; label?: string; help?: string; allowNoLink?: boolean }): ButtonsField<S> {
|
|
364
|
+
const styles = toOptions(options.styles);
|
|
365
|
+
if (styles.length === 0) throw new Error('buttons(): needs at least one style');
|
|
366
|
+
const field: ButtonsField<S> = { kind: 'buttons', label: options.label, styles, max: options.max ?? 2 };
|
|
367
|
+
if (options.allowNoLink) field.allowNoLink = true;
|
|
368
|
+
return withHelp(field, options);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function toggle(options: { label?: string; help?: string; default?: boolean } = {}): ToggleField {
|
|
372
|
+
return withHelp({ kind: 'toggle', label: options.label, default: options.default ?? false }, options);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function number(options: { label?: string; help?: string; min?: number; max?: number; step?: number } = {}): NumberField {
|
|
376
|
+
const field: NumberField = { kind: 'number', label: options.label };
|
|
377
|
+
if (options.min !== undefined) field.min = options.min;
|
|
378
|
+
if (options.max !== undefined) field.max = options.max;
|
|
379
|
+
if (options.step !== undefined) field.step = options.step;
|
|
380
|
+
return withHelp(field, options);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** A day, `"2026-10-15"`: no time, no zone. */
|
|
384
|
+
export function date(options: { label?: string; help?: string } = {}): DateField {
|
|
385
|
+
return withHelp({ kind: 'date', label: options.label }, options);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const isField = (value: unknown): value is Field =>
|
|
389
|
+
!!value && typeof value === 'object' && typeof (value as { kind?: unknown }).kind === 'string';
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* A list (Decision 5): `list(text())` stores `["…"]`; `list({ role: text(), name: text() })`
|
|
393
|
+
* stores `[{ "role": "…", "name": "…" }]`. `itemLabel` names the item field that labels
|
|
394
|
+
* a collapsed row.
|
|
395
|
+
*/
|
|
396
|
+
export function list<const I extends Field | Fields>(of: I, options: { label?: string; help?: string; itemLabel?: string; min?: number; max?: number } = {}): ListField<I> {
|
|
397
|
+
const single = isField(of);
|
|
398
|
+
return withHelp({
|
|
399
|
+
kind: 'list',
|
|
400
|
+
label: options.label,
|
|
401
|
+
item: (single ? of : null) as ListField<I>['item'],
|
|
402
|
+
fields: (single ? null : of) as ListField<I>['fields'],
|
|
403
|
+
...(options.itemLabel !== undefined ? { itemLabel: options.itemLabel } : {}),
|
|
404
|
+
...(options.min !== undefined ? { min: options.min } : {}),
|
|
405
|
+
...(options.max !== undefined ? { max: options.max } : {}),
|
|
406
|
+
}, options);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* A group: `group({ heading: text(), button: buttons(…) }, { label: 'Left' })` stores
|
|
411
|
+
* `{ "heading": "…", "button": [ … ] }`. Any field can be in one but another group:
|
|
412
|
+
* that's refused here, when the schema is built.
|
|
413
|
+
*/
|
|
414
|
+
export function group<const F extends Fields>(fields: F, options: { label?: string; help?: string } = {}): GroupField<F> {
|
|
415
|
+
for (const [key, f] of Object.entries(fields ?? {})) {
|
|
416
|
+
if (isField(f) && f.kind === 'group') {
|
|
417
|
+
throw new Error(`group(): "${key}" is a group, and a group can't hold another group. Put its fields in this group, or make it a group of its own beside this one.`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return withHelp({ kind: 'group', label: options.label, fields }, options);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* An image or a clip (M5b Decision 3). `only: 'image'` or `'video'` takes one kind;
|
|
425
|
+
* `play: 'loop'` makes the slot loop-only (a clip in it always plays as a loop);
|
|
426
|
+
* `format: 'png'` serves its images as PNG (M9 Decision 8).
|
|
427
|
+
*/
|
|
428
|
+
export function media(options: { label?: string; help?: string; only?: MediaOnly; play?: 'loop'; format?: ImageFormat } = {}): MediaField {
|
|
429
|
+
const field: MediaField = { kind: 'media', label: options.label };
|
|
430
|
+
if (options.only !== undefined) field.only = options.only;
|
|
431
|
+
if (options.play !== undefined) field.play = options.play;
|
|
432
|
+
if (options.format !== undefined) field.format = options.format;
|
|
433
|
+
return withHelp(field, options);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A reference to a document in collection `to`, stored as its path (Decision 6). */
|
|
437
|
+
export function reference(options: { to: string; label?: string; help?: string }): ReferenceField {
|
|
438
|
+
return withHelp({ kind: 'reference', label: options.label, to: options.to }, options);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** `case-studies` → "Case studies"; `heading` → "Heading". */
|
|
442
|
+
export function titleCase(key: string): string {
|
|
443
|
+
const words = key.replace(/[-_]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2').trim().toLowerCase();
|
|
444
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── Parse and write ───────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
/** Lines as a lockup renders them: trimmed, blank lines dropped. Non-strings dropped too. */
|
|
450
|
+
export function normaliseLines(lines: unknown): string[] {
|
|
451
|
+
if (!Array.isArray(lines)) return [];
|
|
452
|
+
return lines
|
|
453
|
+
.filter((l): l is string => typeof l === 'string')
|
|
454
|
+
.map((l) => l.trim())
|
|
455
|
+
.filter((l) => l.length > 0);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const isObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
459
|
+
const str = (value: unknown) => (typeof value === 'string' ? value.trim() : '');
|
|
460
|
+
const DAY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
461
|
+
|
|
462
|
+
/** `"2026-10-15"` when the value is a real day in that form, else null. */
|
|
463
|
+
export function parseDay(value: unknown): string | null {
|
|
464
|
+
if (typeof value !== 'string') return null;
|
|
465
|
+
const m = DAY.exec(value.trim());
|
|
466
|
+
if (!m) return null;
|
|
467
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
468
|
+
const at = new Date(Date.UTC(y, mo - 1, d));
|
|
469
|
+
return at.getUTCFullYear() === y && at.getUTCMonth() === mo - 1 && at.getUTCDate() === d ? m[0]! : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export const richTextOptions = (field: RichTextField): RichTextOptions => ({ headings: field.headings, links: field.links, ...(field.heading ? { heading: true } : {}) });
|
|
473
|
+
|
|
474
|
+
function parseButton(field: ButtonsField, raw: Record<string, unknown>): ButtonValue {
|
|
475
|
+
const style = typeof raw.style === 'string' && field.styles.some((o) => o.value === raw.style) ? raw.style : field.styles[0]!.value;
|
|
476
|
+
return { label: str(raw.label), href: str(raw.href), style };
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function parseItem(fields: Fields, raw: Record<string, unknown>): Record<string, unknown> {
|
|
480
|
+
const out: Record<string, unknown> = {};
|
|
481
|
+
for (const [key, field] of Object.entries(fields)) out[key] = parseField(field, Object.hasOwn(raw, key) ? raw[key] : undefined);
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A list item's keys outside the schema, as read (M7: kept on save, after the schema's keys, as a block's are). */
|
|
486
|
+
function unknownKeys(fields: Fields, raw: Record<string, unknown>): [string, unknown][] {
|
|
487
|
+
return Object.entries(raw).filter(([key]) => !Object.hasOwn(fields, key) && key !== '__proto__');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** A list item of several fields: its fields parsed, then its unknown keys as read. */
|
|
491
|
+
function parseListItem(fields: Fields, raw: Record<string, unknown>): Record<string, unknown> {
|
|
492
|
+
const out = parseItem(fields, raw);
|
|
493
|
+
for (const [key, value] of unknownKeys(fields, raw)) out[key] = value;
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* A raw value from the file (or `undefined` when the key is missing) as the field's
|
|
499
|
+
* parsed value. Never throws: a value of the wrong shape parses as empty, and is
|
|
500
|
+
* left alone in the file unless the editor changes it. Takes its own parsed value
|
|
501
|
+
* as readily as the file's forms (the editor sends parsed values back).
|
|
502
|
+
*
|
|
503
|
+
* A value that breaks the field's rule (a number out of range, a link failing the
|
|
504
|
+
* link rule) is kept as typed here; renderValue is what drops it (Decision 15).
|
|
505
|
+
*/
|
|
506
|
+
export function parseField(field: Field, raw: unknown): FieldValue<Field> {
|
|
507
|
+
switch (field.kind) {
|
|
508
|
+
case 'text':
|
|
509
|
+
return typeof raw === 'string' ? raw.trim() : '';
|
|
510
|
+
case 'lockup': {
|
|
511
|
+
// Decision 10: `["…"]`, or `{ "lines": ["…"], "phone": ["…"] }` once it has phone lines.
|
|
512
|
+
if (Array.isArray(raw)) return { lines: normaliseLines(raw), phone: null };
|
|
513
|
+
if (raw && typeof raw === 'object') {
|
|
514
|
+
const o = raw as Record<string, unknown>;
|
|
515
|
+
const phone = normaliseLines(o.phone);
|
|
516
|
+
return { lines: normaliseLines(o.lines), phone: phone.length > 0 ? phone : null };
|
|
517
|
+
}
|
|
518
|
+
return { lines: [], phone: null };
|
|
519
|
+
}
|
|
520
|
+
case 'select':
|
|
521
|
+
return typeof raw === 'string' && field.options.some((o) => o.value === raw) ? raw : field.default;
|
|
522
|
+
case 'richText':
|
|
523
|
+
return readRichText(raw, richTextOptions(field)).value;
|
|
524
|
+
case 'link':
|
|
525
|
+
// G9: an address-only link holds just where it goes.
|
|
526
|
+
if (field.addressOnly) return (isObject(raw) ? { href: str(raw.href) } : { href: '' }) as unknown as FieldValue<Field>;
|
|
527
|
+
return isObject(raw) ? { label: str(raw.label), href: str(raw.href) } : { label: '', href: '' };
|
|
528
|
+
case 'buttons':
|
|
529
|
+
return Array.isArray(raw) ? raw.filter(isObject).map((b) => parseButton(field, b)) : [];
|
|
530
|
+
case 'toggle':
|
|
531
|
+
return typeof raw === 'boolean' ? raw : field.default;
|
|
532
|
+
case 'number':
|
|
533
|
+
return typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
|
|
534
|
+
case 'date':
|
|
535
|
+
return parseDay(raw);
|
|
536
|
+
case 'reference':
|
|
537
|
+
return typeof raw === 'string' && raw.trim() !== '' ? raw.trim() : null;
|
|
538
|
+
case 'media':
|
|
539
|
+
// The parsed value is the stored shape (media-value.ts); the render's value is renderValue's.
|
|
540
|
+
return parseMedia(raw) as unknown as FieldValue<Field>;
|
|
541
|
+
case 'list': {
|
|
542
|
+
if (!Array.isArray(raw)) return [];
|
|
543
|
+
if (field.item) {
|
|
544
|
+
const item = field.item;
|
|
545
|
+
return raw.map((v) => parseField(item, v)) as FieldValue<Field>;
|
|
546
|
+
}
|
|
547
|
+
const fields = field.fields ?? {};
|
|
548
|
+
// M7: an item's unknown keys are kept, unmarked, as a block's are (Acceptance 10).
|
|
549
|
+
return raw.filter(isObject).map((v) => parseListItem(fields, v)) as FieldValue<Field>;
|
|
550
|
+
}
|
|
551
|
+
case 'group':
|
|
552
|
+
// Missing or not an object: every field at its own empty value.
|
|
553
|
+
return parseItem(field.fields, isObject(raw) ? raw : {}) as FieldValue<Field>;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* A field's value in the form the file stores it (Decision 7: "only a touched
|
|
559
|
+
* field takes the schema's form"). A lockup without phone lines is the plain list;
|
|
560
|
+
* rich text is written strictly (rich-text.ts); a list item's keys go in schema order,
|
|
561
|
+
* its unknown keys after them (M7).
|
|
562
|
+
*/
|
|
563
|
+
export function fieldToJson(field: Field, value: unknown): unknown {
|
|
564
|
+
// parseField takes the editor's values as readily as the file's forms.
|
|
565
|
+
const parsed = parseField(field, value);
|
|
566
|
+
switch (field.kind) {
|
|
567
|
+
case 'lockup': {
|
|
568
|
+
const v = parsed as LockupValue;
|
|
569
|
+
return v.phone ? { lines: v.lines, phone: v.phone } : v.lines;
|
|
570
|
+
}
|
|
571
|
+
case 'richText':
|
|
572
|
+
return serializeRichText(parsed, richTextOptions(field));
|
|
573
|
+
case 'link': {
|
|
574
|
+
const v = parsed as LinkValue;
|
|
575
|
+
return field.addressOnly ? { href: v.href } : { label: v.label, href: v.href };
|
|
576
|
+
}
|
|
577
|
+
case 'buttons':
|
|
578
|
+
return (parsed as ButtonValue[]).map((b) => ({ label: b.label, href: b.href, style: b.style }));
|
|
579
|
+
case 'media':
|
|
580
|
+
return mediaToJson(parsed);
|
|
581
|
+
case 'list': {
|
|
582
|
+
const items = parsed as unknown[];
|
|
583
|
+
if (field.item) {
|
|
584
|
+
const item = field.item;
|
|
585
|
+
return items.map((v) => fieldToJson(item, v));
|
|
586
|
+
}
|
|
587
|
+
const fields = field.fields ?? {};
|
|
588
|
+
// Schema keys in schema order, then the item's unknown keys as read (M7).
|
|
589
|
+
return items.map((v) => {
|
|
590
|
+
const o = v as Record<string, unknown>;
|
|
591
|
+
return Object.fromEntries([...Object.entries(fields).map(([key, f]) => [key, fieldToJson(f, o[key])]), ...unknownKeys(fields, o)]);
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
case 'group': {
|
|
595
|
+
const v = parsed as Record<string, unknown>;
|
|
596
|
+
return Object.fromEntries(Object.entries(field.fields).map(([key, f]) => [key, fieldToJson(f, v[key])]));
|
|
597
|
+
}
|
|
598
|
+
default:
|
|
599
|
+
return parsed;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Is this field's parsed value empty (for "the first block with a non-empty heading")? */
|
|
604
|
+
export function isEmptyValue(field: Field, value: unknown): boolean {
|
|
605
|
+
switch (field.kind) {
|
|
606
|
+
case 'lockup': {
|
|
607
|
+
const v = value as LockupValue | undefined;
|
|
608
|
+
return !v || v.lines.length === 0;
|
|
609
|
+
}
|
|
610
|
+
case 'text':
|
|
611
|
+
case 'select':
|
|
612
|
+
return typeof value !== 'string' || value.trim().length === 0;
|
|
613
|
+
case 'richText':
|
|
614
|
+
case 'buttons':
|
|
615
|
+
case 'list':
|
|
616
|
+
return !Array.isArray(value) || value.length === 0;
|
|
617
|
+
case 'link': {
|
|
618
|
+
const v = value as LinkValue | undefined;
|
|
619
|
+
return !v || (!v.label && !v.href);
|
|
620
|
+
}
|
|
621
|
+
case 'toggle':
|
|
622
|
+
return typeof value !== 'boolean';
|
|
623
|
+
case 'group': {
|
|
624
|
+
// Empty when every field in it is (a toggle or a select at its default counts as set).
|
|
625
|
+
const v = isObject(value) ? value : {};
|
|
626
|
+
return Object.entries(field.fields).every(([key, f]) => isEmptyValue(f, v[key]));
|
|
627
|
+
}
|
|
628
|
+
default:
|
|
629
|
+
return value === null || value === undefined || value === '';
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/** Does a number sit inside the field's range? */
|
|
634
|
+
export function inRange(field: NumberField, value: number): boolean {
|
|
635
|
+
return (field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* What a block component gets for a field's parsed value (Decision 15: "ignored
|
|
640
|
+
* where it's used"). A number out of range is `null`, so the block shows its own
|
|
641
|
+
* default; list items are mapped the same way. Everything else is the parsed value:
|
|
642
|
+
* a link failing the rule and half a button stay in the value, and the package's
|
|
643
|
+
* Link and Buttons components render them as text or nothing (indexes stay the
|
|
644
|
+
* editor's, so markers line up). A missing reference becoming `null` is Phase 2's
|
|
645
|
+
* (it needs the store).
|
|
646
|
+
*/
|
|
647
|
+
export function renderValue(field: Field, value: unknown): unknown {
|
|
648
|
+
switch (field.kind) {
|
|
649
|
+
case 'number':
|
|
650
|
+
return typeof value === 'number' && Number.isFinite(value) && inRange(field, value) ? value : null;
|
|
651
|
+
case 'media':
|
|
652
|
+
// M5b Decision 3: resolved for the page, null when empty or of a kind the slot doesn't take; a loop-only slot loops.
|
|
653
|
+
return renderMedia(value, { only: field.only, play: field.play });
|
|
654
|
+
case 'link': {
|
|
655
|
+
// M6c G9: an address-only link is `{ href }` when it's usable, else null (empty, or failing the rule).
|
|
656
|
+
if (!field.addressOnly) return value;
|
|
657
|
+
const href = usableHref(isObject(value) ? value.href : undefined);
|
|
658
|
+
return href ? { href } : null;
|
|
659
|
+
}
|
|
660
|
+
case 'list': {
|
|
661
|
+
if (!Array.isArray(value)) return [];
|
|
662
|
+
if (field.item) {
|
|
663
|
+
const item = field.item;
|
|
664
|
+
return value.map((v) => renderValue(item, v));
|
|
665
|
+
}
|
|
666
|
+
const fields = field.fields ?? {};
|
|
667
|
+
return value.map((v) => (isObject(v) ? renderData(fields, v) : v));
|
|
668
|
+
}
|
|
669
|
+
case 'group':
|
|
670
|
+
return isObject(value) ? renderData(field.fields, value) : value;
|
|
671
|
+
default:
|
|
672
|
+
return value;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/** renderValue for every field of a block's (or a list item's) parsed data; keys outside the schema pass through. */
|
|
677
|
+
export function renderData(fields: Fields, data: Record<string, unknown>): Record<string, unknown> {
|
|
678
|
+
const out: Record<string, unknown> = { ...data };
|
|
679
|
+
for (const [key, field] of Object.entries(fields)) if (Object.hasOwn(data, key)) out[key] = renderValue(field, data[key]);
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* The loading rule's "first image" (M5b Decision 4): the dotted path of the first
|
|
685
|
+
* media slot, in schema order (list items in order), that renders an `<img>` (an
|
|
686
|
+
* image, or a player's poster); null when none does. A loop has no `<img>`.
|
|
687
|
+
*/
|
|
688
|
+
export function firstImagePath(fields: Fields, data: Record<string, unknown>, base = ''): string | null {
|
|
689
|
+
return firstMediaWhere(fields, data, (m) => m.kind === 'image' || m.play === 'player', base)?.path ?? null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* M6c (G5, Decision 14): a grid card's picture, the first media slot in schema order
|
|
694
|
+
* (list items in order) holding an image or a clip, a loop included (its poster stands
|
|
695
|
+
* for it on a card): its path, its stored value and its render value; null when none does.
|
|
696
|
+
*/
|
|
697
|
+
export function firstMedia(fields: Fields, data: Record<string, unknown>): FirstMedia | null {
|
|
698
|
+
return firstMediaWhere(fields, data, () => true);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export interface FirstMedia {
|
|
702
|
+
path: string;
|
|
703
|
+
value: unknown;
|
|
704
|
+
media: RenderMedia;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function firstMediaWhere(fields: Fields, data: Record<string, unknown>, accept: (media: RenderMedia) => boolean, base = ''): FirstMedia | null {
|
|
708
|
+
const shown = (field: MediaField, value: unknown) => {
|
|
709
|
+
const media = renderMedia(value, { only: field.only, play: field.play });
|
|
710
|
+
return media && accept(media) ? media : null;
|
|
711
|
+
};
|
|
712
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
713
|
+
const value = data[key];
|
|
714
|
+
const path = join(base, key);
|
|
715
|
+
if (field.kind === 'media') {
|
|
716
|
+
const media = shown(field, value);
|
|
717
|
+
if (media) return { path, value, media };
|
|
718
|
+
}
|
|
719
|
+
if (field.kind === 'group' && isObject(value)) {
|
|
720
|
+
const found = firstMediaWhere(field.fields, value, accept, path);
|
|
721
|
+
if (found) return found;
|
|
722
|
+
}
|
|
723
|
+
if (field.kind === 'list' && Array.isArray(value)) {
|
|
724
|
+
if (field.item?.kind === 'media') {
|
|
725
|
+
const item = field.item;
|
|
726
|
+
for (const [i, v] of value.entries()) {
|
|
727
|
+
const media = shown(item, v);
|
|
728
|
+
if (media) return { path: join(path, i), value: v, media };
|
|
729
|
+
}
|
|
730
|
+
} else if (field.fields) {
|
|
731
|
+
for (const [i, v] of value.filter(isObject).entries()) {
|
|
732
|
+
const found = firstMediaWhere(field.fields, v, accept, join(path, i));
|
|
733
|
+
if (found) return found;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** M6c (G5, Decision 13): can these fields hold an image anywhere (a media field, lists and groups included)? */
|
|
742
|
+
export function holdsMedia(fields: Fields | null | undefined): boolean {
|
|
743
|
+
return Object.values(fields ?? {}).some(fieldHoldsMedia);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Can this field hold an image: a media field, or a list or group with one in it? */
|
|
747
|
+
export function fieldHoldsMedia(field: Field): boolean {
|
|
748
|
+
if (field.kind === 'media') return true;
|
|
749
|
+
if (field.kind === 'list') return field.item ? fieldHoldsMedia(field.item) : holdsMedia(field.fields);
|
|
750
|
+
return field.kind === 'group' && holdsMedia(field.fields);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// ── Warnings ──────────────────────────────────────────────────────────────────
|
|
754
|
+
|
|
755
|
+
export type FieldWarningCode =
|
|
756
|
+
/** Rich text: a block kind the field doesn't allow, shown as a paragraph. */
|
|
757
|
+
| 'disallowed-kind'
|
|
758
|
+
/** Rich text: a link where the field has links off, shown as its text. */
|
|
759
|
+
| 'link-off'
|
|
760
|
+
/** Rich text: italic in a heading rich-text field (M9 Decision 11), shown as plain text. */
|
|
761
|
+
| 'italic-off'
|
|
762
|
+
/** A value (or a rich-text entry) the field can't read: shown as empty, kept until the field is edited. */
|
|
763
|
+
| 'unreadable'
|
|
764
|
+
/** A link that fails the link rule (Decision 4): shown as text. */
|
|
765
|
+
| 'link-rule'
|
|
766
|
+
/** An internal link to a path no document has (Decision 4). */
|
|
767
|
+
| 'link-missing'
|
|
768
|
+
/** M9 Decision 7: an internal link to a page hidden from the site. */
|
|
769
|
+
| 'link-hidden'
|
|
770
|
+
/** M9 Decision 7: a reference to a page hidden from the site (a warning, as `link-hidden` is). */
|
|
771
|
+
| 'reference-hidden'
|
|
772
|
+
/** A link or button with only one of its label and its link: shows nothing. */
|
|
773
|
+
| 'half-link'
|
|
774
|
+
/** More buttons than the field's `max`: the extra ones don't show. */
|
|
775
|
+
| 'too-many'
|
|
776
|
+
/** A number outside the field's `min`/`max`: the page uses its default. */
|
|
777
|
+
| 'out-of-range'
|
|
778
|
+
/** A reference to a document that doesn't exist (Decision 6): the render gets null. */
|
|
779
|
+
| 'missing-reference'
|
|
780
|
+
/** M5b Decision 9: an image that isn't decorative with no alt text. Never blocks. */
|
|
781
|
+
| 'missing-alt'
|
|
782
|
+
/** M5b Decision 3: a value of a kind the slot doesn't take (`only`): kept, not shown. */
|
|
783
|
+
| 'wrong-kind'
|
|
784
|
+
/** M5b Decision 10: fewer items than the list's `min` (`too-many` covers `max`). */
|
|
785
|
+
| 'too-few';
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* A message for the field, never an error: the value is kept as typed (Decision 15).
|
|
789
|
+
* `path` is dotted and relative to the field: `''` for the field itself, `'1'` for
|
|
790
|
+
* a list's or buttons' second item, `'1.href'` inside it, `'credits.1.name'`-style
|
|
791
|
+
* paths compose by prefixing the field's own key.
|
|
792
|
+
*/
|
|
793
|
+
export interface FieldWarning {
|
|
794
|
+
path: string;
|
|
795
|
+
code: FieldWarningCode;
|
|
796
|
+
message: string;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** What fieldWarnings checks against, when the caller knows it (Phase 2 supplies these from the store's listing). */
|
|
800
|
+
export interface WarningContext {
|
|
801
|
+
/** The site's documents' URLs (DocumentEntry.url), for internal links. Without them, internal links aren't checked. */
|
|
802
|
+
addresses?: Iterable<string | null | undefined>;
|
|
803
|
+
/** Whether a document exists, for references. Without it, references aren't checked. */
|
|
804
|
+
documentExists?: (collection: string, path: string) => boolean;
|
|
805
|
+
/**
|
|
806
|
+
* M6c G4 (Decision 10): whether a file's key is in the library, for links to `/files/…`.
|
|
807
|
+
* Without it (the library not read yet, or not set up), file links aren't checked.
|
|
808
|
+
*/
|
|
809
|
+
fileExists?: (key: string) => boolean;
|
|
810
|
+
/**
|
|
811
|
+
* M8 trial (item 3): the site's own code routes (code-routes.ts). A link to an address one
|
|
812
|
+
* of them serves exists, as a document's does; a route with parameters counts for every
|
|
813
|
+
* address under it, since which ones it builds isn't known from its file name.
|
|
814
|
+
*/
|
|
815
|
+
codeRoutes?: readonly CodeRoute[];
|
|
816
|
+
/** M9 Decision 7: hidden pages, their address (normalised) to their name. A link to one warns. */
|
|
817
|
+
hiddenPages?: ReadonlyMap<string, string>;
|
|
818
|
+
/**
|
|
819
|
+
* M9 Decision 7: hidden documents, `collection/path` to their name and address (links.ts
|
|
820
|
+
* `hiddenDocuments`). A reference to one warns.
|
|
821
|
+
*/
|
|
822
|
+
hiddenDocuments?: ReadonlyMap<string, { label: string; url: string | null }>;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const join = (base: string, part: string | number) => (base === '' ? String(part) : `${base}.${part}`);
|
|
826
|
+
const ordinal = (n: number) => String(n + 1);
|
|
827
|
+
|
|
828
|
+
function hrefWarnings(href: string, path: string, context: WarningContext, out: FieldWarning[]) {
|
|
829
|
+
const rule = validateLink(href);
|
|
830
|
+
if (rule) {
|
|
831
|
+
out.push({ path, code: 'link-rule', message: rule });
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const file = fileKeyOf(href.trim());
|
|
835
|
+
if (file) {
|
|
836
|
+
// A file link (Decision 4): its key in the library, never a page's address.
|
|
837
|
+
if (context.fileExists && !context.fileExists(file.key)) {
|
|
838
|
+
out.push({ path, code: 'link-missing', message: `There's no file at ${fileAddress(file.key)} in the library, so this link goes nowhere. Pick a file.` });
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (context.addresses) {
|
|
843
|
+
const target = internalLinkPath(href);
|
|
844
|
+
if (target !== null && codeRouteAt(target, context.codeRoutes)) return;
|
|
845
|
+
const hidden = context.hiddenPages && hiddenLinkTarget(href, context.hiddenPages);
|
|
846
|
+
if (hidden) {
|
|
847
|
+
out.push({ path, code: 'link-hidden', message: hidden });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const missing = missingLinkTarget(href, context.addresses);
|
|
851
|
+
if (missing) out.push({ path, code: 'link-missing', message: missing });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* The field's warnings for a value: the file's raw value (untouched), or the
|
|
857
|
+
* editor's value once the field is edited (both read through parseField). Never
|
|
858
|
+
* throws. Warnings that depend on what the file holds, such as a rich-text kind the
|
|
859
|
+
* field doesn't allow, only come from the raw value: once the field is edited,
|
|
860
|
+
* that's been written as a paragraph and the warning goes.
|
|
861
|
+
*/
|
|
862
|
+
export function fieldWarnings(field: Field, raw: unknown, context: WarningContext = {}, path = ''): FieldWarning[] {
|
|
863
|
+
const out: FieldWarning[] = [];
|
|
864
|
+
const unreadable = (message = "This value isn't in the field's format, so the page shows the field as empty.") =>
|
|
865
|
+
out.push({ path, code: 'unreadable', message });
|
|
866
|
+
const present = raw !== undefined && raw !== null;
|
|
867
|
+
|
|
868
|
+
switch (field.kind) {
|
|
869
|
+
case 'richText': {
|
|
870
|
+
if (present && typeof raw !== 'string' && !Array.isArray(raw)) unreadable();
|
|
871
|
+
const { value, issues } = readRichText(raw, richTextOptions(field));
|
|
872
|
+
for (const issue of issues) {
|
|
873
|
+
const at = join(path, issue.block);
|
|
874
|
+
if (issue.code === 'disallowed-kind') {
|
|
875
|
+
out.push({
|
|
876
|
+
path: at,
|
|
877
|
+
code: 'disallowed-kind',
|
|
878
|
+
message: `${issue.kind === 'h1' ? 'An h1' : `A${/^[aeiouh]/i.test(issue.kind) ? 'n' : ''} ${issue.kind}`} isn't allowed in this field, so it shows as a paragraph.`,
|
|
879
|
+
});
|
|
880
|
+
} else if (issue.code === 'italic-off') {
|
|
881
|
+
out.push({ path: at, code: 'italic-off', message: 'This heading takes bold only, so italic here shows as plain text.' });
|
|
882
|
+
} else if (issue.code === 'link-off') {
|
|
883
|
+
out.push({
|
|
884
|
+
path: at,
|
|
885
|
+
code: 'link-off',
|
|
886
|
+
message: "This field doesn't take links, so a link here shows as its text.",
|
|
887
|
+
});
|
|
888
|
+
} else {
|
|
889
|
+
out.push({ path: at, code: 'unreadable', message: "Part of this text isn't in a form the field can read, so it doesn't show." });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
if (field.links) for (const href of richTextHrefs(value)) hrefWarnings(href, path, context, out);
|
|
893
|
+
break;
|
|
894
|
+
}
|
|
895
|
+
case 'link': {
|
|
896
|
+
if (present && !isObject(raw)) unreadable();
|
|
897
|
+
const v = parseField(field, raw) as LinkValue;
|
|
898
|
+
if (v.href) hrefWarnings(v.href, join(path, 'href'), context, out);
|
|
899
|
+
// G9: an address-only link has no words, so it's never half a link.
|
|
900
|
+
if (!field.addressOnly && !!v.label !== !!v.href) {
|
|
901
|
+
out.push({ path, code: 'half-link', message: 'A link shows only once it has both its words and where it goes.' });
|
|
902
|
+
}
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
case 'buttons': {
|
|
906
|
+
if (present && !Array.isArray(raw)) unreadable();
|
|
907
|
+
const items = parseField(field, raw) as ButtonValue[];
|
|
908
|
+
items.forEach((b, i) => {
|
|
909
|
+
if (b.href) hrefWarnings(b.href, join(path, `${i}.href`), context, out);
|
|
910
|
+
// allowNoLink: words with no link are a plain label, meant; a link with no words is still half a button.
|
|
911
|
+
const half = b.href ? !b.label : !!b.label && !field.allowNoLink;
|
|
912
|
+
if (half) {
|
|
913
|
+
out.push({ path: join(path, i), code: 'half-link', message: `Button ${ordinal(i)} shows only once it has both a label and a link.` });
|
|
914
|
+
}
|
|
915
|
+
});
|
|
916
|
+
if (items.length > field.max) {
|
|
917
|
+
out.push({ path, code: 'too-many', message: `Up to ${field.max} button${field.max === 1 ? '' : 's'} here: remove one. Only the first ${field.max} show.` });
|
|
918
|
+
}
|
|
919
|
+
break;
|
|
920
|
+
}
|
|
921
|
+
case 'number': {
|
|
922
|
+
if (present && (typeof raw !== 'number' || !Number.isFinite(raw))) unreadable();
|
|
923
|
+
const v = parseField(field, raw) as number | null;
|
|
924
|
+
if (v !== null && !inRange(field, v)) {
|
|
925
|
+
const range =
|
|
926
|
+
field.min !== undefined && field.max !== undefined
|
|
927
|
+
? `between ${field.min} and ${field.max}`
|
|
928
|
+
: field.min !== undefined
|
|
929
|
+
? `at least ${field.min}`
|
|
930
|
+
: `at most ${field.max}`;
|
|
931
|
+
out.push({ path, code: 'out-of-range', message: `Enter a number ${range}. Until then the page shows its default.` });
|
|
932
|
+
}
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
case 'date':
|
|
936
|
+
if (present && parseDay(raw) === null) unreadable("This isn't a date in the form 2026-10-15, so the page shows no date.");
|
|
937
|
+
break;
|
|
938
|
+
case 'toggle':
|
|
939
|
+
if (present && typeof raw !== 'boolean') unreadable();
|
|
940
|
+
break;
|
|
941
|
+
case 'reference': {
|
|
942
|
+
if (present && typeof raw !== 'string') unreadable();
|
|
943
|
+
const v = parseField(field, raw) as string | null;
|
|
944
|
+
if (v !== null && context.documentExists && !context.documentExists(field.to, v)) {
|
|
945
|
+
out.push({ path, code: 'missing-reference', message: `This points at "${v}", which isn't in ${titleCase(field.to)} any more.` });
|
|
946
|
+
} else if (v !== null && context.hiddenDocuments) {
|
|
947
|
+
const message = hiddenReferenceTarget(field.to, v, context.hiddenDocuments);
|
|
948
|
+
if (message) out.push({ path, code: 'reference-hidden', message });
|
|
949
|
+
}
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
case 'media': {
|
|
953
|
+
if (present && parseMedia(raw) === null) unreadable("This isn't an image or a video the field can read, so the page shows nothing here.");
|
|
954
|
+
const kind = mediaKind(raw);
|
|
955
|
+
if (kind === 'image' && isFileKey((parseMedia(raw) as Extract<MediaValue, { key: string }>).key)) {
|
|
956
|
+
// M6c G4 (Decision 4): a file's key in a media slot, written by hand: the page shows nothing for it.
|
|
957
|
+
const type = fileTypeOf((parseMedia(raw) as Extract<MediaValue, { key: string }>).key)!;
|
|
958
|
+
out.push({ path, code: 'wrong-kind', message: `A file (${type.label}), not an image or video; nothing shows.` });
|
|
959
|
+
} else if (kind && field.only && kind !== field.only) {
|
|
960
|
+
out.push({
|
|
961
|
+
path,
|
|
962
|
+
code: 'wrong-kind',
|
|
963
|
+
message: field.only === 'image' ? "This slot takes images only, so the page doesn't show this video. It's kept until you replace it." : "This slot takes videos only, so the page doesn't show this image. It's kept until you replace it.",
|
|
964
|
+
});
|
|
965
|
+
} else if (kind === 'image') {
|
|
966
|
+
// Reads the current value, edited or not (Decision 9): it stays for as long as the alt is empty.
|
|
967
|
+
const v = parseMedia(raw) as Extract<MediaValue, { key: string }>;
|
|
968
|
+
if (!v.decorative && v.alt.trim() === '') out.push({ path: join(path, 'alt'), code: 'missing-alt', message: MISSING_ALT_MESSAGE });
|
|
969
|
+
}
|
|
970
|
+
break;
|
|
971
|
+
}
|
|
972
|
+
case 'list': {
|
|
973
|
+
if (present && !Array.isArray(raw)) {
|
|
974
|
+
unreadable();
|
|
975
|
+
break;
|
|
976
|
+
}
|
|
977
|
+
const items = Array.isArray(raw) ? raw : [];
|
|
978
|
+
// M5b Decision 10: outside min/max is warned, never blocked (counted as the editor's rows are).
|
|
979
|
+
const count = field.item ? items.length : items.filter(isObject).length;
|
|
980
|
+
if (field.min !== undefined && count < field.min) {
|
|
981
|
+
out.push({ path, code: 'too-few', message: `This takes at least ${field.min}: add ${field.min - count} more.` });
|
|
982
|
+
}
|
|
983
|
+
if (field.max !== undefined && count > field.max) {
|
|
984
|
+
out.push({ path, code: 'too-many', message: `This takes up to ${field.max}: remove ${count - field.max}. Until then they all show.` });
|
|
985
|
+
}
|
|
986
|
+
if (field.item) {
|
|
987
|
+
const item = field.item;
|
|
988
|
+
items.forEach((v, i) => out.push(...fieldWarnings(item, v, context, join(path, i))));
|
|
989
|
+
} else {
|
|
990
|
+
const fields = field.fields ?? {};
|
|
991
|
+
// Indexes are the parsed list's (non-objects are dropped), so they match the editor's rows.
|
|
992
|
+
items.filter(isObject).forEach((v, i) => {
|
|
993
|
+
for (const [key, f] of Object.entries(fields)) out.push(...fieldWarnings(f, v[key], context, join(path, `${i}.${key}`)));
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
case 'group': {
|
|
999
|
+
if (present && !isObject(raw)) {
|
|
1000
|
+
unreadable();
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
// Each field's own warnings, at `<key>` inside the group (so `left.button.0` from the block).
|
|
1004
|
+
const v = isObject(raw) ? raw : {};
|
|
1005
|
+
for (const [key, f] of Object.entries(field.fields)) out.push(...fieldWarnings(f, Object.hasOwn(v, key) ? v[key] : undefined, context, join(path, key)));
|
|
1006
|
+
break;
|
|
1007
|
+
}
|
|
1008
|
+
default:
|
|
1009
|
+
break;
|
|
1010
|
+
}
|
|
1011
|
+
return out;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** A link's href as the render uses it: the href when it passes the rule, else null (render the label as text). */
|
|
1015
|
+
export function usableHref(href: unknown): string | null {
|
|
1016
|
+
return isLinkHref(href) ? href.trim() : null;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// ── Paths (Decision 16) ───────────────────────────────────────────────────────
|
|
1020
|
+
|
|
1021
|
+
/** `credits.1.name` → `['credits', '1', 'name']`. */
|
|
1022
|
+
export function splitPath(path: string): string[] {
|
|
1023
|
+
return path === '' ? [] : path.split('.');
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Is a path part a list's index (`1` in `credits.1.name`)? A field's key never is (config.ts refuses one). */
|
|
1027
|
+
export const isIndexPart = (part: string) => /^\d+$/.test(part);
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* A path with no list index in it: a top-level field, or one inside groups
|
|
1031
|
+
* (`left.heading`). Only these are typed on the page (M5a Decision 13); a list item's
|
|
1032
|
+
* fields open in the sidebar.
|
|
1033
|
+
*/
|
|
1034
|
+
export const isKeyPath = (path: string) => path !== '' && !splitPath(path).some(isIndexPart);
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* The fields that can hold the page's heading (M3 Decision 16: a lockup, or a text
|
|
1038
|
+
* or rich-text field marked `heading`, M9 Decision 11), as paths, in the order they're tried: the top-level ones in
|
|
1039
|
+
* schema order, then those inside groups (`left.heading`), groups in schema order. A
|
|
1040
|
+
* top-level heading always comes before a group's.
|
|
1041
|
+
*/
|
|
1042
|
+
export function headingPaths(fields: Fields): string[] {
|
|
1043
|
+
const isHeading = (field: Field) => (field.kind === 'lockup' || field.kind === 'text' || field.kind === 'richText') && !!field.heading;
|
|
1044
|
+
const top = Object.entries(fields).filter(([, f]) => isHeading(f)).map(([key]) => key);
|
|
1045
|
+
const grouped = Object.entries(fields).flatMap(([key, f]) => (f.kind === 'group' ? Object.entries(f.fields).filter(([, g]) => isHeading(g)).map(([sub]) => `${key}.${sub}`) : []));
|
|
1046
|
+
return [...top, ...grouped];
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* The descriptor a dotted path lands on, and what's left of the path after it:
|
|
1051
|
+
* `credits.1.name` → the `name` text field, rest `[]`; `buttons.0.label` → the
|
|
1052
|
+
* buttons field, rest `['0', 'label']`; `cta.label` → the link field, rest
|
|
1053
|
+
* `['label']`; `left.heading` → the group's `heading` field, rest `[]`. Undefined
|
|
1054
|
+
* when the path doesn't start with a field.
|
|
1055
|
+
*/
|
|
1056
|
+
export function fieldAtPath(fields: Fields, path: string): { field: Field; rest: string[] } | undefined {
|
|
1057
|
+
const parts = splitPath(path);
|
|
1058
|
+
const first = parts.shift();
|
|
1059
|
+
if (first === undefined || !Object.hasOwn(fields, first)) return undefined;
|
|
1060
|
+
let field = fields[first]!;
|
|
1061
|
+
while (parts.length > 0 && (field.kind === 'list' || field.kind === 'group')) {
|
|
1062
|
+
if (field.kind === 'group') {
|
|
1063
|
+
if (!Object.hasOwn(field.fields, parts[0]!)) return undefined;
|
|
1064
|
+
field = field.fields[parts.shift()!]!;
|
|
1065
|
+
continue;
|
|
1066
|
+
}
|
|
1067
|
+
if (!/^\d+$/.test(parts[0]!)) return undefined;
|
|
1068
|
+
if (field.item) {
|
|
1069
|
+
parts.shift();
|
|
1070
|
+
field = field.item;
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
const key = parts[1];
|
|
1074
|
+
const sub = field.fields ?? {};
|
|
1075
|
+
if (key === undefined) return { field, rest: parts };
|
|
1076
|
+
if (!Object.hasOwn(sub, key)) return undefined;
|
|
1077
|
+
parts.splice(0, 2);
|
|
1078
|
+
field = sub[key]!;
|
|
1079
|
+
}
|
|
1080
|
+
return { field, rest: parts };
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/** The field a dotted path names exactly (`heading`, `left.heading`, `credits.1.name`), or undefined. */
|
|
1084
|
+
export function fieldAt(fields: Fields, path: string): Field | undefined {
|
|
1085
|
+
const found = fieldAtPath(fields, path);
|
|
1086
|
+
return found && found.rest.length === 0 ? found.field : undefined;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
/** The value at a dotted path inside `data`, or undefined. */
|
|
1090
|
+
export function getAtPath(data: unknown, path: string): unknown {
|
|
1091
|
+
let at: unknown = data;
|
|
1092
|
+
for (const part of splitPath(path)) {
|
|
1093
|
+
if (Array.isArray(at)) at = /^\d+$/.test(part) ? at[Number(part)] : undefined;
|
|
1094
|
+
else if (isObject(at)) at = Object.hasOwn(at, part) ? at[part] : undefined;
|
|
1095
|
+
else return undefined;
|
|
1096
|
+
}
|
|
1097
|
+
return at;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/**
|
|
1101
|
+
* A copy of `data` with the value at a dotted path set, copying only along the
|
|
1102
|
+
* path (what's off the path is shared). A missing step is made: an array when the
|
|
1103
|
+
* next part is an index, an object otherwise. An index past an array's end appends
|
|
1104
|
+
* there (holes are filled with null). An empty path returns `value`.
|
|
1105
|
+
*/
|
|
1106
|
+
export function setAtPath(data: unknown, path: string, value: unknown): unknown {
|
|
1107
|
+
const parts = splitPath(path);
|
|
1108
|
+
const set = (at: unknown, i: number): unknown => {
|
|
1109
|
+
if (i === parts.length) return value;
|
|
1110
|
+
const part = parts[i]!;
|
|
1111
|
+
const index = /^\d+$/.test(part) ? Number(part) : null;
|
|
1112
|
+
if (Array.isArray(at) && index !== null) {
|
|
1113
|
+
const copy = [...at];
|
|
1114
|
+
while (copy.length < index) copy.push(null);
|
|
1115
|
+
copy[index] = set(at[index], i + 1);
|
|
1116
|
+
return copy;
|
|
1117
|
+
}
|
|
1118
|
+
if (isObject(at)) return { ...at, [part]: set(Object.hasOwn(at, part) ? at[part] : undefined, i + 1) };
|
|
1119
|
+
// Nothing usable here: make the container.
|
|
1120
|
+
if (index !== null) {
|
|
1121
|
+
const made: unknown[] = [];
|
|
1122
|
+
while (made.length < index) made.push(null);
|
|
1123
|
+
made[index] = set(undefined, i + 1);
|
|
1124
|
+
return made;
|
|
1125
|
+
}
|
|
1126
|
+
return { [part]: set(undefined, i + 1) };
|
|
1127
|
+
};
|
|
1128
|
+
return set(data, 0);
|
|
1129
|
+
}
|