caracms 0.1.0 → 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/bin/init.mjs +1 -0
- package/bin/templates/catch-all.astro.txt +3 -2
- package/package.json +1 -1
- package/skills/caracms-adopt/SKILL.md +12 -0
- package/skills/caracms-adopt/look-same.mjs +16 -7
- package/skills/caracms-blocks/SKILL.md +49 -2
- package/skills/caracms-content/SKILL.md +2 -1
- 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/cli/validate.ts +10 -5
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +20 -1
- package/src/components/Form.astro +76 -10
- package/src/components/Head.astro +1 -0
- package/src/components/Media.astro +27 -1
- package/src/components/RichText.astro +12 -4
- package/src/components/Text.astro +32 -9
- package/src/components/form-client.ts +7 -4
- package/src/components/sitemap.xml.ts +2 -1
- package/src/config.ts +32 -1
- package/src/content-schema.ts +7 -4
- package/src/content.ts +26 -0
- package/src/document.ts +15 -2
- package/src/editor/client/block-list/summary.ts +7 -1
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +17 -12
- package/src/editor/client/fields/ReferenceField.tsx +6 -0
- package/src/editor/client/fields/RichTextField.tsx +38 -32
- package/src/editor/client/fields/host.tsx +5 -0
- package/src/editor/client/fields/parts.tsx +2 -1
- package/src/editor/client/fields/rich-text-doc.ts +28 -2
- package/src/editor/client/fields/rich-text-schema.ts +41 -2
- package/src/editor/client/nav/DocRow.tsx +6 -0
- package/src/editor/client/overlays/rich-text-bar.ts +6 -6
- package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
- package/src/editor/client/preview/driver.ts +5 -5
- package/src/editor/client/preview/field-text.ts +40 -2
- package/src/editor/client/preview/page-edit.ts +2 -2
- package/src/editor/client/preview/page-editor-registry.ts +4 -3
- package/src/editor/client/preview/page-editor.ts +3 -3
- package/src/editor/client/state/document-store.ts +20 -2
- package/src/editor/styles/editor.css +2 -0
- package/src/editor/types.ts +2 -0
- package/src/fields.ts +63 -15
- package/src/forms/rules.ts +9 -1
- package/src/head.ts +7 -3
- package/src/links.ts +35 -0
- package/src/live/README.md +1 -0
- package/src/live/backend.ts +41 -23
- package/src/live/branches.ts +19 -0
- package/src/live/mode.ts +46 -0
- package/src/live/runtime.ts +5 -2
- package/src/media.ts +14 -6
- package/src/read.ts +2 -1
- package/src/rich-text.ts +30 -5
- package/src/serialize.ts +10 -1
|
@@ -211,8 +211,8 @@ export function startRichTextBar(editor: Editor, overlays: Overlays, pageEdit: P
|
|
|
211
211
|
|
|
212
212
|
/** The bar's buttons, for the field's options (built once per set of options). */
|
|
213
213
|
function build(s: Shown) {
|
|
214
|
-
const { headings, links } = s.state.options;
|
|
215
|
-
const key = `${headings.join(',')}|${links}`;
|
|
214
|
+
const { headings, links, heading } = s.state.options;
|
|
215
|
+
const key = `${headings.join(',')}|${links}|${!!heading}`;
|
|
216
216
|
if (s.built === key) return;
|
|
217
217
|
s.built = key;
|
|
218
218
|
const sep = '<span class="cms-rtsep" aria-hidden="true"></span>';
|
|
@@ -223,10 +223,10 @@ export function startRichTextBar(editor: Editor, overlays: Overlays, pageEdit: P
|
|
|
223
223
|
? `<button type="button" class="cms-st" data-rtc="style" aria-haspopup="menu" aria-expanded="false" aria-label="Paragraph style: Paragraph">Paragraph${icon('chev-d')}</button>${sep}`
|
|
224
224
|
: '') +
|
|
225
225
|
button('bold', 'Bold', 'Bold (⌘B)', '<b>B</b>') +
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
// M9 Decision 11: a heading field is bold and line breaks only.
|
|
227
|
+
(heading
|
|
228
|
+
? ''
|
|
229
|
+
: button('italic', 'Italic', 'Italic (⌘I)', '<i>I</i>') + sep + button('ul', 'Bulleted list', 'Bulleted list', icon('ul')) + button('ol', 'Numbered list', 'Numbered list', icon('ol'))) +
|
|
230
230
|
(links ? sep + button('link', 'Link', 'Link (⌘K)', icon('link'), ' aria-haspopup="dialog"') : '');
|
|
231
231
|
}
|
|
232
232
|
|
|
@@ -20,6 +20,7 @@ import { DocNotices, ReadOnlyChip } from '../../live/notices.tsx';
|
|
|
20
20
|
import { DocMenu } from '../../documents/DocMenu.tsx';
|
|
21
21
|
import { FieldsDocumentLevel, SettingsLevel, SharedDocumentLevel } from './FormLevel.tsx';
|
|
22
22
|
import { SeoGroup } from '../../fields/SeoGroup.tsx';
|
|
23
|
+
import { HiddenToggle } from '../../fields/HiddenToggle.tsx';
|
|
23
24
|
import { PageFields } from '../../fields/PageFields.tsx';
|
|
24
25
|
|
|
25
26
|
/** The open document's level, by its kind (M5a): a section builder's blocks, or a form. */
|
|
@@ -81,6 +82,8 @@ function SectionsLevel() {
|
|
|
81
82
|
)}
|
|
82
83
|
{/* M6c (G2): the page's own fields, when its collection has some, under its blocks. */}
|
|
83
84
|
<PageFields />
|
|
85
|
+
{/* M9 Decision 7: hidden from the site, or not. */}
|
|
86
|
+
<HiddenToggle />
|
|
84
87
|
{/* M6 Decision 13: the page's own Search and sharing, under its blocks. */}
|
|
85
88
|
<SeoGroup />
|
|
86
89
|
</div>
|
|
@@ -83,13 +83,13 @@ import type { DocumentStore } from '../state/document-store.ts';
|
|
|
83
83
|
import { createStore, type Store } from '../state/store.ts';
|
|
84
84
|
import { ENDPOINTS, PREVIEW_MARK, TRUE_WIDTH, WIDTHS, sameDoc, type DocRef, type EditorSite, type Width } from '../../types.ts';
|
|
85
85
|
import { createSequencer, type Sequencer } from './render-sequencer.ts';
|
|
86
|
-
import { fieldText } from './field-text.ts';
|
|
86
|
+
import { fieldText, shownText, writeFieldText } from './field-text.ts';
|
|
87
87
|
import { restoreSelection, saveSelection } from './selection.ts';
|
|
88
88
|
import { readLockups, type LockupWarning } from './warnings.ts';
|
|
89
89
|
import { ACTIVITY_EVENTS } from '../state/live.ts';
|
|
90
90
|
import { fieldElement } from './markers.ts';
|
|
91
91
|
import { mountedIn, writeRichText } from './page-editor-registry.ts';
|
|
92
|
-
import { fieldAt, getAtPath, isKeyPath, type Fields, type RichTextField } from '../../../fields.ts';
|
|
92
|
+
import { fieldAt, getAtPath, isKeyPath, richTextOptions, type Fields, type RichTextField } from '../../../fields.ts';
|
|
93
93
|
import { fieldsOfBlock } from '../state/schema.ts';
|
|
94
94
|
|
|
95
95
|
export interface PreviewFrame {
|
|
@@ -501,7 +501,7 @@ export function createPreviewDriver(options: {
|
|
|
501
501
|
function setRichText(doc: Document, block: number, field: string, value: unknown, descriptor: RichTextField) {
|
|
502
502
|
const el = fieldElement(doc, block, field, 'richText');
|
|
503
503
|
if (!el || mountedIn(doc)?.el === el) return;
|
|
504
|
-
writeRichText(el, value,
|
|
504
|
+
writeRichText(el, value, richTextOptions(descriptor));
|
|
505
505
|
}
|
|
506
506
|
|
|
507
507
|
/** The field's descriptor in a block, when it's a rich-text field typed on the page (top level, or in a group). */
|
|
@@ -515,14 +515,14 @@ export function createPreviewDriver(options: {
|
|
|
515
515
|
|
|
516
516
|
/**
|
|
517
517
|
* Put a plain text field's text into a frame. `\n` becomes <br> (innerText's setter;
|
|
518
|
-
* the line-break rule); the text is compared as typed (field-text.ts), not as the
|
|
518
|
+
* the line-break rule), or a line each in the lines rendering (field-text.ts); the text is compared as typed (field-text.ts), not as the
|
|
519
519
|
* site's CSS shows it. `skipActive`: leave the field being typed in alone (it's the
|
|
520
520
|
* source of the text, and rewriting it would lose the caret).
|
|
521
521
|
*/
|
|
522
522
|
function setText(doc: Document, block: number, field: string, text: string, skipActive = false) {
|
|
523
523
|
const el = fieldElement(doc, block, field, 'text');
|
|
524
524
|
if (!el || (skipActive && doc.activeElement === el)) return;
|
|
525
|
-
if (fieldText(el) !== text) el
|
|
525
|
+
if (fieldText(el) !== shownText(el, text)) writeFieldText(el, text);
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
store.subscribe((session, change) => {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* capitals), and a contenteditable's spaces come back as no-break spaces.
|
|
6
6
|
*
|
|
7
7
|
* - a text node is its text (no-break spaces as spaces); a `<br>` is `\n`
|
|
8
|
-
* - a block element the browser inserted (a `<div>` on Enter) starts a new line
|
|
8
|
+
* - a block element the browser inserted (a `<div>` on Enter) starts a new line, and so
|
|
9
|
+
* does a line of the lines rendering (`[data-cms-line]`, M9 Decision 10)
|
|
9
10
|
* - the trailing `<br>` a browser adds so an empty last line shows is dropped
|
|
10
11
|
* - one line only: breaks become spaces
|
|
11
12
|
*/
|
|
@@ -20,7 +21,7 @@ export function fieldText(el: Element, multiline = true): string {
|
|
|
20
21
|
const tag = (child as Element).tagName;
|
|
21
22
|
if (tag === 'BR') out += '\n';
|
|
22
23
|
else {
|
|
23
|
-
if (BLOCKS.has(tag) && out && !out.endsWith('\n')) out += '\n';
|
|
24
|
+
if ((BLOCKS.has(tag) || (child as Element).hasAttribute('data-cms-line')) && out && !out.endsWith('\n')) out += '\n';
|
|
24
25
|
walk(child);
|
|
25
26
|
}
|
|
26
27
|
}
|
|
@@ -31,3 +32,40 @@ export function fieldText(el: Element, multiline = true): string {
|
|
|
31
32
|
if (out.endsWith('\n')) out = out.slice(0, -1);
|
|
32
33
|
return multiline ? out : out.replace(/\n+/g, ' ');
|
|
33
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The text a field element shows for `text`: a lines-rendering field (`[data-cms-lines]`,
|
|
38
|
+
* M9 Decision 10) trims each line and drops blank ones, as Text.astro does; any other field shows it all.
|
|
39
|
+
*/
|
|
40
|
+
export function shownText(el: Element, text: string): string {
|
|
41
|
+
return el.hasAttribute('data-cms-lines') ? text.split('\n').map((line) => line.trim()).filter((line) => line !== '').join('\n') : text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Put a plain text field's text into its element as the render draws it. `\n` becomes
|
|
46
|
+
* `<br>` (innerText's setter; the line-break rule), except in the lines rendering (M9
|
|
47
|
+
* Decision 10): blank lines drop, one line is bare text, and two or more are a `<span>`
|
|
48
|
+
* each with the component's `lineClass` and the `data-cms-line` marker.
|
|
49
|
+
*/
|
|
50
|
+
export function writeFieldText(el: HTMLElement, text: string): void {
|
|
51
|
+
if (!el.hasAttribute('data-cms-lines')) {
|
|
52
|
+
el.innerText = text;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const lines = shownText(el, text).split('\n').filter((line) => line !== '');
|
|
56
|
+
if (lines.length <= 1) {
|
|
57
|
+
el.textContent = lines[0] ?? '';
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const doc = el.ownerDocument;
|
|
61
|
+
const cls = el.getAttribute('data-cms-line-class');
|
|
62
|
+
el.replaceChildren(
|
|
63
|
+
...lines.map((line) => {
|
|
64
|
+
const span = doc.createElement('span');
|
|
65
|
+
if (cls) span.className = cls;
|
|
66
|
+
span.setAttribute('data-cms-line', '');
|
|
67
|
+
span.textContent = line;
|
|
68
|
+
return span;
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -46,7 +46,7 @@ import { blockElement, blockIndexOf, blockRootOf, fieldElement, skippedAnchor, s
|
|
|
46
46
|
import { mountPageEditor, pointInField, positionOf, type MountedPageEditor } from './page-editor.ts';
|
|
47
47
|
import { mountedIn, setMounted } from './page-editor-registry.ts';
|
|
48
48
|
import { fieldsOfBlock } from '../state/schema.ts';
|
|
49
|
-
import { fieldAt, getAtPath, isKeyPath } from '../../../fields.ts';
|
|
49
|
+
import { fieldAt, getAtPath, isKeyPath, richTextOptions } from '../../../fields.ts';
|
|
50
50
|
import type { RichTextOptions } from '../../../rich-text.ts';
|
|
51
51
|
|
|
52
52
|
export interface Typing {
|
|
@@ -227,7 +227,7 @@ export function pageEditFor(editor: Editor): PageEdit | null {
|
|
|
227
227
|
const block = editor.doc.block(key);
|
|
228
228
|
const session = editor.doc.get();
|
|
229
229
|
const descriptor = block?.known ? fieldAt(fieldsOfBlock(editor.site, session.document, session.ref.collection, block), field) : undefined;
|
|
230
|
-
return descriptor?.kind === 'richText' ?
|
|
230
|
+
return descriptor?.kind === 'richText' ? richTextOptions(descriptor) : null;
|
|
231
231
|
};
|
|
232
232
|
|
|
233
233
|
/** Mount the page editor on a field (tearing down any other). Null where it isn't typed on the page. */
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Light on purpose (no Tiptap here): page-edit.ts mounts and registers; the driver
|
|
9
9
|
* only asks.
|
|
10
10
|
*/
|
|
11
|
-
import { parseRichText, richTextHtml, type RichTextOptions } from '../../../rich-text.ts';
|
|
11
|
+
import { parseRichText, richHeadingHtml, richTextHtml, type RichTextOptions } from '../../../rich-text.ts';
|
|
12
12
|
|
|
13
13
|
export interface MountedField {
|
|
14
14
|
/** The field's element: the editor's root while mounted. */
|
|
@@ -42,9 +42,10 @@ export function setMounted(doc: Document, mounted: MountedField | null): void {
|
|
|
42
42
|
else byDoc.delete(doc);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/** A field's value as the render gives it: the same HTML RichText.astro writes. */
|
|
45
|
+
/** A field's value as the render gives it: the same HTML RichText.astro writes (a heading's: its lines, M9 Decision 11). */
|
|
46
46
|
export function renderedHtml(raw: unknown, options: RichTextOptions): string {
|
|
47
|
-
|
|
47
|
+
const value = parseRichText(raw, options);
|
|
48
|
+
return options.heading ? richHeadingHtml(value) : richTextHtml(value);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
/**
|
|
@@ -141,7 +141,7 @@ export function mountPageEditor(
|
|
|
141
141
|
onKeyDown?: (event: KeyboardEvent) => boolean;
|
|
142
142
|
},
|
|
143
143
|
): MountedPageEditor {
|
|
144
|
-
const options = { headings: [...init.options.headings], links: init.options.links };
|
|
144
|
+
const options: RichTextOptions = { headings: [...init.options.headings], links: init.options.links, ...(init.options.heading ? { heading: true } : {}) };
|
|
145
145
|
let value = parseRichText(init.value, options);
|
|
146
146
|
let last = JSON.stringify(value);
|
|
147
147
|
// Decision 5's recipe: the attributes exactly as the render left them.
|
|
@@ -152,7 +152,7 @@ export function mountPageEditor(
|
|
|
152
152
|
// Decision 3: ProseMirror uses the element itself only when given `{ mount }`.
|
|
153
153
|
element: { mount: el } as unknown as HTMLElement,
|
|
154
154
|
extensions: richTextExtensions(options),
|
|
155
|
-
content: toDoc(value),
|
|
155
|
+
content: toDoc(value, options),
|
|
156
156
|
injectCSS: false,
|
|
157
157
|
enableInputRules: false,
|
|
158
158
|
enablePasteRules: false,
|
|
@@ -189,7 +189,7 @@ export function mountPageEditor(
|
|
|
189
189
|
// Only the part that differs is replaced, so the selection maps through it (a
|
|
190
190
|
// whole-document setContent would put it at the end), without an update and
|
|
191
191
|
// outside the page's own undo (Decision 11).
|
|
192
|
-
const target = editor.schema.nodeFromJSON(toDoc(next));
|
|
192
|
+
const target = editor.schema.nodeFromJSON(toDoc(next, options));
|
|
193
193
|
const current = editor.state.doc;
|
|
194
194
|
const start = current.content.findDiffStart(target.content);
|
|
195
195
|
if (start === null) return;
|
|
@@ -62,6 +62,8 @@ export type DocumentChange =
|
|
|
62
62
|
/** `field` is the path that was set: a field's key, or a dotted path into it (`credits.1.name`). */
|
|
63
63
|
| { kind: 'field'; key: string; field: string; origin: ChangeOrigin }
|
|
64
64
|
| { kind: 'title' }
|
|
65
|
+
/** M9 Decision 7: the page hidden from the site, or shown again. */
|
|
66
|
+
| { kind: 'hidden' }
|
|
65
67
|
| { kind: 'add'; key: string }
|
|
66
68
|
| { kind: 'remove'; key: string }
|
|
67
69
|
| { kind: 'move'; key: string }
|
|
@@ -114,6 +116,8 @@ export interface DocumentStore {
|
|
|
114
116
|
*/
|
|
115
117
|
setField(key: string, field: string, value: unknown, options?: { origin?: ChangeOrigin; undoLabel?: string }): void;
|
|
116
118
|
setTitle(title: string): void;
|
|
119
|
+
/** M9 Decision 7: hide a section builder's page from the site, or show it again (never the home page's). */
|
|
120
|
+
setHidden(hidden: boolean): void;
|
|
117
121
|
/** Add a new, empty block of a known type at `index` (default: the end). Returns its key. */
|
|
118
122
|
addBlock(type: string, index?: number): string;
|
|
119
123
|
/**
|
|
@@ -196,11 +200,12 @@ export function createDocumentStore(
|
|
|
196
200
|
: doc.blocks.map(keyed);
|
|
197
201
|
const seo = (doc.kind === undefined || doc.kind === 'sections' || doc.kind === 'fields') && doc.seo !== undefined ? { seo: doc.seo as Record<string, unknown> } : {};
|
|
198
202
|
const data = (doc.kind === undefined || doc.kind === 'sections') && doc.data !== undefined ? { data: doc.data } : {};
|
|
203
|
+
const hidden = (doc.kind === undefined || doc.kind === 'sections') && doc.hidden ? { hidden: true } : {};
|
|
199
204
|
return {
|
|
200
205
|
ref: { collection: input.ref.collection, path: input.ref.path },
|
|
201
206
|
entry: input.entry,
|
|
202
207
|
base: input.base,
|
|
203
|
-
document: { kind, title: doc.title, blocks, ...seo, ...data },
|
|
208
|
+
document: { kind, title: doc.title, blocks, ...seo, ...data, ...hidden },
|
|
204
209
|
revision: 0,
|
|
205
210
|
undo: [],
|
|
206
211
|
};
|
|
@@ -268,6 +273,13 @@ export function createDocumentStore(
|
|
|
268
273
|
commit({ ...current, document: { ...current.document, title }, revision: current.revision + 1 }, { kind: 'title' });
|
|
269
274
|
},
|
|
270
275
|
|
|
276
|
+
setHidden(hidden) {
|
|
277
|
+
if (!editable() || !builder()) return;
|
|
278
|
+
const current = state.get();
|
|
279
|
+
if (!!current.document.hidden === hidden) return;
|
|
280
|
+
commit({ ...current, document: { ...current.document, hidden }, revision: current.revision + 1 }, { kind: 'hidden' });
|
|
281
|
+
},
|
|
282
|
+
|
|
271
283
|
addBlock(type, index) {
|
|
272
284
|
if (!editable() || !builder()) return '';
|
|
273
285
|
const created = emptyBlock(type, site as Parameters<typeof emptyBlock>[1]);
|
|
@@ -418,7 +430,13 @@ export function createDocumentStore(
|
|
|
418
430
|
case 'shared':
|
|
419
431
|
return { kind: 'shared', title: document.title, block: first ? plain(first) : { source: 0, type: '', known: false, data: {} } };
|
|
420
432
|
default:
|
|
421
|
-
return {
|
|
433
|
+
return {
|
|
434
|
+
title: document.title,
|
|
435
|
+
blocks: document.blocks.map(plain),
|
|
436
|
+
...seoWire(),
|
|
437
|
+
...(document.data !== undefined ? { data: document.data } : {}),
|
|
438
|
+
...(document.hidden !== undefined ? { hidden: document.hidden } : {}),
|
|
439
|
+
};
|
|
422
440
|
}
|
|
423
441
|
},
|
|
424
442
|
};
|
|
@@ -215,6 +215,8 @@ details.fgroup[open]>summary .i{rotate:90deg}
|
|
|
215
215
|
details.fgroup>.in{padding:0 var(--space-4) var(--space-2)}
|
|
216
216
|
details.fgroup>summary .wmark{margin-left:var(--space-1)}
|
|
217
217
|
details.fgroup>summary .wmark .i{margin-left:0;rotate:none}
|
|
218
|
+
/* M9 Decision 7: the page's "Hidden from the site" switch, between its page fields and Search and sharing. */
|
|
219
|
+
.page-hidden{margin-top:var(--space-4)}
|
|
218
220
|
/* A group field (group()): Page details' section, among a form's fields; inside a list item it keeps to the item's width. */
|
|
219
221
|
details.fgroup.group-field{margin-block:var(--space-3)}
|
|
220
222
|
details.fgroup.group-field+details.fgroup.group-field{margin-top:calc(-1 * var(--space-3))}
|
package/src/editor/types.ts
CHANGED
|
@@ -235,6 +235,8 @@ export interface EditorDocument {
|
|
|
235
235
|
* has page fields. Set through `setField(PAGE_SCOPE, …)` (document-store.ts).
|
|
236
236
|
*/
|
|
237
237
|
data?: Record<string, unknown>;
|
|
238
|
+
/** M9 Decision 7: a section builder hidden from the site; absent when the file isn't. Set through `setHidden`. */
|
|
239
|
+
hidden?: boolean;
|
|
238
240
|
}
|
|
239
241
|
|
|
240
242
|
/** The preview widths (M2 Decision 3): each iframe at its true width. */
|
package/src/fields.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* text() one line
|
|
9
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)
|
|
10
11
|
* text({ max: 200 }) M6: a soft limit, counted in the editor and amber past it (nothing refused)
|
|
11
12
|
* lockup({ style }) display text set as lines, optionally with phone lines (Decisions 10–11)
|
|
12
13
|
* select({ options }) one of a fixed list
|
|
@@ -24,7 +25,7 @@
|
|
|
24
25
|
*
|
|
25
26
|
* M5b (Decision 3) adds:
|
|
26
27
|
*
|
|
27
|
-
* media({ only?, play? })
|
|
28
|
+
* media({ only?, play?, format? }) an image `{ key, alt, … }` or a Mux clip `{ video, play, … }` (media-value.ts)
|
|
28
29
|
*
|
|
29
30
|
* M6c adds `help` (G8: plain text under the field in the sidebar) to every helper, and
|
|
30
31
|
* `link({ addressOnly: true })` (G9): `{ "href" }`, a link with no words of its own.
|
|
@@ -47,9 +48,9 @@
|
|
|
47
48
|
*/
|
|
48
49
|
import { fileAddress, fileKeyOf, fileTypeOf, isFileKey } from './files.ts';
|
|
49
50
|
import { codeRouteAt, type CodeRoute } from './code-routes.ts';
|
|
50
|
-
import { internalLinkPath, isLinkHref, missingLinkTarget, validateLink } from './links.ts';
|
|
51
|
+
import { hiddenLinkTarget, hiddenReferenceTarget, internalLinkPath, isLinkHref, missingLinkTarget, validateLink } from './links.ts';
|
|
51
52
|
import { MISSING_ALT_MESSAGE, mediaKind, mediaToJson, parseMedia, renderMedia, type MediaOnly, type MediaValue, type RenderMedia } from './media-value.ts';
|
|
52
|
-
import { readRichText, richTextHrefs, serializeRichText, type HeadingKind, type RichTextValue } from './rich-text.ts';
|
|
53
|
+
import { readRichText, richTextHrefs, serializeRichText, type HeadingKind, type RichTextOptions, type RichTextValue } from './rich-text.ts';
|
|
53
54
|
|
|
54
55
|
export type { RichTextValue } from './rich-text.ts';
|
|
55
56
|
export type { ClipValue, ImageValue, MediaOnly, MediaValue, PlayAs, RenderClip, RenderImage, RenderMedia } from './media-value.ts';
|
|
@@ -80,7 +81,12 @@ export interface TextField extends FieldBase {
|
|
|
80
81
|
* M6: a soft limit (the Form block's intro, Decision 17): the editor counts the
|
|
81
82
|
* characters and turns amber past it. Nothing is refused: it's kept as written.
|
|
82
83
|
*/
|
|
83
|
-
max?: number;
|
|
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;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
export interface LockupField extends FieldBase {
|
|
@@ -108,6 +114,12 @@ export interface RichTextField extends FieldBase {
|
|
|
108
114
|
headings: readonly HeadingKind[];
|
|
109
115
|
/** Links allowed (default true). */
|
|
110
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;
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
export interface LinkField extends FieldBase {
|
|
@@ -200,14 +212,19 @@ export interface ReferenceField extends FieldBase {
|
|
|
200
212
|
/**
|
|
201
213
|
* A media slot (M5b Decision 3): an image or a Mux clip. `only` limits it to one
|
|
202
214
|
* kind (a value of the other is kept and warned); `play: 'loop'` makes it loop-only
|
|
203
|
-
* (the hero): any clip in it renders as a loop.
|
|
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).
|
|
204
217
|
*/
|
|
205
218
|
export interface MediaField extends FieldBase {
|
|
206
219
|
kind: 'media';
|
|
207
220
|
only?: MediaOnly;
|
|
208
221
|
play?: 'loop';
|
|
222
|
+
format?: ImageFormat;
|
|
209
223
|
}
|
|
210
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
|
+
|
|
211
228
|
export type Field =
|
|
212
229
|
| TextField
|
|
213
230
|
| LockupField
|
|
@@ -288,9 +305,10 @@ export type BlockData<F extends Fields> = { [K in keyof F]: FieldValue<F[K]> };
|
|
|
288
305
|
|
|
289
306
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
290
307
|
|
|
291
|
-
export function text(options: { label?: string; help?: string; multiline?: boolean; heading?: boolean; max?: number } = {}): TextField {
|
|
292
|
-
const field: TextField = { kind: 'text', label: options.label, multiline: options.multiline ?? false, heading: options.heading ?? false };
|
|
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 };
|
|
293
310
|
if (options.max !== undefined) field.max = options.max;
|
|
311
|
+
if (options.lines) field.lines = true;
|
|
294
312
|
return withHelp(field, options);
|
|
295
313
|
}
|
|
296
314
|
|
|
@@ -316,9 +334,14 @@ export function select<const V extends string>(options: {
|
|
|
316
334
|
return withHelp({ kind: 'select', label: options.label, options: list, default: fallback }, options);
|
|
317
335
|
}
|
|
318
336
|
|
|
319
|
-
/**
|
|
320
|
-
|
|
321
|
-
|
|
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);
|
|
322
345
|
}
|
|
323
346
|
|
|
324
347
|
/**
|
|
@@ -399,12 +422,14 @@ export function group<const F extends Fields>(fields: F, options: { label?: stri
|
|
|
399
422
|
|
|
400
423
|
/**
|
|
401
424
|
* An image or a clip (M5b Decision 3). `only: 'image'` or `'video'` takes one kind;
|
|
402
|
-
* `play: 'loop'` makes the slot loop-only (a clip in it always plays as a loop)
|
|
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).
|
|
403
427
|
*/
|
|
404
|
-
export function media(options: { label?: string; help?: string; only?: MediaOnly; play?: 'loop' } = {}): MediaField {
|
|
428
|
+
export function media(options: { label?: string; help?: string; only?: MediaOnly; play?: 'loop'; format?: ImageFormat } = {}): MediaField {
|
|
405
429
|
const field: MediaField = { kind: 'media', label: options.label };
|
|
406
430
|
if (options.only !== undefined) field.only = options.only;
|
|
407
431
|
if (options.play !== undefined) field.play = options.play;
|
|
432
|
+
if (options.format !== undefined) field.format = options.format;
|
|
408
433
|
return withHelp(field, options);
|
|
409
434
|
}
|
|
410
435
|
|
|
@@ -444,7 +469,7 @@ export function parseDay(value: unknown): string | null {
|
|
|
444
469
|
return at.getUTCFullYear() === y && at.getUTCMonth() === mo - 1 && at.getUTCDate() === d ? m[0]! : null;
|
|
445
470
|
}
|
|
446
471
|
|
|
447
|
-
const richTextOptions = (field: RichTextField) => ({ headings: field.headings, links: field.links });
|
|
472
|
+
export const richTextOptions = (field: RichTextField): RichTextOptions => ({ headings: field.headings, links: field.links, ...(field.heading ? { heading: true } : {}) });
|
|
448
473
|
|
|
449
474
|
function parseButton(field: ButtonsField, raw: Record<string, unknown>): ButtonValue {
|
|
450
475
|
const style = typeof raw.style === 'string' && field.styles.some((o) => o.value === raw.style) ? raw.style : field.styles[0]!.value;
|
|
@@ -732,12 +757,18 @@ export type FieldWarningCode =
|
|
|
732
757
|
| 'disallowed-kind'
|
|
733
758
|
/** Rich text: a link where the field has links off, shown as its text. */
|
|
734
759
|
| 'link-off'
|
|
760
|
+
/** Rich text: italic in a heading rich-text field (M9 Decision 11), shown as plain text. */
|
|
761
|
+
| 'italic-off'
|
|
735
762
|
/** A value (or a rich-text entry) the field can't read: shown as empty, kept until the field is edited. */
|
|
736
763
|
| 'unreadable'
|
|
737
764
|
/** A link that fails the link rule (Decision 4): shown as text. */
|
|
738
765
|
| 'link-rule'
|
|
739
766
|
/** An internal link to a path no document has (Decision 4). */
|
|
740
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'
|
|
741
772
|
/** A link or button with only one of its label and its link: shows nothing. */
|
|
742
773
|
| 'half-link'
|
|
743
774
|
/** More buttons than the field's `max`: the extra ones don't show. */
|
|
@@ -782,6 +813,13 @@ export interface WarningContext {
|
|
|
782
813
|
* address under it, since which ones it builds isn't known from its file name.
|
|
783
814
|
*/
|
|
784
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 }>;
|
|
785
823
|
}
|
|
786
824
|
|
|
787
825
|
const join = (base: string, part: string | number) => (base === '' ? String(part) : `${base}.${part}`);
|
|
@@ -804,6 +842,11 @@ function hrefWarnings(href: string, path: string, context: WarningContext, out:
|
|
|
804
842
|
if (context.addresses) {
|
|
805
843
|
const target = internalLinkPath(href);
|
|
806
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
|
+
}
|
|
807
850
|
const missing = missingLinkTarget(href, context.addresses);
|
|
808
851
|
if (missing) out.push({ path, code: 'link-missing', message: missing });
|
|
809
852
|
}
|
|
@@ -834,6 +877,8 @@ export function fieldWarnings(field: Field, raw: unknown, context: WarningContex
|
|
|
834
877
|
code: 'disallowed-kind',
|
|
835
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.`,
|
|
836
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.' });
|
|
837
882
|
} else if (issue.code === 'link-off') {
|
|
838
883
|
out.push({
|
|
839
884
|
path: at,
|
|
@@ -898,6 +943,9 @@ export function fieldWarnings(field: Field, raw: unknown, context: WarningContex
|
|
|
898
943
|
const v = parseField(field, raw) as string | null;
|
|
899
944
|
if (v !== null && context.documentExists && !context.documentExists(field.to, v)) {
|
|
900
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 });
|
|
901
949
|
}
|
|
902
950
|
break;
|
|
903
951
|
}
|
|
@@ -987,12 +1035,12 @@ export const isKeyPath = (path: string) => path !== '' && !splitPath(path).some(
|
|
|
987
1035
|
|
|
988
1036
|
/**
|
|
989
1037
|
* The fields that can hold the page's heading (M3 Decision 16: a lockup, or a text
|
|
990
|
-
* field marked `heading
|
|
1038
|
+
* or rich-text field marked `heading`, M9 Decision 11), as paths, in the order they're tried: the top-level ones in
|
|
991
1039
|
* schema order, then those inside groups (`left.heading`), groups in schema order. A
|
|
992
1040
|
* top-level heading always comes before a group's.
|
|
993
1041
|
*/
|
|
994
1042
|
export function headingPaths(fields: Fields): string[] {
|
|
995
|
-
const isHeading = (field: Field) => (field.kind === 'lockup' || field.kind === 'text') && !!field.heading;
|
|
1043
|
+
const isHeading = (field: Field) => (field.kind === 'lockup' || field.kind === 'text' || field.kind === 'richText') && !!field.heading;
|
|
996
1044
|
const top = Object.entries(fields).filter(([, f]) => isHeading(f)).map(([key]) => key);
|
|
997
1045
|
const grouped = Object.entries(fields).flatMap(([key, f]) => (f.kind === 'group' ? Object.entries(f.fields).filter(([, g]) => isHeading(g)).map(([sub]) => `${key}.${sub}`) : []));
|
|
998
1046
|
return [...top, ...grouped];
|
package/src/forms/rules.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface FormFieldConfig {
|
|
|
43
43
|
* Plain text, trimmed; blank is the same as leaving it out.
|
|
44
44
|
*/
|
|
45
45
|
prefix?: string;
|
|
46
|
+
/** A textarea's height in lines (`rows`), a whole number from 1 to 50. Default 6. Textarea only. */
|
|
47
|
+
rows?: number;
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
/** One form (Contracts → The forms config). Its key is its id: stored on submissions, keys `formRecipients`; never renamed once live. */
|
|
@@ -66,6 +68,7 @@ export interface ResolvedFormField {
|
|
|
66
68
|
error?: string;
|
|
67
69
|
placeholder?: string;
|
|
68
70
|
prefix?: string;
|
|
71
|
+
rows?: number;
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** A form as the package uses it: its fields as a list, in order, each with its type. Plain JSON. */
|
|
@@ -122,7 +125,7 @@ export const FILE_ACCEPT = Object.keys(FILE_TYPES)
|
|
|
122
125
|
.join(',');
|
|
123
126
|
|
|
124
127
|
/** The types that take a placeholder: the ones a visitor types into. */
|
|
125
|
-
const PLACEHOLDER_TYPES: readonly FormFieldType[] = ['text', 'email', 'tel', 'textarea'];
|
|
128
|
+
export const PLACEHOLDER_TYPES: readonly FormFieldType[] = ['text', 'email', 'tel', 'textarea'];
|
|
126
129
|
/** The types that take a prefix: a one-line box where a fixed lead-in reads naturally ("$", "+353"). */
|
|
127
130
|
const PREFIX_TYPES: readonly FormFieldType[] = ['text', 'tel'];
|
|
128
131
|
|
|
@@ -188,6 +191,10 @@ export function checkForms(forms: unknown, fail: (message: string) => never): Re
|
|
|
188
191
|
if (placeholder && !PLACEHOLDER_TYPES.includes(type)) fail(`${at}: a ${type} field has no box to type in, so it can't take a placeholder (only ${PLACEHOLDER_TYPES.join(', ')} can).`);
|
|
189
192
|
const prefix = field.prefix?.trim() ?? '';
|
|
190
193
|
if (prefix && !PREFIX_TYPES.includes(type)) fail(`${at}: only a text or tel field takes a prefix (this one is ${type}).`);
|
|
194
|
+
if (field.rows !== undefined) {
|
|
195
|
+
if (type !== 'textarea') fail(`${at}: only a textarea takes rows (this one is ${type}).`);
|
|
196
|
+
if (!Number.isInteger(field.rows) || field.rows < 1 || field.rows > 50) fail(`${at}: rows must be a whole number from 1 to 50 (got ${JSON.stringify(field.rows)}).`);
|
|
197
|
+
}
|
|
191
198
|
fields.push({
|
|
192
199
|
name,
|
|
193
200
|
label: field.label!.trim(),
|
|
@@ -199,6 +206,7 @@ export function checkForms(forms: unknown, fail: (message: string) => never): Re
|
|
|
199
206
|
...(field.error ? { error: field.error } : {}),
|
|
200
207
|
...(placeholder ? { placeholder } : {}),
|
|
201
208
|
...(prefix ? { prefix } : {}),
|
|
209
|
+
...(field.rows !== undefined ? { rows: field.rows } : {}),
|
|
202
210
|
});
|
|
203
211
|
}
|
|
204
212
|
const email = fields.find((f) => f.name === 'email');
|
package/src/head.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* - **Title:** the page's `seo.title` as written; otherwise, for the home page, Site
|
|
6
6
|
* Settings' `seo.title`, then the brand's name; otherwise `<document title> · <brand name>`,
|
|
7
|
-
* where ` · ` is the config's `titleSeparator` (default ` · `, M8 trial round 2).
|
|
7
|
+
* where ` · ` is the config's `titleSeparator` (default ` · `, M8 trial round 2). With the
|
|
8
|
+
* config's `titleFallback: 'site'` (M9), every page falls back as the home page does.
|
|
8
9
|
* - **Description and share image** fall back from the page's `seo` to Site Settings'.
|
|
9
10
|
* - **Canonical link and `og:url`** only with the site's address (`SITE_URL`), and never
|
|
10
11
|
* in a preview.
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* - **The Google tag** is an ID, never a script: `GTM-…` loads Tag Manager, `G-…` loads
|
|
14
15
|
* gtag.js, anything else nothing. Only in a production build, never in a preview.
|
|
15
16
|
*/
|
|
16
|
-
import { DEFAULT_TITLE_SEPARATOR } from './config.ts';
|
|
17
|
+
import { DEFAULT_TITLE_SEPARATOR, type TitleFallback } from './config.ts';
|
|
17
18
|
import { parseSettingsBrand, type PageDocument } from './document.ts';
|
|
18
19
|
import { imageUrl, isResizable, type MediaConfig } from './media.ts';
|
|
19
20
|
import { googleTagKind, parseGoogleTag, parseSeo, SHARE_IMAGE, type Seo, type SeoImage } from './seo.ts';
|
|
@@ -33,6 +34,8 @@ export interface HeadInput {
|
|
|
33
34
|
listPage?: number;
|
|
34
35
|
/** The config's `titleSeparator` (ResolvedSite), spaces included; ` · ` when left out. */
|
|
35
36
|
titleSeparator?: string;
|
|
37
|
+
/** The config's `titleFallback` (ResolvedSite); `'page-and-brand'` when left out. */
|
|
38
|
+
titleFallback?: TitleFallback;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
export interface Head {
|
|
@@ -73,7 +76,8 @@ export function headOf(input: HeadInput): Head {
|
|
|
73
76
|
const docTitle = document.title.trim();
|
|
74
77
|
// Decision 16: " — page N" straight after the page's own part, before the brand, so ten pages don't share one title.
|
|
75
78
|
const paged = (text: string) => (input.listPage && input.listPage > 1 && text ? `${text} — page ${input.listPage}` : text);
|
|
76
|
-
const
|
|
79
|
+
const siteWide = home || input.titleFallback === 'site';
|
|
80
|
+
const fallback = siteWide ? paged(settings.seo.title || settings.brandName || docTitle) : [paged(docTitle), settings.brandName].filter(Boolean).join(input.titleSeparator ?? DEFAULT_TITLE_SEPARATOR);
|
|
77
81
|
const title = paged(own.title ?? '') || fallback;
|
|
78
82
|
const imageValue = own.image ?? settings.seo.image ?? null;
|
|
79
83
|
const image = imageValue ? shareImage(imageValue, input.media) : null;
|
package/src/links.ts
CHANGED
|
@@ -76,6 +76,41 @@ export function normaliseAddress(url: string): string {
|
|
|
76
76
|
* `/`, `/about`, `/journal/my-first-post`), from the store's listing (Phase 2
|
|
77
77
|
* wires it). Never an error: a warning the field shows.
|
|
78
78
|
*/
|
|
79
|
+
/** M9 Decision 7: the hidden pages in a listing, as `WarningContext.hiddenPages` takes them (address → name). */
|
|
80
|
+
export function hiddenPages(entries: Iterable<{ url: string | null; label: string; hidden?: true }>): Map<string, string> {
|
|
81
|
+
const out = new Map<string, string>();
|
|
82
|
+
for (const e of entries) if (e.hidden && e.url !== null) out.set(normaliseAddress(e.url), e.label);
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** M9 Decision 7: the hidden documents in a listing, as `WarningContext.hiddenDocuments` takes them (`collection/path` → name and address). */
|
|
87
|
+
export function hiddenDocuments(entries: Iterable<{ collection: string; path: string; url: string | null; label: string; hidden?: true }>): Map<string, { label: string; url: string | null }> {
|
|
88
|
+
const out = new Map<string, { label: string; url: string | null }>();
|
|
89
|
+
for (const e of entries) if (e.hidden) out.set(`${e.collection}/${e.path}`, { label: e.label, url: e.url === null ? null : normaliseAddress(e.url) });
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* M9 Decision 7's warning for a reference (a `reference()` field) to a hidden page, worded as
|
|
95
|
+
* the link one is. Never an error: unhiding the page fixes it.
|
|
96
|
+
*/
|
|
97
|
+
export function hiddenReferenceTarget(collection: string, path: string, hidden: ReadonlyMap<string, { label: string; url: string | null }>): string | undefined {
|
|
98
|
+
const doc = hidden.get(`${collection}/${path}`);
|
|
99
|
+
if (!doc) return undefined;
|
|
100
|
+
return `Points at ${doc.url ?? path}, the ${doc.label || path} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* M9 Decision 7's warning: a link to a hidden page. `hidden` maps a hidden page's address
|
|
105
|
+
* (normalised) to its name. Never an error: unhiding the page fixes it.
|
|
106
|
+
*/
|
|
107
|
+
export function hiddenLinkTarget(href: unknown, hidden: ReadonlyMap<string, string>): string | undefined {
|
|
108
|
+
const path = internalLinkPath(href);
|
|
109
|
+
if (path === null) return undefined;
|
|
110
|
+
const name = hidden.get(path);
|
|
111
|
+
return name === undefined ? undefined : `Links to ${path}, the ${name} page, which is hidden from the site. Visitors get its 404 until it's shown again.`;
|
|
112
|
+
}
|
|
113
|
+
|
|
79
114
|
export function missingLinkTarget(href: unknown, addresses: Iterable<string | null | undefined>): string | undefined {
|
|
80
115
|
const path = internalLinkPath(href);
|
|
81
116
|
if (path === null) return undefined;
|
package/src/live/README.md
CHANGED
|
@@ -15,6 +15,7 @@ live drafts store, sweep, locks and status route, and Phase 3's sign-in, sit on
|
|
|
15
15
|
| `status.ts` | Pure: `waitingFromCommits()`, `publishState()`, `PublishRecord` |
|
|
16
16
|
| `redis.ts` | `RedisClient`; `connectRedis(url)` (ioredis with M1 → R2's retry and idle close) and `memoryRedis()` (the stand-in, with `calls` and `flush()`) |
|
|
17
17
|
| `backend.ts` | `GitBackend`; `GitHubBackend`, `MemoryBackend` (test helpers: `seed`, `push`, `file`, `deployments`, `beforeRequest`); `StaleHeadError` |
|
|
18
|
+
| `branches.ts` | `Branch` (a role: `main` or `drafts`), `BranchNames` (the refs behind them), `DEFAULT_BRANCHES`. `CARACMS_MAIN_BRANCH` and `CARACMS_DRAFTS_BRANCH` (read by `mode.ts`, M9 Decision 6) name the branch Publish commits to and the one autosaves land on; unset, `main` and `drafts`. They're set together or not at all: one alone is a problem, so the mode is unconfigured (`/admin` answers 503 saying both must be set; the public site still serves). Only the backends turn a role into a ref name, so with both set a branch called `main` is never read or moved. `MemoryBackend.refs` is keyed by name |
|
|
18
19
|
| `github.ts` | The App's JWT, the installation token (one repo, refreshed early), `Tracker` (request counts) |
|
|
19
20
|
| `webhook.ts` | `handleGithubWebhook()`, behind `POST /_caracms/github` (`../editor/routes/github.ts`) |
|
|
20
21
|
| `autosaves.ts` | Phase 2. Autosaves in Redis (`caracms:autosave:<file>`, a week; stored compare-and-set, ordered per tab by `X-Caracms-Seq`), the session's last commit (the merge's ancestor) and who last changed a document (Decision 11; no expiry, and `known: false` when neither Redis nor git can say). M5a: a new document's autosave carries `created: { over }` (its ancestor until committed), a deletion is a "gone" autosave (`text` and `document` null, `gone: { title }`) |
|