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.
Files changed (58) hide show
  1. package/bin/init.mjs +1 -0
  2. package/bin/templates/catch-all.astro.txt +3 -2
  3. package/package.json +1 -1
  4. package/skills/caracms-adopt/SKILL.md +12 -0
  5. package/skills/caracms-adopt/look-same.mjs +16 -7
  6. package/skills/caracms-blocks/SKILL.md +49 -2
  7. package/skills/caracms-content/SKILL.md +2 -1
  8. package/skills/caracms-tina/SKILL.md +427 -0
  9. package/skills/caracms-tina/field-kinds.md +88 -0
  10. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  11. package/src/block-runs.ts +84 -0
  12. package/src/cli/validate.ts +10 -5
  13. package/src/components/BlockRunTree.astro +45 -0
  14. package/src/components/Blocks.astro +20 -1
  15. package/src/components/Form.astro +76 -10
  16. package/src/components/Head.astro +1 -0
  17. package/src/components/Media.astro +27 -1
  18. package/src/components/RichText.astro +12 -4
  19. package/src/components/Text.astro +32 -9
  20. package/src/components/form-client.ts +7 -4
  21. package/src/components/sitemap.xml.ts +2 -1
  22. package/src/config.ts +32 -1
  23. package/src/content-schema.ts +7 -4
  24. package/src/content.ts +26 -0
  25. package/src/document.ts +15 -2
  26. package/src/editor/client/block-list/summary.ts +7 -1
  27. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  28. package/src/editor/client/fields/LinkField.tsx +17 -12
  29. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  30. package/src/editor/client/fields/RichTextField.tsx +38 -32
  31. package/src/editor/client/fields/host.tsx +5 -0
  32. package/src/editor/client/fields/parts.tsx +2 -1
  33. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  34. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  35. package/src/editor/client/nav/DocRow.tsx +6 -0
  36. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  37. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  38. package/src/editor/client/preview/driver.ts +5 -5
  39. package/src/editor/client/preview/field-text.ts +40 -2
  40. package/src/editor/client/preview/page-edit.ts +2 -2
  41. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  42. package/src/editor/client/preview/page-editor.ts +3 -3
  43. package/src/editor/client/state/document-store.ts +20 -2
  44. package/src/editor/styles/editor.css +2 -0
  45. package/src/editor/types.ts +2 -0
  46. package/src/fields.ts +63 -15
  47. package/src/forms/rules.ts +9 -1
  48. package/src/head.ts +7 -3
  49. package/src/links.ts +35 -0
  50. package/src/live/README.md +1 -0
  51. package/src/live/backend.ts +41 -23
  52. package/src/live/branches.ts +19 -0
  53. package/src/live/mode.ts +46 -0
  54. package/src/live/runtime.ts +5 -2
  55. package/src/media.ts +14 -6
  56. package/src/read.ts +2 -1
  57. package/src/rich-text.ts +30 -5
  58. 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
- button('italic', 'Italic', 'Italic (⌘I)', '<i>I</i>') +
227
- sep +
228
- button('ul', 'Bulleted list', 'Bulleted list', icon('ul')) +
229
- button('ol', 'Numbered list', 'Numbered list', icon('ol')) +
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, { headings: descriptor.headings, links: descriptor.links });
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.innerText = text;
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' ? { headings: descriptor.headings, links: descriptor.links } : null;
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
- return richTextHtml(parseRichText(raw, options));
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 { title: document.title, blocks: document.blocks.map(plain), ...seoWire(), ...(document.data !== undefined ? { data: document.data } : {}) };
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))}
@@ -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? }) an image `{ key, alt, … }` or a Mux clip `{ video, play, … }` (media-value.ts)
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
- /** Rich text (Decision 3). `headings`: `['h2']` or `['h2', 'h3']`, off by default. `links`: on by default. */
320
- export function richText(options: { label?: string; help?: string; headings?: readonly HeadingKind[]; links?: boolean } = {}): RichTextField {
321
- return withHelp({ kind: 'richText', label: options.label, headings: [...(options.headings ?? [])], links: options.links ?? true }, options);
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`), as paths, in the order they're tried: the top-level ones in
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];
@@ -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 fallback = home ? paged(settings.seo.title || settings.brandName || docTitle) : [paged(docTitle), settings.brandName].filter(Boolean).join(input.titleSeparator ?? DEFAULT_TITLE_SEPARATOR);
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;
@@ -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 }`) |