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
package/src/config.ts CHANGED
@@ -227,8 +227,19 @@ export interface SiteConfig {
227
227
  * hands it to the site's own routes as `seo.titleSeparator`, so their titles match.
228
228
  */
229
229
  titleSeparator?: string;
230
+ /**
231
+ * What a page without its own `seo.title` falls back to (M9, head.ts). `'page-and-brand'`
232
+ * (the default): `<document title><titleSeparator><brand name>`, the home page excepted.
233
+ * `'site'`: every page falls back as the home page does, to Site Settings' `seo.title`,
234
+ * then the brand name, then the document title. For a site (a Tina template's, say)
235
+ * whose pages all show the site's own title.
236
+ */
237
+ titleFallback?: TitleFallback;
230
238
  }
231
239
 
240
+ /** The config's `titleFallback` values (head.ts). */
241
+ export type TitleFallback = 'page-and-brand' | 'site';
242
+
232
243
  /** The fallback title's separator when the config sets none (head.ts). */
233
244
  export const DEFAULT_TITLE_SEPARATOR = ' · ';
234
245
 
@@ -329,6 +340,8 @@ export interface ResolvedSite {
329
340
  forms: Record<string, ResolvedForm>;
330
341
  /** The fallback title's separator, spaces included (`DEFAULT_TITLE_SEPARATOR` when the config sets none). */
331
342
  titleSeparator: string;
343
+ /** What a page without its own `seo.title` falls back to (`'page-and-brand'` when the config sets none). */
344
+ titleFallback: TitleFallback;
332
345
  /**
333
346
  * M8 Decision 10: the URLs the site's own `src/pages/` serves (code-routes.ts), read by the
334
347
  * integration at setup (and by the commands). Absent before then: nothing is reserved.
@@ -563,7 +576,13 @@ export function resolveSite(config: SiteConfig, root: string): ResolvedSite {
563
576
  fail(`titleSeparator must be the text between a page's title and the site's name, spaces included, like ' | ' (got ${JSON.stringify(config.titleSeparator)}).`);
564
577
  }
565
578
 
566
- return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator };
579
+ // What a page without its own title falls back to (M9): the page and the brand, or the site's title.
580
+ const titleFallback = config.titleFallback ?? 'page-and-brand';
581
+ if (titleFallback !== 'page-and-brand' && titleFallback !== 'site') {
582
+ fail(`titleFallback must be 'page-and-brand' (the page's title and the site's name) or 'site' (Site Settings' title on every page) (got ${JSON.stringify(config.titleFallback)}).`);
583
+ }
584
+
585
+ return { root, collections, blocks, textStyles, page: abs(config.page), brand, sharedFolder, phoneLinesWidth, repoPath: repoPath!, timeZone, forms, titleSeparator, titleFallback };
567
586
  }
568
587
 
569
588
  interface FieldCheck {
@@ -617,6 +636,11 @@ function validateField(field: Field, where: string, check: FieldCheck): void {
617
636
  // M6c (G8): help is plain text, said in the sidebar.
618
637
  if (field.help !== undefined && (typeof field.help !== 'string' || field.help.trim() === '')) fail(`${where}: help is a sentence of plain text, shown under the field in the editor (got ${JSON.stringify(field.help)}).`);
619
638
  switch (field.kind) {
639
+ case 'text':
640
+ // M9 Decision 10.
641
+ if (field.lines !== undefined && field.lines !== true) fail(`${where}: lines is true or left out (got ${JSON.stringify(field.lines)}).`);
642
+ if (field.lines && !field.multiline) fail(`${where}: lines draws each typed line as its own element, so the field takes several lines. Write text({ lines: true }) without multiline: false.`);
643
+ break;
620
644
  case 'lockup':
621
645
  if (!Object.hasOwn(check.textStyles, field.style)) fail(`${where} uses the text style "${field.style}", which isn't in textStyles.`);
622
646
  break;
@@ -635,6 +659,10 @@ function validateField(field: Field, where: string, check: FieldCheck): void {
635
659
  "An h3 with no h2 above it breaks the page's outline, and the page's h1 is a block's heading field.",
636
660
  );
637
661
  }
662
+ // M9 Decision 11: a heading is bold and line breaks only.
663
+ if (field.heading !== undefined && field.heading !== true) fail(`${where}: heading is true or left out (got ${JSON.stringify(field.heading)}).`);
664
+ if (field.heading && headings.length > 0) fail(`${where}: a heading rich-text field can't hold headings of its own; it is the block's heading. Leave headings out.`);
665
+ if (field.heading && field.links) fail(`${where}: a heading rich-text field takes bold and line breaks only, not links. Leave links out.`);
638
666
  break;
639
667
  }
640
668
  case 'buttons':
@@ -701,6 +729,9 @@ function validateField(field: Field, where: string, check: FieldCheck): void {
701
729
  if (field.only !== undefined && field.only !== 'image' && field.only !== 'video') fail(`${where}: media's only must be 'image' or 'video' (got ${JSON.stringify(field.only)}).`);
702
730
  if (field.play !== undefined && field.play !== 'loop') fail(`${where}: media's play can only be 'loop' (a loop-only slot, like a hero's); leave it out to let each clip choose (got ${JSON.stringify(field.play)}).`);
703
731
  if (field.play === 'loop' && field.only === 'image') fail(`${where}: play: 'loop' is for a slot that takes videos, but this one takes images only.`);
732
+ // M9 Decision 8.
733
+ if (field.format !== undefined && field.format !== 'webp' && field.format !== 'png') fail(`${where}: media's format must be 'png' or 'webp' (got ${JSON.stringify(field.format)}).`);
734
+ if (field.format !== undefined && field.only === 'video') fail(`${where}: format is for images, but this slot takes videos only.`);
704
735
  break;
705
736
  default:
706
737
  break;
@@ -47,11 +47,12 @@ const clip: Schema = closed(
47
47
  const plainImage: Schema = closed({ key: { type: 'string', minLength: 1 }, w: size, h: size, focal }, ['key']);
48
48
  const seo: Schema = closed({ title: string, description: string, image: plainImage });
49
49
 
50
- function richText(headings: readonly string[]): Schema {
50
+ function richText(headings: readonly string[], lists = true): Schema {
51
51
  const kinds = ['p', ...headings];
52
52
  const unit: Schema[] = [
53
53
  ...kinds.map((k) => closed({ [k]: string }, [k])),
54
- ...['ul', 'ol'].map((k) => closed({ [k]: strings }, [k])),
54
+ // M9 Decision 11: a heading rich-text field is paragraphs only.
55
+ ...(lists ? ['ul', 'ol'] : []).map((k) => closed({ [k]: strings }, [k])),
55
56
  ];
56
57
  // A plain string reads as rich text too (rich-text.ts → "A plain string is read as rich text").
57
58
  return { anyOf: [string, { type: 'array', items: { oneOf: unit } }] };
@@ -68,7 +69,7 @@ export function fieldSchema(field: Field): Schema {
68
69
  case 'select':
69
70
  return described({ enum: field.options.map((o) => o.value) }, field);
70
71
  case 'richText':
71
- return described(richText(field.headings), field);
72
+ return described(richText(field.headings, !field.heading), field);
72
73
  case 'link':
73
74
  return described(field.addressOnly ? closed({ href: string }) : closed({ label: string, href: string }), field);
74
75
  case 'buttons':
@@ -129,7 +130,9 @@ export function collectionSchema(site: Pick<ResolvedSite, 'blocks'>, collection:
129
130
  const own = Object.fromEntries(Object.entries(collection.fields ?? {}).map(([key, f]) => [key, fieldSchema(f)]));
130
131
  switch (collection.kind) {
131
132
  case 'sections':
132
- return document(collection, { ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
133
+ // M9 Decision 7: `hidden: true` keeps the page off the site; absent, it's shown.
134
+ const hidden: Schema = { description: 'Hidden from the site: not served, not in the sitemap, still edited in /admin.', const: true };
135
+ return document(collection, { hidden, ...own, blocks: { type: 'array', items: { anyOf: [{ $ref: '#/$defs/shared' }, anyBlock(site)] } }, seo }, ['title', 'blocks'], { shared: sharedEntry, ...blockDefs(site) });
133
136
  case 'fields':
134
137
  return document(collection, { ...own, seo }, ['title']);
135
138
  case 'shared':
package/src/content.ts CHANGED
@@ -70,6 +70,13 @@ export interface DocumentEntry {
70
70
  label: string;
71
71
  /** Set when the file can't be opened. */
72
72
  warning: DocumentWarning | null;
73
+ /**
74
+ * M9 Decision 7: hidden from the site, by the page's own `"hidden": true` or (`hiddenBy`, the
75
+ * path of the hidden page above it) a page it's nested under. A site's routes, the sitemap and
76
+ * `contentReader().documents()` leave it out; the editor lists it, marked. Never the home page.
77
+ */
78
+ hidden?: true;
79
+ hiddenBy?: string;
73
80
  /** A section builder's shared-block entries, by shared block path, in block order, once each (Decision 8). Absent otherwise. */
74
81
  uses?: string[];
75
82
  /** A shared block: its block's type (`''` when it has none). Absent otherwise. */
@@ -224,6 +231,7 @@ export async function listDocuments(
224
231
  if (!collection) throw new DocumentError('unknown-collection', `No collection "${name}" is declared.`);
225
232
  const paths = await documentPaths(collection, source);
226
233
  const entries = (await Promise.all(paths.map((path) => entryFor(site, collection, path, source)))).filter((e) => e !== null);
234
+ if (collection.nested) hideChildren(entries);
227
235
  all.push(...(collection.nested ? flattenTree(pageTree(entries)) : entries.sort(byLabel)));
228
236
  }
229
237
  if (!collectionName) {
@@ -236,6 +244,22 @@ export async function listDocuments(
236
244
  return all;
237
245
  }
238
246
 
247
+ /** M9 Decision 7: a hidden page's children are hidden with it; `hiddenBy` names the topmost hidden page above. */
248
+ function hideChildren(entries: DocumentEntry[]): void {
249
+ const hidden = new Set(entries.filter((e) => e.hidden).map((e) => e.path));
250
+ if (hidden.size === 0) return;
251
+ for (const entry of entries) {
252
+ const parts = entry.path.split('/');
253
+ for (let n = 1; n < parts.length; n++) {
254
+ const above = parts.slice(0, n).join('/');
255
+ if (!hidden.has(above)) continue;
256
+ entry.hidden = true;
257
+ entry.hiddenBy = above;
258
+ break;
259
+ }
260
+ }
261
+ }
262
+
239
263
  /**
240
264
  * Read one document. Throws DocumentError: 'not-found', 'bad-path',
241
265
  * 'unknown-collection', or the listing's warning codes ('invalid-json',
@@ -308,6 +332,8 @@ function inspect(
308
332
  return warn('no-title', 'This file has no title, so it can’t be opened. Add a "title".');
309
333
  }
310
334
  const entry: DocumentEntry = { ...base, title, label: title, warning: null };
335
+ // M9 Decision 7: the home page is never hidden.
336
+ if ((document.kind === undefined || document.kind === 'sections') && document.hidden && path !== 'index') entry.hidden = true;
311
337
  if (document.kind === undefined || document.kind === 'sections') {
312
338
  entry.uses = [...new Set(document.blocks.flatMap((b) => (b.shared !== undefined ? [b.shared] : [])))];
313
339
  } else if (document.kind === 'shared') {
package/src/document.ts CHANGED
@@ -60,6 +60,13 @@ export interface SiteDocument {
60
60
  * own `seo` as it is.
61
61
  */
62
62
  seo?: Seo;
63
+ /**
64
+ * M9 Decision 7: the page is hidden from the site (the file's top-level `"hidden": true`;
65
+ * absent means shown). Not served, not in the sitemap, not offered by the link picker; still
66
+ * edited, previewed and published in /admin. The editor sends `false` to unhide, which takes
67
+ * the key out of the file. The home page (`index`) can't be hidden (content.ts reads it as shown).
68
+ */
69
+ hidden?: boolean;
63
70
  /**
64
71
  * M6c (G2): the page fields' parsed values, keyed by field, every field of the
65
72
  * collection's `fields` present (the file's top-level keys after `title`). Present
@@ -217,6 +224,7 @@ export function parseDocument(raw: unknown, site: Pick<ResolvedSite, 'blocks'>):
217
224
  title: typeof doc.title === 'string' ? doc.title : '',
218
225
  blocks: rawBlocks.map((b, index) => parseBlock(b, index, site)),
219
226
  ...seoOf(doc),
227
+ ...(doc.hidden === true ? { hidden: true } : {}),
220
228
  };
221
229
  }
222
230
 
@@ -279,6 +287,8 @@ export function normaliseDocument(input: unknown, site: Pick<ResolvedSite, 'bloc
279
287
  return { source, type, known: true, data: parsed };
280
288
  }),
281
289
  ...seoOf(asObject(input)),
290
+ // M9 Decision 7: true hides, false unhides, absent leaves the file's as it is.
291
+ ...(typeof doc.hidden === 'boolean' ? { hidden: doc.hidden } : {}),
282
292
  };
283
293
  }
284
294
 
@@ -366,6 +376,9 @@ export interface DocumentReader {
366
376
  * render from the document (never module state, so concurrent preview renders
367
377
  * can't leak into each other: M3 → Watch-outs).
368
378
  */
379
+ /** M9 Decision 9: `<Blocks wrap>`'s rule and its runs, for a site's page component (`import type { WrapBlocks } from 'caracms/document'`). */
380
+ export type { BlockRun, WrapBlock, WrapBlocks } from './block-runs.ts';
381
+
369
382
  export interface BlockContext {
370
383
  /**
371
384
  * The block's position in the document being rendered: the editor's block index (0 for a
@@ -497,8 +510,8 @@ export function headingTag(cms: BlockContext, field: string): 'h1' | 'h2' {
497
510
  return cms.h1Field === field ? 'h1' : 'h2';
498
511
  }
499
512
 
500
- /** Is a field one that can hold the page's heading (Decision 16): a lockup or a text field marked `heading`? */
501
- export const isHeadingField = (field: Field): boolean => (field.kind === 'lockup' || field.kind === 'text') && field.heading;
513
+ /** Is a field one that can hold the page's heading (Decision 16): a lockup, or a text or rich-text field (M9 Decision 11) marked `heading`? */
514
+ export const isHeadingField = (field: Field): boolean => (field.kind === 'lockup' || field.kind === 'text' || field.kind === 'richText') && !!field.heading;
502
515
 
503
516
  /**
504
517
  * Decision 16: the index of the first block, in render order (skipping unknown
@@ -9,7 +9,8 @@
9
9
  * fieldsSummary(block) the picker's line under a type: its fields' labels
10
10
  */
11
11
  import { own, sharedEntryOf } from '../../../document.ts';
12
- import { titleCase, type Field } from '../../../fields.ts';
12
+ import { parseField, titleCase, type Field } from '../../../fields.ts';
13
+ import { richTextPlain, type RichTextValue } from '../../../rich-text.ts';
13
14
  import type { EditorBlock, EditorSite, ListedDocument } from '../../types.ts';
14
15
 
15
16
  /**
@@ -94,6 +95,11 @@ function firstText(fields: Record<string, Field>, block: Pick<EditorBlock, 'data
94
95
  if (lines.length) return lines.join(' / ');
95
96
  }
96
97
  if (field.kind === 'text' && typeof value === 'string' && value.trim()) return value.replace(/\s+/g, ' ').trim();
98
+ // M9 Decision 11: a rich heading's words, without its bold.
99
+ if (field.kind === 'richText' && field.heading) {
100
+ const words = richTextPlain(parseField(field, value) as RichTextValue).replace(/\s+/g, ' ').trim();
101
+ if (words) return words;
102
+ }
97
103
  }
98
104
  return null;
99
105
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * M9 Decision 7: the page's "Hidden from the site" switch, at the document level under its
3
+ * page fields and above Search and sharing. It's the toggle field's switch (SimpleFields.tsx →
4
+ * ToggleField), set through `editor.doc.setHidden`, so it saves and publishes like any edit.
5
+ *
6
+ * - Only on a section builder's page with an address, and never on the home page (`index`),
7
+ * which can't be hidden.
8
+ * - A page under a hidden page is hidden with it (content.ts → `hiddenBy`): the switch shows
9
+ * on and can't be turned off here, and its help names the page to show again.
10
+ */
11
+ import { useMemo } from 'react';
12
+ import { useEditor, useStore } from '../context.tsx';
13
+ import { FieldFrame } from './FieldFrame.tsx';
14
+ import { FieldHostProvider, type FieldHost } from './host.tsx';
15
+
16
+ const SCOPE = 'page-hidden';
17
+ const NAME = 'hidden';
18
+
19
+ export function HiddenToggle() {
20
+ const editor = useEditor();
21
+ const ref = useStore(editor.doc.state, (s) => s.ref);
22
+ const kind = useStore(editor.doc.state, (s) => s.document.kind);
23
+ const hidden = useStore(editor.doc.state, (s) => !!s.document.hidden);
24
+ const documents = useStore(editor.documents);
25
+ const readOnly = useStore(editor.access, (a) => a.readOnly);
26
+ const host = useMemo<FieldHost>(
27
+ () => ({ scope: SCOPE, picked: () => null, pick() {}, set: (_path, value) => editor.doc.setHidden(value === true), removeItem() {}, raw: () => undefined, readOnly }),
28
+ [editor, readOnly],
29
+ );
30
+ const collection = editor.site.collections.find((c) => c.name === ref.collection);
31
+ if (kind !== 'sections' || !collection?.urlBase || ref.path === 'index') return null;
32
+ const listed = documents.find((d) => d.collection === ref.collection && d.path === ref.path);
33
+ const by = listed?.hiddenBy ? documents.find((d) => d.collection === ref.collection && d.path === listed.hiddenBy) : undefined;
34
+ const on = hidden || !!by;
35
+ const id = `f-${SCOPE}-${NAME}`;
36
+ const help = by
37
+ ? `Hidden with ${by.label}, the page it's under. Show that page again to show this one.`
38
+ : 'Visitors get the 404 page, and it’s left out of the sitemap and the link picker. You can still edit, preview and publish it.';
39
+ return (
40
+ <div className="page-hidden" data-page-hidden={on ? 'on' : 'off'}>
41
+ <FieldHostProvider host={host}>
42
+ <FieldFrame blockKey={SCOPE} name={NAME} label="Hidden from the site" help={help} htmlFor={id}>
43
+ <button type="button" className="switch" role="switch" id={id} data-path={NAME} aria-checked={on} disabled={readOnly || !!by} onClick={() => host.set(NAME, !hidden)}>
44
+ <span className="track" />
45
+ <span>{on ? 'On' : 'Off'}</span>
46
+ </button>
47
+ </FieldFrame>
48
+ </FieldHostProvider>
49
+ </div>
50
+ );
51
+ }
@@ -88,6 +88,7 @@ export function LinkControl(props: { path: string; href: string; onChange: (href
88
88
  const file = fileKeyOf(href.trim());
89
89
  const doc = href && !file ? documentAt(href, linkable) : undefined;
90
90
  const missing = props.warnings.find((w) => w.code === 'link-missing');
91
+ const hiddenTarget = props.warnings.find((w) => w.code === 'link-hidden');
91
92
  const rule = props.warnings.find((w) => w.code === 'link-rule');
92
93
  const files = useLibraryFiles();
93
94
  const chooseFile = () => {
@@ -181,20 +182,24 @@ export function LinkControl(props: { path: string; href: string; onChange: (href
181
182
  if (doc && !rule) {
182
183
  const collection = editor.site.collections.find((c) => c.name === doc.collection);
183
184
  const crumb = collection?.nested ? parentLabel(doc, documents) : '';
185
+ // M9 Decision 7: a link to a hidden page keeps its chip, with the warning under it.
184
186
  return (
185
- <div className="lnk" data-link-path={props.path}>
186
- <div className="lnk-box" data-path={props.path} tabIndex={-1}>
187
- <Icon name={docIcon(collection)} />
188
- <span className="lnk-doc">
189
- <b>
190
- {crumb ? `${crumb} / ` : ''}
191
- {doc.label || doc.title}
192
- </b>
193
- <span className="mono">{doc.url}</span>
194
- </span>
195
- {clear}
187
+ <>
188
+ <div className="lnk" data-link-path={props.path} data-link-hidden={hiddenTarget ? '' : undefined}>
189
+ <div className="lnk-box" data-path={props.path} tabIndex={-1}>
190
+ <Icon name={docIcon(collection)} />
191
+ <span className="lnk-doc">
192
+ <b>
193
+ {crumb ? `${crumb} / ` : ''}
194
+ {doc.label || doc.title}
195
+ </b>
196
+ <span className="mono">{doc.url}</span>
197
+ </span>
198
+ {clear}
199
+ </div>
196
200
  </div>
197
- </div>
201
+ {hiddenTarget ? <Warnings warnings={[hiddenTarget]} /> : null}
202
+ </>
198
203
  );
199
204
  }
200
205
  if (missing && !rule) {
@@ -148,6 +148,12 @@ export function RefPicker(props: { to: string; taken?: readonly string[]; onPick
148
148
  <Icon name={icon} />
149
149
  <span className="nm">{d.label || d.title}</span>
150
150
  <span className="meta">
151
+ {/* M9 Decision 7: a hidden page is still offered (the reference is legitimate), marked as the pages list marks it. */}
152
+ {d.hidden ? (
153
+ <span className="hmark" data-mark="hidden" title={d.hiddenBy ? 'Hidden from the site with the page it’s under' : 'Hidden from the site'}>
154
+ Hidden
155
+ </span>
156
+ ) : null}
151
157
  <span>{on ? 'Added' : <span className="mono">{d.url ?? d.path}</span>}</span>
152
158
  </span>
153
159
  </button>
@@ -5,7 +5,8 @@
5
5
  *
6
6
  * - **The toolbar:** the paragraph style (only when the field allows headings:
7
7
  * Paragraph, Heading 2, and Heading 3 where allowed; never Heading 1), Bold,
8
- * Italic, Bulleted list, Numbered list, and Link (when the field allows links).
8
+ * Italic, Bulleted list, Numbered list, and Link (when the field allows links). A heading
9
+ * field (M9 Decision 11) has Bold only.
9
10
  * ⌘B, ⌘I and ⌘K do the same.
10
11
  * - **The link popover** (⌘K or the toolbar): find a document by title or address,
11
12
  * or type an address; picking a document stores its URL (Decision 4). An address
@@ -23,7 +24,7 @@
23
24
  */
24
25
  import { Editor } from '@tiptap/core';
25
26
  import { useEffect, useRef, useState, type FocusEvent } from 'react';
26
- import type { RichTextField as RichTextDescriptor } from '../../../fields.ts';
27
+ import { richTextOptions, type RichTextField as RichTextDescriptor } from '../../../fields.ts';
27
28
  import { fileAddress, fileKeyOf } from '../../../files.ts';
28
29
  import type { LibraryFile } from '../../types.ts';
29
30
  import { richTextHtml, type RichTextValue } from '../../../rich-text.ts';
@@ -48,8 +49,8 @@ export function RichTextField(props: { name: string; label: string; field: RichT
48
49
  const { name, field } = props;
49
50
  const id = `f-${host.scope}-${name}`.replace(/\./g, '-');
50
51
  const value = (Array.isArray(props.value) ? props.value : []) as RichTextValue;
51
- const options = { headings: field.headings, links: field.links };
52
- const optionsKey = `${field.headings.join(',')}|${field.links}`;
52
+ const options = richTextOptions(field);
53
+ const optionsKey = `${field.headings.join(',')}|${field.links}|${!!field.heading}`;
53
54
  const linkable = useLinkable();
54
55
  const warnings = useFieldWarnings(name, field, props.value);
55
56
 
@@ -79,7 +80,7 @@ export function RichTextField(props: { name: string; label: string; field: RichT
79
80
  const ed = new Editor({
80
81
  element: el,
81
82
  extensions: richTextExtensions(latest.current.options),
82
- content: toDoc(JSON.parse(lastValue.current) as RichTextValue),
83
+ content: toDoc(JSON.parse(lastValue.current) as RichTextValue, latest.current.options),
83
84
  injectCSS: false,
84
85
  enableInputRules: false,
85
86
  enablePasteRules: false,
@@ -126,7 +127,7 @@ export function RichTextField(props: { name: string; label: string; field: RichT
126
127
  const json = JSON.stringify(value);
127
128
  if (!ed || json === lastValue.current) return;
128
129
  lastValue.current = json;
129
- if (JSON.stringify(fromDoc(ed.getJSON() as PMNode, options)) !== json) ed.commands.setContent(toDoc(value), { emitUpdate: false });
130
+ if (JSON.stringify(fromDoc(ed.getJSON() as PMNode, options)) !== json) ed.commands.setContent(toDoc(value, options), { emitUpdate: false });
130
131
  }, [props.value]);
131
132
 
132
133
  useEffect(() => {
@@ -172,8 +173,8 @@ export function RichTextField(props: { name: string; label: string; field: RichT
172
173
  };
173
174
 
174
175
  const shownAsText = warnings.filter((w) => w.code === 'disallowed-kind').length;
175
- const quiet = warnings.filter((w) => w.code === 'disallowed-kind' || w.code === 'link-off');
176
- const loud = warnings.filter((w) => w.code !== 'disallowed-kind' && w.code !== 'link-off');
176
+ const quiet = warnings.filter((w) => w.code === 'disallowed-kind' || w.code === 'link-off' || w.code === 'italic-off');
177
+ const loud = warnings.filter((w) => w.code !== 'disallowed-kind' && w.code !== 'link-off' && w.code !== 'italic-off');
177
178
  const disabled = host.readOnly;
178
179
 
179
180
  return (
@@ -215,30 +216,35 @@ export function RichTextField(props: { name: string; label: string; field: RichT
215
216
  <button type="button" aria-label="Bold" aria-pressed={active('bold')} disabled={disabled} onMouseDown={keep} onClick={() => run((e) => e.chain().focus().toggleMark('bold').run())}>
216
217
  <b>B</b>
217
218
  </button>
218
- <button type="button" aria-label="Italic" aria-pressed={active('italic')} disabled={disabled} onMouseDown={keep} onClick={() => run((e) => e.chain().focus().toggleMark('italic').run())}>
219
- <i className="tb-i">I</i>
220
- </button>
221
- <span className="sep" aria-hidden="true" />
222
- <button
223
- type="button"
224
- aria-label="Bulleted list"
225
- aria-pressed={active('bulletList')}
226
- disabled={disabled}
227
- onMouseDown={keep}
228
- onClick={() => run((e) => toggleList(e, 'bulletList'))}
229
- >
230
- <FieldIcon name="ul" size="sm" />
231
- </button>
232
- <button
233
- type="button"
234
- aria-label="Numbered list"
235
- aria-pressed={active('orderedList')}
236
- disabled={disabled}
237
- onMouseDown={keep}
238
- onClick={() => run((e) => toggleList(e, 'orderedList'))}
239
- >
240
- <FieldIcon name="ol" size="sm" />
241
- </button>
219
+ {/* M9 Decision 11: a heading field is bold and line breaks only. */}
220
+ {field.heading ? null : (
221
+ <>
222
+ <button type="button" aria-label="Italic" aria-pressed={active('italic')} disabled={disabled} onMouseDown={keep} onClick={() => run((e) => e.chain().focus().toggleMark('italic').run())}>
223
+ <i className="tb-i">I</i>
224
+ </button>
225
+ <span className="sep" aria-hidden="true" />
226
+ <button
227
+ type="button"
228
+ aria-label="Bulleted list"
229
+ aria-pressed={active('bulletList')}
230
+ disabled={disabled}
231
+ onMouseDown={keep}
232
+ onClick={() => run((e) => toggleList(e, 'bulletList'))}
233
+ >
234
+ <FieldIcon name="ul" size="sm" />
235
+ </button>
236
+ <button
237
+ type="button"
238
+ aria-label="Numbered list"
239
+ aria-pressed={active('orderedList')}
240
+ disabled={disabled}
241
+ onMouseDown={keep}
242
+ onClick={() => run((e) => toggleList(e, 'orderedList'))}
243
+ >
244
+ <FieldIcon name="ol" size="sm" />
245
+ </button>
246
+ </>
247
+ )}
242
248
  {field.links ? (
243
249
  <>
244
250
  <span className="sep" aria-hidden="true" />
@@ -20,6 +20,7 @@ import { fieldWarnings, getAtPath, parseField, splitPath, type Field, type Field
20
20
  import { mediaFor } from '../media/store.ts';
21
21
  import { useEditor, useStore } from '../context.tsx';
22
22
  import type { Editor, View } from '../editor.ts';
23
+ import { hiddenDocuments, hiddenPages } from '../../../links.ts';
23
24
 
24
25
  export interface FieldHost {
25
26
  /**
@@ -121,6 +122,10 @@ export function useWarningContext(): WarningContext {
121
122
  const keys = files ? new Set(files.map((f) => f.key)) : null;
122
123
  return {
123
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),
124
129
  // M8 trial (item 3): an address the site's own code serves exists too.
125
130
  codeRoutes: editor.site.codeRoutes ?? [],
126
131
  documentExists: (collection: string, path: string) => ids.has(`${collection}/${path}`),
@@ -113,7 +113,8 @@ export interface LinkHits {
113
113
  export function linkHits(query: string, documents: readonly ListedDocument[], collections: Editor['site']['collections']): LinkHits {
114
114
  const q = query.trim();
115
115
  const address: LinkHit | null = q && looksLikeAddress(q) ? { key: `addr:${q}`, href: q, doc: null, crumb: '' } : null;
116
- const linkable = documents.filter((d) => typeof d.url === 'string');
116
+ // M9 Decision 7: a hidden page isn't offered (a link to one only warns).
117
+ const linkable = documents.filter((d) => typeof d.url === 'string' && !d.hidden);
117
118
  const groups = q
118
119
  ? searchDocuments(q, linkable, collections).map((g) => ({
119
120
  label: g.collection.label,
@@ -10,6 +10,10 @@
10
10
  * a run ↔ text with marks bold, italic, link { href }
11
11
  * "\n" ↔ hardBreak (Shift+Enter: a break inside the paragraph, heading or item)
12
12
  *
13
+ * A heading field (M9 Decision 11, `options.heading`) has no blocks: its document's
14
+ * content is inline, as its heading tag holds it, each paragraph a line, the lines
15
+ * joined by hardBreaks. Read back, every break starts a paragraph.
16
+ *
13
17
  * `fromDoc` reads back through Phase 1's `parseRichText`, so what the store holds
14
18
  * is always the field's own value: empty paragraphs, items and lists dropped,
15
19
  * breaks at a unit's start or end trimmed, kinds the field doesn't allow as `p`,
@@ -56,8 +60,13 @@ const textblock = (type: string, runs: readonly TextRun[], attrs?: Record<string
56
60
  return { type, ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) };
57
61
  };
58
62
 
59
- /** A value as the editor's document. Never empty: an empty value is one empty paragraph. */
60
- export function toDoc(value: RichTextValue): PMNode {
63
+ /** A value as the editor's document. Never empty: an empty value is one empty paragraph (a heading's: no content). */
64
+ export function toDoc(value: RichTextValue, options?: Pick<RichTextOptions, 'heading'>): PMNode {
65
+ if (options?.heading) {
66
+ const lines = value.flatMap((block) => ('items' in block ? block.items : [block.runs]));
67
+ const content = lines.flatMap((runs, i) => [...(i > 0 ? [{ type: 'hardBreak' }] : []), ...inlineNodes(runs)]);
68
+ return { type: 'doc', ...(content.length ? { content } : {}) };
69
+ }
61
70
  const content: PMNode[] = value.map((block) => {
62
71
  if ('items' in block) {
63
72
  return {
@@ -91,6 +100,18 @@ export function runsOf(content: readonly PMNode[] | undefined): TextRun[] {
91
100
  return runs;
92
101
  }
93
102
 
103
+ /** Runs split at each `\n`: a heading field's lines (M9 Decision 11). */
104
+ export function splitLines(runs: readonly TextRun[]): TextRun[][] {
105
+ const lines: TextRun[][] = [[]];
106
+ for (const run of runs) {
107
+ run.text.split('\n').forEach((part, i) => {
108
+ if (i > 0) lines.push([]);
109
+ if (part !== '') lines.at(-1)!.push({ ...run, text: part });
110
+ });
111
+ }
112
+ return lines;
113
+ }
114
+
94
115
  const isBlock = (node: PMNode) => node.type === 'paragraph' || node.type === 'heading';
95
116
 
96
117
  /**
@@ -105,6 +126,11 @@ const itemRuns = (item: PMNode): TextRun[] => {
105
126
 
106
127
  /** The editor's document as the field's value (normalised by the field's rules). */
107
128
  export function fromDoc(doc: PMNode, options: RichTextOptions): RichTextValue {
129
+ if (options.heading) {
130
+ // A heading's document is inline (or, from older content, paragraphs): each line a paragraph.
131
+ const content = (doc.content ?? []).some(isBlock) ? (doc.content ?? []).filter(isBlock).flatMap((child, i) => [...(i > 0 ? [{ type: 'hardBreak' }] : []), ...(child.content ?? [])]) : doc.content;
132
+ return parseRichText(splitLines(runsOf(content)).map((runs) => ({ kind: 'p', runs })), options);
133
+ }
108
134
  const blocks: unknown[] = [];
109
135
  for (const node of doc.content ?? []) {
110
136
  if (node.type === 'paragraph') blocks.push({ kind: 'p', runs: runsOf(node.content) });
@@ -20,6 +20,8 @@
20
20
  * - **Paste keeps only what the field allows:** HTML is read through this schema
21
21
  * (anything else is dropped or becomes text); plain text has a blank line start a
22
22
  * paragraph and a single newline make a break.
23
+ * - **A heading field** (M9 Decision 11): the document is inline content (no paragraph,
24
+ * list or heading nodes), with bold and hardBreak only; Enter is a line break too.
23
25
  * - Undo inside the field is its own (⌘Z / ⇧⌘Z), as every text field's is (Decision 14).
24
26
  */
25
27
  import { Extension, Mark, Node } from '@tiptap/core';
@@ -153,8 +155,41 @@ const Keys = () =>
153
155
  },
154
156
  });
155
157
 
158
+ /**
159
+ * A heading field's document (M9 Decision 11): inline content straight inside the
160
+ * heading tag, as RichText.astro draws it, so there's no paragraph to put a `<p>` in.
161
+ */
162
+ const InlineDoc = Node.create({ name: 'doc', topNode: true, content: 'inline*' });
163
+
164
+ /** A heading field's keys: Enter and Shift+Enter both start a new line; bold only. */
165
+ const HeadingKeys = () =>
166
+ Extension.create({
167
+ name: 'caracmsHeadingKeys',
168
+ priority: 1000,
169
+ addProseMirrorPlugins: () => [history()],
170
+ addKeyboardShortcuts() {
171
+ const editor = this.editor;
172
+ const lineBreak = () =>
173
+ editor.commands.command(({ tr, state, dispatch }) => {
174
+ if (dispatch) tr.replaceSelectionWith(state.schema.nodes.hardBreak!.create(), false).scrollIntoView();
175
+ return true;
176
+ });
177
+ return {
178
+ 'Shift-Enter': lineBreak,
179
+ Enter: lineBreak,
180
+ 'Mod-b': () => editor.commands.toggleMark('bold'),
181
+ 'Mod-i': () => true,
182
+ 'Mod-z': () => undo(editor.state, editor.view.dispatch),
183
+ 'Shift-Mod-z': () => redo(editor.state, editor.view.dispatch),
184
+ 'Mod-y': () => redo(editor.state, editor.view.dispatch),
185
+ Tab: () => false,
186
+ };
187
+ },
188
+ });
189
+
156
190
  /** Every extension the field's editor runs with. */
157
- export function richTextExtensions(options: { headings: readonly HeadingKind[]; links: boolean }) {
191
+ export function richTextExtensions(options: { headings: readonly HeadingKind[]; links: boolean; heading?: boolean }) {
192
+ if (options.heading) return [InlineDoc, Text, HardBreak, Bold, HeadingKeys()];
158
193
  const levels = options.headings.map((h) => (h === 'h2' ? 2 : 3));
159
194
  return [
160
195
  Doc,
@@ -172,9 +207,13 @@ export function richTextExtensions(options: { headings: readonly HeadingKind[];
172
207
  ];
173
208
  }
174
209
 
175
- /** Plain text pasted (Spec → Text): a blank line starts a new paragraph, a single newline is a break. */
210
+ /** Plain text pasted (Spec → Text): a blank line starts a new paragraph, a single newline is a break. In a heading (no paragraphs): each a line. */
176
211
  export function clipboardTextParser(text: string, _context: unknown, _plain: boolean, view: EditorView): Slice {
177
212
  const schema = view.state.schema;
213
+ if (!schema.nodes.paragraph) {
214
+ const inline = pastedParagraphs(text).flatMap((nodes, i) => [...(i > 0 ? [{ type: 'hardBreak' } as PMNode] : []), ...nodes]);
215
+ return new Slice(Fragment.fromArray(inline.map((n) => (n.type === 'hardBreak' ? schema.nodes.hardBreak!.create() : schema.text(n.text!)))), 0, 0);
216
+ }
178
217
  const paragraphs = pastedParagraphs(text).map((inline) =>
179
218
  schema.nodes.paragraph!.create(
180
219
  null,
@@ -85,6 +85,12 @@ export function DocRow(props: {
85
85
  <span className="sr">, can't be opened</span>
86
86
  </span>
87
87
  ) : null}
88
+ {/* M9 Decision 7: hidden from the site (or under a hidden page). */}
89
+ {doc.hidden ? (
90
+ <span className="hmark" data-mark="hidden" title={doc.hiddenBy ? 'Hidden from the site with the page it’s under' : 'Hidden from the site'}>
91
+ Hidden
92
+ </span>
93
+ ) : null}
88
94
  {tooLong ? (
89
95
  <span className="wmark" title="Too long for mobile" data-mark="too-long">
90
96
  <Icon name="phone" size="xs" />