caracms 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search and sharing (M6 Decision 13; the mockup's #seo-page, #seo-long, #seo-defaults,
|
|
3
|
+
* #seo-case): a document's own `seo` key, as one collapsed group that says what's set.
|
|
4
|
+
* At the foot of a section-builder page's block list (below Add block), and at the end of
|
|
5
|
+
* a fields document's form. Only on collections with a URL.
|
|
6
|
+
*
|
|
7
|
+
* - **Search title** and **Search description**: plain text with M5a's count and M6's
|
|
8
|
+
* soft limits (60 and 160, seo.ts). Over the limit the count and a note turn amber, and
|
|
9
|
+
* nothing is blocked.
|
|
10
|
+
* - **Share image**: an image-only media slot with **no alt input** (like the brand's
|
|
11
|
+
* logo, M5b Decision 11) that may have a focal point; never in Publish's alt summary.
|
|
12
|
+
* - Blank parts fall back to Site Settings' defaults (the placeholders say what).
|
|
13
|
+
*
|
|
14
|
+
* The values live in the document store beside the blocks (`SEO_SCOPE`, document-store.ts):
|
|
15
|
+
* clearing the last part removes `seo` from the file, and a file with none is untouched.
|
|
16
|
+
*
|
|
17
|
+
* `SoftText` is also Site Settings' (its Search and sharing defaults).
|
|
18
|
+
*/
|
|
19
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
20
|
+
import { media as mediaField } from '../../../fields.ts';
|
|
21
|
+
import { parseMedia } from '../../../media-value.ts';
|
|
22
|
+
import { SETTINGS_PATH } from '../../../reserved.ts';
|
|
23
|
+
import { SEO_DESCRIPTION_SOFT_LIMIT, SEO_TITLE_SOFT_LIMIT } from '../../../seo.ts';
|
|
24
|
+
import { ENDPOINTS, sameDoc, type AnyOpenedDocument } from '../../types.ts';
|
|
25
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
26
|
+
import type { Editor, View } from '../editor.ts';
|
|
27
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
28
|
+
import { SEO_SCOPE } from '../state/document-store.ts';
|
|
29
|
+
import { createStore, type Store } from '../state/store.ts';
|
|
30
|
+
import { FieldFrame } from './FieldFrame.tsx';
|
|
31
|
+
import { FieldHostProvider, parseBase, useHost, type FieldHost } from './host.tsx';
|
|
32
|
+
import { MediaSlot } from './MediaField.tsx';
|
|
33
|
+
|
|
34
|
+
const TITLE_WHY = 'search results usually cut a title off around here';
|
|
35
|
+
const DESC_WHY = 'search results usually cut a description off around here';
|
|
36
|
+
const SHARE_IMAGE = mediaField({ only: 'image' });
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A text field with a count and a soft limit (M6 Decision 13, Decision 17's intro): over
|
|
40
|
+
* `max`, the count and a note turn amber; nothing is blocked.
|
|
41
|
+
*/
|
|
42
|
+
export function SoftText(props: {
|
|
43
|
+
scope: string;
|
|
44
|
+
name: string;
|
|
45
|
+
label: string;
|
|
46
|
+
value: string;
|
|
47
|
+
max: number;
|
|
48
|
+
multiline?: boolean;
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
help?: ReactNode;
|
|
51
|
+
overWhy?: string;
|
|
52
|
+
disabled?: boolean;
|
|
53
|
+
onChange: (value: string) => void;
|
|
54
|
+
}) {
|
|
55
|
+
const id = `f-${props.scope}-${props.name}`.replace(/[^\w-]/g, '-');
|
|
56
|
+
const n = props.value.length;
|
|
57
|
+
const over = n > props.max;
|
|
58
|
+
const input = props.multiline ? (
|
|
59
|
+
<textarea
|
|
60
|
+
className="input"
|
|
61
|
+
id={id}
|
|
62
|
+
data-path={props.name}
|
|
63
|
+
rows={3}
|
|
64
|
+
value={props.value}
|
|
65
|
+
placeholder={props.placeholder}
|
|
66
|
+
spellCheck={false}
|
|
67
|
+
disabled={props.disabled}
|
|
68
|
+
aria-describedby={over ? `${id}-w` : undefined}
|
|
69
|
+
onChange={(e) => props.onChange(e.target.value)}
|
|
70
|
+
/>
|
|
71
|
+
) : (
|
|
72
|
+
<input
|
|
73
|
+
className="input"
|
|
74
|
+
id={id}
|
|
75
|
+
data-path={props.name}
|
|
76
|
+
value={props.value}
|
|
77
|
+
placeholder={props.placeholder}
|
|
78
|
+
autoComplete="off"
|
|
79
|
+
spellCheck={false}
|
|
80
|
+
disabled={props.disabled}
|
|
81
|
+
aria-describedby={over ? `${id}-w` : undefined}
|
|
82
|
+
onChange={(e) => props.onChange(e.target.value.replace(/\n+/g, ' '))}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
return (
|
|
86
|
+
<FieldFrame
|
|
87
|
+
blockKey={props.scope}
|
|
88
|
+
name={props.name}
|
|
89
|
+
label={props.label}
|
|
90
|
+
htmlFor={id}
|
|
91
|
+
meta={
|
|
92
|
+
<span className={`field-meta${over ? ' soft-over' : ''}`} data-count={props.max} data-soft="">
|
|
93
|
+
{n} / {props.max}
|
|
94
|
+
</span>
|
|
95
|
+
}
|
|
96
|
+
>
|
|
97
|
+
{props.help ? <p className="field-help">{props.help}</p> : null}
|
|
98
|
+
{input}
|
|
99
|
+
{over ? (
|
|
100
|
+
<p className="field-warn" id={`${id}-w`} data-over="">
|
|
101
|
+
<Icon name="warn" size="sm" />
|
|
102
|
+
<span>
|
|
103
|
+
Over {props.max}: {props.overWhy ?? 'it may be cut short'}.
|
|
104
|
+
</span>
|
|
105
|
+
</p>
|
|
106
|
+
) : null}
|
|
107
|
+
</FieldFrame>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The share image (a page's, or Site Settings' default): image-only, no alt input, a focal point allowed. */
|
|
112
|
+
export function ShareImage(props: { name: string; label: string; help: string; value: unknown }) {
|
|
113
|
+
const host = useHost();
|
|
114
|
+
return (
|
|
115
|
+
<FieldFrame blockKey={host.scope} name={props.name} label={props.label} className="media-field" meta={<span className="field-meta">Images only</span>}>
|
|
116
|
+
<p className="field-help">{props.help}</p>
|
|
117
|
+
<MediaSlot path={props.name} field={SHARE_IMAGE} value={props.value} warnings={[]} pickLabel={props.label} noAlt />
|
|
118
|
+
</FieldFrame>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** "Title, description and image set", or "Uses the site’s defaults". */
|
|
123
|
+
export function seoSummary(seo: Record<string, unknown> | undefined): string {
|
|
124
|
+
const text = (v: unknown) => typeof v === 'string' && v.trim() !== '';
|
|
125
|
+
const set = [text(seo?.title) && 'title', text(seo?.description) && 'description', parseMedia(seo?.image) && 'image'].filter((x): x is string => !!x);
|
|
126
|
+
if (!set.length) return 'Uses the site’s defaults';
|
|
127
|
+
const list = set.length < 2 ? set[0]! : `${set.slice(0, -1).join(', ')} and ${set.at(-1)}`;
|
|
128
|
+
return `${list[0]!.toUpperCase()}${list.slice(1)} set`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The page's seo host: its fields picked at the document level as `seo.<part>` (so they never ring the form's own `title`). */
|
|
132
|
+
function seoHost(editor: Editor, raw: Record<string, unknown> | undefined, readOnly: boolean): FieldHost {
|
|
133
|
+
return {
|
|
134
|
+
scope: SEO_SCOPE,
|
|
135
|
+
picked: (view: View) => (view.level === 'document' && view.field?.startsWith('seo.') ? view.field.slice(4) : null),
|
|
136
|
+
pick(path) {
|
|
137
|
+
if (editor.view.get().field !== `seo.${path}`) editor.view.update({ field: `seo.${path}`, picked: false });
|
|
138
|
+
},
|
|
139
|
+
set: (path, value, options) => editor.doc.setField(SEO_SCOPE, path, value, options),
|
|
140
|
+
removeItem() {},
|
|
141
|
+
raw: (name) => (raw && Object.hasOwn(raw, name) ? raw[name] : undefined),
|
|
142
|
+
readOnly,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The group, for the open document. Null for a document without a URL (a collection with no `urlBase`). */
|
|
147
|
+
export function SeoGroup() {
|
|
148
|
+
const editor = useEditor();
|
|
149
|
+
const session = useStore(editor.doc.state);
|
|
150
|
+
const readOnly = useStore(editor.access, (a) => a.readOnly);
|
|
151
|
+
const brand = useStore(editor.brand, (b) => b.name);
|
|
152
|
+
const settings = useSiteSettings();
|
|
153
|
+
const [open, setOpen] = useState(false);
|
|
154
|
+
const collection = editor.site.collections.find((c) => c.name === session.ref.collection);
|
|
155
|
+
const raw = useMemo(() => {
|
|
156
|
+
const base = parseBase(session.base);
|
|
157
|
+
const seo = base && typeof base === 'object' ? (base as Record<string, unknown>).seo : undefined;
|
|
158
|
+
return seo && typeof seo === 'object' && !Array.isArray(seo) ? (seo as Record<string, unknown>) : undefined;
|
|
159
|
+
}, [session.base]);
|
|
160
|
+
const host = useMemo(() => seoHost(editor, raw, readOnly), [editor, raw, readOnly]);
|
|
161
|
+
if (!collection?.urlBase || (session.document.kind !== 'sections' && session.document.kind !== 'fields')) return null;
|
|
162
|
+
const seo = session.document.seo ?? {};
|
|
163
|
+
const str = (v: unknown) => (typeof v === 'string' ? v : '');
|
|
164
|
+
const isHome = sameDoc(session.ref, editor.site.home);
|
|
165
|
+
const defaults = settings?.seo ?? {};
|
|
166
|
+
const fallback = isHome ? str(defaults.title).trim() || brand : `${session.document.title || session.entry.label} · ${brand}`;
|
|
167
|
+
return (
|
|
168
|
+
<details className="fgroup seo" open={open} data-seo="" onToggle={(e) => setOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
|
169
|
+
<summary>
|
|
170
|
+
Search and sharing <span className="s">· {seoSummary(session.document.seo)}</span>
|
|
171
|
+
<Icon name="chev-r" size="sm" />
|
|
172
|
+
</summary>
|
|
173
|
+
<div className="in">
|
|
174
|
+
<FieldHostProvider host={host}>
|
|
175
|
+
<SoftText
|
|
176
|
+
scope={SEO_SCOPE}
|
|
177
|
+
name="title"
|
|
178
|
+
label="Search title"
|
|
179
|
+
value={str(seo.title)}
|
|
180
|
+
max={SEO_TITLE_SOFT_LIMIT}
|
|
181
|
+
placeholder={fallback}
|
|
182
|
+
help={`Blank is “${fallback}”.`}
|
|
183
|
+
overWhy={TITLE_WHY}
|
|
184
|
+
disabled={readOnly}
|
|
185
|
+
onChange={(v) => host.set('title', v)}
|
|
186
|
+
/>
|
|
187
|
+
<SoftText
|
|
188
|
+
scope={SEO_SCOPE}
|
|
189
|
+
name="description"
|
|
190
|
+
label="Search description"
|
|
191
|
+
value={str(seo.description)}
|
|
192
|
+
max={SEO_DESCRIPTION_SOFT_LIMIT}
|
|
193
|
+
multiline
|
|
194
|
+
placeholder={str(defaults.description)}
|
|
195
|
+
help="Blank uses the site default."
|
|
196
|
+
overWhy={DESC_WHY}
|
|
197
|
+
disabled={readOnly}
|
|
198
|
+
onChange={(v) => host.set('description', v.replace(/\n+/g, ' '))}
|
|
199
|
+
/>
|
|
200
|
+
<ShareImage
|
|
201
|
+
name="image"
|
|
202
|
+
label="Share image"
|
|
203
|
+
value={seo.image ?? null}
|
|
204
|
+
help="1200 × 630. Blank uses the site default."
|
|
205
|
+
/>
|
|
206
|
+
</FieldHostProvider>
|
|
207
|
+
</div>
|
|
208
|
+
</details>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export { TITLE_WHY, DESC_WHY };
|
|
213
|
+
|
|
214
|
+
// ── Site Settings as the page's fields need it ───────────────────────────────
|
|
215
|
+
|
|
216
|
+
/** What a page's fields read from Site Settings: the SEO defaults (fallbacks) and who gets each form's email. */
|
|
217
|
+
export interface SettingsView {
|
|
218
|
+
seo: Record<string, unknown>;
|
|
219
|
+
formRecipients: Record<string, string[]>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const asObject = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : {});
|
|
223
|
+
const viewOf = (data: Record<string, unknown>): SettingsView => {
|
|
224
|
+
const recipients: Record<string, string[]> = {};
|
|
225
|
+
for (const [id, list] of Object.entries(asObject(data.formRecipients))) if (Array.isArray(list)) recipients[id] = list.filter((x): x is string => typeof x === 'string');
|
|
226
|
+
return { seo: asObject(data.seo), formRecipients: recipients };
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
interface SettingsSource {
|
|
230
|
+
store: Store<SettingsView | null>;
|
|
231
|
+
/** Read Site Settings' draft again, once this tab's own changes to it are sent. The latest read wins. */
|
|
232
|
+
refresh(): void;
|
|
233
|
+
}
|
|
234
|
+
const settingsSources = new WeakMap<Editor, SettingsSource>();
|
|
235
|
+
|
|
236
|
+
function settingsFor(editor: Editor): SettingsSource {
|
|
237
|
+
let source = settingsSources.get(editor);
|
|
238
|
+
if (source) return source;
|
|
239
|
+
const store = createStore<SettingsView | null>(null);
|
|
240
|
+
// While Site Settings is open, its edited values, as they're typed.
|
|
241
|
+
const follow = (s: ReturnType<Editor['doc']['get']>) => {
|
|
242
|
+
if (s.document.kind === 'settings') store.set(viewOf(s.document.blocks[0]?.data ?? {}));
|
|
243
|
+
};
|
|
244
|
+
follow(editor.doc.get());
|
|
245
|
+
editor.doc.state.subscribe(follow);
|
|
246
|
+
let reads = 0;
|
|
247
|
+
source = {
|
|
248
|
+
store,
|
|
249
|
+
refresh() {
|
|
250
|
+
const ticket = ++reads;
|
|
251
|
+
// Its own changes first (a recipient added a moment ago), so the draft read has them.
|
|
252
|
+
void (editor.keeper?.flush() ?? Promise.resolve())
|
|
253
|
+
.then(() => fetch(`${ENDPOINTS.document}?collection=settings&path=${encodeURIComponent(SETTINGS_PATH)}`, { headers: { Accept: 'application/json' } }))
|
|
254
|
+
.then(async (response) => {
|
|
255
|
+
if (!response.ok || ticket !== reads) return;
|
|
256
|
+
const opened = (await response.json()) as AnyOpenedDocument;
|
|
257
|
+
if (ticket === reads && opened.document.kind === 'settings' && editor.doc.get().document.kind !== 'settings') store.set(viewOf(opened.document.data as Record<string, unknown>));
|
|
258
|
+
})
|
|
259
|
+
.catch(() => undefined);
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
settingsSources.set(editor, source);
|
|
263
|
+
return source;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Site Settings' SEO defaults and form recipients, for fields on another document (the
|
|
268
|
+
* fallbacks' placeholders, the Form block's "its email goes to"). While Site Settings is
|
|
269
|
+
* open, its edited values as they're typed; otherwise its draft, read again each time a
|
|
270
|
+
* field that needs it is shown (someone else, or another tab, may have changed it), with
|
|
271
|
+
* the last known shown meanwhile. Null until first read.
|
|
272
|
+
*/
|
|
273
|
+
export function useSiteSettings(): SettingsView | null {
|
|
274
|
+
const editor = useEditor();
|
|
275
|
+
const source = settingsFor(editor);
|
|
276
|
+
const view = useStore(source.store);
|
|
277
|
+
const settingsOpen = useStore(editor.doc.state, (s) => s.document.kind === 'settings');
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
if (!settingsOpen) source.refresh();
|
|
280
|
+
}, [source, settingsOpen]);
|
|
281
|
+
return view;
|
|
282
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M5a's small fields (Decision 2), each in the field frame and set through the
|
|
3
|
+
* field host (host.tsx):
|
|
4
|
+
*
|
|
5
|
+
* toggle `true` / `false`: the mockup's switch, "On" / "Off"
|
|
6
|
+
* number a JSON number, `null` when cleared. Out of range: kept as typed, with
|
|
7
|
+
* the reason on the field (Decision 15); the page gets null and shows its default
|
|
8
|
+
* date `"2026-10-15"`, `null` when cleared: the browser's own date input (Decision 2)
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useState } from 'react';
|
|
11
|
+
import type { NumberField as NumberDescriptor, ToggleField as ToggleDescriptor } from '../../../fields.ts';
|
|
12
|
+
import { FieldFrame } from './FieldFrame.tsx';
|
|
13
|
+
import { useFieldWarnings, useHost } from './host.tsx';
|
|
14
|
+
import { FieldIcon, Warnings } from './parts.tsx';
|
|
15
|
+
|
|
16
|
+
interface Props<F> {
|
|
17
|
+
name: string;
|
|
18
|
+
label: string;
|
|
19
|
+
field: F;
|
|
20
|
+
value: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const fieldId = (scope: string, name: string) => `f-${scope}-${name}`;
|
|
24
|
+
|
|
25
|
+
export function ToggleField(props: Props<ToggleDescriptor>) {
|
|
26
|
+
const host = useHost();
|
|
27
|
+
const id = fieldId(host.scope, props.name);
|
|
28
|
+
const on = typeof props.value === 'boolean' ? props.value : props.field.default;
|
|
29
|
+
const warnings = useFieldWarnings(props.name, props.field, props.value);
|
|
30
|
+
return (
|
|
31
|
+
<FieldFrame blockKey={host.scope} name={props.name} label={props.label} help={props.field.help} htmlFor={id}>
|
|
32
|
+
<button type="button" className="switch" role="switch" id={id} data-path={props.name} aria-checked={on} onClick={() => host.set(props.name, !on)}>
|
|
33
|
+
<span className="track" />
|
|
34
|
+
<span>{on ? 'On' : 'Off'}</span>
|
|
35
|
+
</button>
|
|
36
|
+
<Warnings warnings={warnings} />
|
|
37
|
+
</FieldFrame>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the input shows for a stored number. */
|
|
42
|
+
const shown = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? String(value) : '');
|
|
43
|
+
|
|
44
|
+
export function NumberField(props: Props<NumberDescriptor>) {
|
|
45
|
+
const host = useHost();
|
|
46
|
+
const id = fieldId(host.scope, props.name);
|
|
47
|
+
const { min, max, step } = props.field;
|
|
48
|
+
// What's typed, kept while it's mid-entry ("-", "1."), so the stored value never rewrites the input under the caret.
|
|
49
|
+
const [text, setText] = useState(() => shown(props.value));
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const parsed = text.trim() === '' ? null : Number(text);
|
|
52
|
+
const stored = typeof props.value === 'number' ? props.value : null;
|
|
53
|
+
if (parsed !== stored) setText(shown(props.value));
|
|
54
|
+
}, [props.value]);
|
|
55
|
+
const warnings = useFieldWarnings(props.name, props.field, props.value);
|
|
56
|
+
const bad = warnings.length > 0;
|
|
57
|
+
const range = min !== undefined && max !== undefined ? `${min} to ${max}` : min !== undefined ? `${min} or more` : max !== undefined ? `up to ${max}` : '';
|
|
58
|
+
const onChange = (next: string) => {
|
|
59
|
+
setText(next);
|
|
60
|
+
if (next.trim() === '') {
|
|
61
|
+
host.set(props.name, null);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const n = Number(next);
|
|
65
|
+
if (Number.isFinite(n)) host.set(props.name, n);
|
|
66
|
+
};
|
|
67
|
+
return (
|
|
68
|
+
<FieldFrame blockKey={host.scope} name={props.name} label={props.label} help={props.field.help} htmlFor={id}>
|
|
69
|
+
<div className="numf">
|
|
70
|
+
<input
|
|
71
|
+
className="input"
|
|
72
|
+
type="number"
|
|
73
|
+
id={id}
|
|
74
|
+
data-path={props.name}
|
|
75
|
+
inputMode="decimal"
|
|
76
|
+
value={text}
|
|
77
|
+
min={min}
|
|
78
|
+
max={max}
|
|
79
|
+
step={step ?? 'any'}
|
|
80
|
+
aria-invalid={bad || undefined}
|
|
81
|
+
aria-describedby={bad ? `${id}-w` : undefined}
|
|
82
|
+
onChange={(e) => onChange(e.target.value)}
|
|
83
|
+
/>
|
|
84
|
+
{range ? <span className="rng">{range}</span> : null}
|
|
85
|
+
</div>
|
|
86
|
+
<Warnings warnings={warnings} id={`${id}-w`} />
|
|
87
|
+
</FieldFrame>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text, one line or several (Decision 12; the line-break rule: a break is `\n`, and
|
|
3
|
+
* nothing else breaks the text). The same field is typed on the page (page-edit.ts);
|
|
4
|
+
* what's typed there shows here as it's typed (the store), and what's typed here
|
|
5
|
+
* re-renders the preview.
|
|
6
|
+
*
|
|
7
|
+
* M6: a field with a `max` (the Form block's intro, Decision 17) counts its characters,
|
|
8
|
+
* and past it the count and a note turn amber. Nothing is refused (it's kept as written).
|
|
9
|
+
*/
|
|
10
|
+
import type { TextField as TextDescriptor } from '../../../fields.ts';
|
|
11
|
+
import { useEditor } from '../context.tsx';
|
|
12
|
+
import { Icon } from '../shell/Icons.tsx';
|
|
13
|
+
import { FieldFrame } from './FieldFrame.tsx';
|
|
14
|
+
import { useFieldHost } from './host.tsx';
|
|
15
|
+
|
|
16
|
+
export function TextField(props: { blockKey: string; name: string; label: string; field: TextDescriptor; value: unknown }) {
|
|
17
|
+
const editor = useEditor();
|
|
18
|
+
// In a field host (host.tsx) the value is set through it; M3's block level passed the block's key.
|
|
19
|
+
const host = useFieldHost();
|
|
20
|
+
const id = `f-${props.blockKey}-${props.name}`.replace(/\./g, '-');
|
|
21
|
+
const value = typeof props.value === 'string' ? props.value : '';
|
|
22
|
+
const set = (next: string) => {
|
|
23
|
+
const v = props.field.multiline ? next : next.replace(/\n+/g, ' ');
|
|
24
|
+
if (host) host.set(props.name, v);
|
|
25
|
+
else editor.doc.setField(props.blockKey, props.name, v);
|
|
26
|
+
};
|
|
27
|
+
const max = props.field.max;
|
|
28
|
+
const over = max !== undefined && value.length > max;
|
|
29
|
+
const meta =
|
|
30
|
+
max !== undefined ? (
|
|
31
|
+
<span className={`field-meta${over ? ' soft-over' : ''}`} data-count={max} data-soft="">
|
|
32
|
+
{value.length} / {max}
|
|
33
|
+
</span>
|
|
34
|
+
) : undefined;
|
|
35
|
+
const warn = over ? (
|
|
36
|
+
<p className="field-warn" id={`${id}-w`} data-over="">
|
|
37
|
+
<Icon name="warn" size="sm" />
|
|
38
|
+
<span>Over {max}: it may not fit.</span>
|
|
39
|
+
</p>
|
|
40
|
+
) : null;
|
|
41
|
+
return (
|
|
42
|
+
<FieldFrame blockKey={props.blockKey} name={props.name} label={props.label} help={props.field.help} htmlFor={id} meta={meta}>
|
|
43
|
+
{props.field.multiline ? (
|
|
44
|
+
<textarea className="input" id={id} data-path={props.name} rows={3} value={value} spellCheck={false} aria-describedby={over ? `${id}-w` : undefined} onChange={(e) => set(e.target.value)} />
|
|
45
|
+
) : (
|
|
46
|
+
<input className="input" id={id} data-path={props.name} value={value} autoComplete="off" spellCheck={false} aria-describedby={over ? `${id}-w` : undefined} onChange={(e) => set(e.target.value)} />
|
|
47
|
+
)}
|
|
48
|
+
{warn}
|
|
49
|
+
</FieldFrame>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The date field's words (pre-M7 polish: the calendar in place of the browser's own
|
|
3
|
+
* date input). The stored value is still a day, `YYYY-MM-DD`, or null (fields.ts →
|
|
4
|
+
* parseDay); these turn it into what the box shows ("2 May 2026", the mockup's
|
|
5
|
+
* fmtDate) and read back what someone types.
|
|
6
|
+
*
|
|
7
|
+
* Typed days are read day first (the editor's people write 2/5/2026 for 2 May):
|
|
8
|
+
* `2026-05-02`, `2/5/2026`, `2.5.2026`, `2-5-2026`, `2 May 2026`, `May 2, 2026`, a
|
|
9
|
+
* month's first three letters, and a two-digit year as 20xx.
|
|
10
|
+
*/
|
|
11
|
+
import { parseDay } from '../../../fields.ts';
|
|
12
|
+
|
|
13
|
+
export const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
|
14
|
+
|
|
15
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
16
|
+
|
|
17
|
+
/** "2 May 2026" for `2026-05-02`; '' for anything that isn't a day. */
|
|
18
|
+
export function formatDay(value: unknown): string {
|
|
19
|
+
const day = parseDay(value);
|
|
20
|
+
if (!day) return '';
|
|
21
|
+
const [y, m, d] = day.split('-').map(Number) as [number, number, number];
|
|
22
|
+
return `${d} ${MONTHS[m - 1]} ${y}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const monthOf = (word: string) => {
|
|
26
|
+
const w = word.toLowerCase();
|
|
27
|
+
if (w.length < 3) return 0;
|
|
28
|
+
return MONTHS.findIndex((m) => m.toLowerCase().startsWith(w)) + 1;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const year = (text: string) => (text.length === 2 ? 2000 + Number(text) : Number(text));
|
|
32
|
+
|
|
33
|
+
const day = (y: number, m: number, d: number) => parseDay(`${String(y).padStart(4, '0')}-${pad(m)}-${pad(d)}`);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What someone typed, read as a day: the day (`YYYY-MM-DD`), null for an empty box
|
|
37
|
+
* (clears the field), or undefined when it can't be read as a real day.
|
|
38
|
+
*/
|
|
39
|
+
export function readTypedDay(text: string): string | null | undefined {
|
|
40
|
+
const t = text.trim().replace(/\s+/g, ' ');
|
|
41
|
+
if (t === '') return null;
|
|
42
|
+
let m = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(t);
|
|
43
|
+
if (m) return day(Number(m[1]), Number(m[2]), Number(m[3])) ?? undefined;
|
|
44
|
+
m = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4}|\d{2})$/.exec(t);
|
|
45
|
+
if (m) return day(year(m[3]!), Number(m[2]), Number(m[1])) ?? undefined;
|
|
46
|
+
m = /^(\d{1,2})(?:st|nd|rd|th)? ([a-z]+)\.?,? (\d{4}|\d{2})$/i.exec(t);
|
|
47
|
+
if (m && monthOf(m[2]!)) return day(year(m[3]!), monthOf(m[2]!), Number(m[1])) ?? undefined;
|
|
48
|
+
m = /^([a-z]+)\.? (\d{1,2})(?:st|nd|rd|th)?,? (\d{4}|\d{2})$/i.exec(t);
|
|
49
|
+
if (m && monthOf(m[1]!)) return day(year(m[3]!), monthOf(m[1]!), Number(m[2])) ?? undefined;
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A stored day as a local Date at midnight (the calendar's), or undefined. */
|
|
54
|
+
export function dayToDate(value: unknown): Date | undefined {
|
|
55
|
+
const d = parseDay(value);
|
|
56
|
+
if (!d) return undefined;
|
|
57
|
+
const [y, m, dd] = d.split('-').map(Number) as [number, number, number];
|
|
58
|
+
const at = new Date(y, m - 1, dd);
|
|
59
|
+
at.setFullYear(y); // years before 100 aren't 19xx
|
|
60
|
+
return at;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A calendar's Date (local) as a stored day. */
|
|
64
|
+
export const dateToDay = (date: Date) => `${String(date.getFullYear()).padStart(4, '0')}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
65
|
+
|
|
66
|
+
/** Today, as a day, in a time zone (the site's): `en-CA` writes YYYY-MM-DD. */
|
|
67
|
+
export function todayIn(timeZone: string, now = new Date()): string {
|
|
68
|
+
try {
|
|
69
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(now);
|
|
70
|
+
} catch {
|
|
71
|
+
return dateToDay(now);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a field's value lives, so the field components aren't tied to a block
|
|
3
|
+
* (M5a Phase 4; Phase 5 mounts the same components in a fields document's form).
|
|
4
|
+
*
|
|
5
|
+
* <FieldHostProvider host={host}>
|
|
6
|
+
* <FieldControl name="credits" field={descriptor} value={parsed} />
|
|
7
|
+
* </FieldHostProvider>
|
|
8
|
+
*
|
|
9
|
+
* A host says how to set a value by dotted path (M5a Decision 16: `credits.1.name`),
|
|
10
|
+
* how to remove a list row with Undo (Decision 14), what the file held for a field
|
|
11
|
+
* (warnings read the raw value while the field is untouched, Phase 1's note), and
|
|
12
|
+
* which field is picked. The block level's host is `blockHost(editor, key)`: every
|
|
13
|
+
* path goes to `editor.doc.setField(key, path, value)`.
|
|
14
|
+
*
|
|
15
|
+
* Warnings' context (links to documents that don't exist, references to documents
|
|
16
|
+
* that don't exist) comes from the editor's document listing: `useWarningContext()`.
|
|
17
|
+
*/
|
|
18
|
+
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
19
|
+
import { fieldWarnings, getAtPath, parseField, splitPath, type Field, type Fields, type FieldWarning, type WarningContext } from '../../../fields.ts';
|
|
20
|
+
import { mediaFor } from '../media/store.ts';
|
|
21
|
+
import { useEditor, useStore } from '../context.tsx';
|
|
22
|
+
import type { Editor, View } from '../editor.ts';
|
|
23
|
+
import { hiddenDocuments, hiddenPages } from '../../../links.ts';
|
|
24
|
+
|
|
25
|
+
export interface FieldHost {
|
|
26
|
+
/**
|
|
27
|
+
* What the fields belong to, for element ids and the picked state: the block's
|
|
28
|
+
* key in the block level; Phase 5's fields document picks its own (one per form).
|
|
29
|
+
*/
|
|
30
|
+
scope: string;
|
|
31
|
+
/** The path picked among this host's fields (a top-level key or a dotted path), or null. A pure read of the view. */
|
|
32
|
+
picked(view: View): string | null;
|
|
33
|
+
/** Pick a field (focus moved into it in the sidebar). `path` may be dotted. */
|
|
34
|
+
pick(path: string): void;
|
|
35
|
+
/** Set the value at a dotted path from the host's root: `credits.1.name`, `cta.href`, `featured`. */
|
|
36
|
+
set(path: string, value: unknown, options?: { undoLabel?: string }): void;
|
|
37
|
+
/** Remove item `index` of the list at `path`, with Undo under `label` (M2's toast; Decision 14). */
|
|
38
|
+
removeItem(path: string, index: number, label: string): void;
|
|
39
|
+
/** The file's value for a top-level field as first read; undefined when the file has none (a new block). */
|
|
40
|
+
raw(name: string): unknown;
|
|
41
|
+
/** Read-only (M4: a lock, held edits, signed out). Controls are disabled by the level's fieldset too. */
|
|
42
|
+
readOnly: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const HostContext = createContext<FieldHost | null>(null);
|
|
46
|
+
|
|
47
|
+
export function FieldHostProvider({ host, children }: { host: FieldHost; children: ReactNode }) {
|
|
48
|
+
return <HostContext.Provider value={host}>{children}</HostContext.Provider>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The host the field is rendered in, or null outside one (M3's fields fall back to their block key). */
|
|
52
|
+
export function useFieldHost(): FieldHost | null {
|
|
53
|
+
return useContext(HostContext);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The host every field needs. Throws outside a FieldHostProvider. */
|
|
57
|
+
export function useHost(): FieldHost {
|
|
58
|
+
const host = useContext(HostContext);
|
|
59
|
+
if (!host) throw new Error('A field needs a <FieldHostProvider>');
|
|
60
|
+
return host;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Is `path` the field `name` itself or a path inside it? */
|
|
64
|
+
export const within = (path: string | null | undefined, name: string): boolean => !!path && (path === name || path.startsWith(`${name}.`));
|
|
65
|
+
|
|
66
|
+
const rawCache = new Map<string, unknown>();
|
|
67
|
+
|
|
68
|
+
/** The file's text as a value (cached per text, so a re-render doesn't re-parse); undefined when it isn't JSON. */
|
|
69
|
+
export function parseBase(base: string): unknown {
|
|
70
|
+
if (rawCache.has(base)) return rawCache.get(base);
|
|
71
|
+
let value: unknown;
|
|
72
|
+
try {
|
|
73
|
+
value = JSON.parse(base);
|
|
74
|
+
} catch {
|
|
75
|
+
value = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (rawCache.size > 8) rawCache.clear();
|
|
78
|
+
rawCache.set(base, value);
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A block's raw fields in the file as first read (by its `source`), or undefined for an added block. */
|
|
83
|
+
export function rawBlock(base: string, source: number | null): Record<string, unknown> | undefined {
|
|
84
|
+
if (source === null) return undefined;
|
|
85
|
+
const doc = parseBase(base);
|
|
86
|
+
const blocks = doc && typeof doc === 'object' ? (doc as { blocks?: unknown }).blocks : undefined;
|
|
87
|
+
const raw = Array.isArray(blocks) ? blocks[source] : undefined;
|
|
88
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The block level's host: one block of the open document, by its key. */
|
|
92
|
+
export function blockHost(editor: Editor, key: string, raw: Record<string, unknown> | undefined, readOnly: boolean): FieldHost {
|
|
93
|
+
return {
|
|
94
|
+
scope: key,
|
|
95
|
+
picked: (view) => (view.level === 'block' && view.block === key ? view.field : null),
|
|
96
|
+
pick(path) {
|
|
97
|
+
const view = editor.view.get();
|
|
98
|
+
if (view.field !== path) editor.view.update({ field: path, picked: false });
|
|
99
|
+
},
|
|
100
|
+
set: (path, value, options) => editor.doc.setField(key, path, value, options),
|
|
101
|
+
removeItem(path, index, label) {
|
|
102
|
+
editor.doc.removeItem(key, path, index, { label });
|
|
103
|
+
editor.announce(`${label}. Undo with Command Z.`);
|
|
104
|
+
},
|
|
105
|
+
raw: (name) => (raw && Object.hasOwn(raw, name) ? raw[name] : undefined),
|
|
106
|
+
readOnly,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What warnings check against, from the editor's document listing: every document's
|
|
112
|
+
* address and the site's code routes (internal links, Decision 4) and whether a document exists (references,
|
|
113
|
+
* Decision 6).
|
|
114
|
+
*/
|
|
115
|
+
export function useWarningContext(): WarningContext {
|
|
116
|
+
const editor = useEditor();
|
|
117
|
+
const documents = useStore(editor.documents);
|
|
118
|
+
// M6c G4 (Decision 10): the library's file keys, once read (media/store.ts reads them the first time an opened document holds a /files/ link).
|
|
119
|
+
const files = useStore(mediaFor(editor).files, (l) => l.list);
|
|
120
|
+
return useMemo(() => {
|
|
121
|
+
const ids = new Set(documents.map((d) => `${d.collection}/${d.path}`));
|
|
122
|
+
const keys = files ? new Set(files.map((f) => f.key)) : null;
|
|
123
|
+
return {
|
|
124
|
+
addresses: documents.map((d) => d.url),
|
|
125
|
+
// M9 Decision 7: a link to a hidden page warns.
|
|
126
|
+
hiddenPages: hiddenPages(documents),
|
|
127
|
+
// ...and so does a reference to one.
|
|
128
|
+
hiddenDocuments: hiddenDocuments(documents),
|
|
129
|
+
// M8 trial (item 3): an address the site's own code serves exists too.
|
|
130
|
+
codeRoutes: editor.site.codeRoutes ?? [],
|
|
131
|
+
documentExists: (collection: string, path: string) => ids.has(`${collection}/${path}`),
|
|
132
|
+
...(keys ? { fileExists: (key: string) => keys.has(key) } : {}),
|
|
133
|
+
};
|
|
134
|
+
}, [documents, files]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A field's warnings (Phase 1's fieldWarnings): from the file's raw value while the
|
|
141
|
+
* field is untouched (so a heading where headings are off, kept in the file, is
|
|
142
|
+
* still said), from the edited value once it's changed.
|
|
143
|
+
*/
|
|
144
|
+
export function useFieldWarnings(name: string, field: Field, value: unknown): FieldWarning[] {
|
|
145
|
+
const host = useHost();
|
|
146
|
+
const context = useWarningContext();
|
|
147
|
+
// A field inside a group or a list item (`left.heading`): the file's value at that path in its top-level field.
|
|
148
|
+
const [top, ...inside] = splitPath(name);
|
|
149
|
+
const raw = inside.length ? getAtPath(host.raw(top!), inside.join('.')) : host.raw(name);
|
|
150
|
+
return useMemo(() => {
|
|
151
|
+
const untouched = raw !== undefined && same(parseField(field, raw), value);
|
|
152
|
+
return fieldWarnings(field, untouched ? raw : value, context);
|
|
153
|
+
}, [field, raw, value, context]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every warning in a set of fields (a form's, or a section builder's page fields), by
|
|
158
|
+
* the same rule as each field's own (useFieldWarnings): the file's value while a field is
|
|
159
|
+
* untouched, the edited one once it's changed. For a section's mark (GroupField.tsx →
|
|
160
|
+
* FieldSection), so a closed section says it holds a warning.
|
|
161
|
+
*/
|
|
162
|
+
export function fieldsWarnings(fields: Fields, data: Record<string, unknown>, raw: Record<string, unknown> | undefined, context: WarningContext): FieldWarning[] {
|
|
163
|
+
return Object.entries(fields).flatMap(([name, field]) => {
|
|
164
|
+
const was = raw && Object.hasOwn(raw, name) ? raw[name] : undefined;
|
|
165
|
+
const untouched = was !== undefined && same(parseField(field, was), data[name]);
|
|
166
|
+
return fieldWarnings(field, untouched ? was : data[name], context, name);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Warnings at a path relative to the field: `''` the field's own, `'1'` and `'1.href'` the second item's. */
|
|
171
|
+
export const warningsAt = (warnings: readonly FieldWarning[], path: string): FieldWarning[] =>
|
|
172
|
+
warnings.filter((w) => w.path === path || w.path.startsWith(`${path}.`));
|