caracms 0.1.0 → 0.3.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 (93) hide show
  1. package/README.md +247 -3
  2. package/bin/caracms.mjs +32 -16
  3. package/bin/cloudflare-ips.mjs +38 -0
  4. package/bin/gate.mjs +277 -4
  5. package/bin/init.mjs +65 -20
  6. package/bin/templates/catch-all.astro.txt +3 -2
  7. package/package.json +1 -1
  8. package/skills/caracms-adopt/SKILL.md +17 -3
  9. package/skills/caracms-adopt/look-same.mjs +24 -11
  10. package/skills/caracms-blocks/SKILL.md +69 -4
  11. package/skills/caracms-content/SKILL.md +2 -1
  12. package/skills/caracms-tina/SKILL.md +431 -0
  13. package/skills/caracms-tina/field-kinds.md +88 -0
  14. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  15. package/src/block-runs.ts +84 -0
  16. package/src/cli/validate.ts +10 -5
  17. package/src/components/BlockRunTree.astro +45 -0
  18. package/src/components/Blocks.astro +20 -1
  19. package/src/components/Form.astro +76 -10
  20. package/src/components/Head.astro +4 -2
  21. package/src/components/Media.astro +27 -1
  22. package/src/components/RichText.astro +12 -4
  23. package/src/components/Text.astro +32 -9
  24. package/src/components/form-client.ts +7 -4
  25. package/src/components/sitemap.xml.ts +2 -1
  26. package/src/config.ts +32 -1
  27. package/src/content-schema.ts +7 -4
  28. package/src/content.ts +28 -0
  29. package/src/document.ts +15 -2
  30. package/src/editor/client/block-list/summary.ts +7 -1
  31. package/src/editor/client/editor.ts +24 -6
  32. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  33. package/src/editor/client/fields/LinkField.tsx +17 -12
  34. package/src/editor/client/fields/ReferenceField.tsx +6 -0
  35. package/src/editor/client/fields/RichTextField.tsx +38 -32
  36. package/src/editor/client/fields/host.tsx +5 -0
  37. package/src/editor/client/fields/parts.tsx +2 -1
  38. package/src/editor/client/fields/rich-text-doc.ts +28 -2
  39. package/src/editor/client/fields/rich-text-schema.ts +41 -2
  40. package/src/editor/client/live/publish.tsx +20 -10
  41. package/src/editor/client/nav/DocRow.tsx +6 -0
  42. package/src/editor/client/overlays/rich-text-bar.ts +6 -6
  43. package/src/editor/client/pane/levels/DocumentLevel.tsx +3 -0
  44. package/src/editor/client/pane/parts.tsx +34 -2
  45. package/src/editor/client/preview/driver.ts +5 -5
  46. package/src/editor/client/preview/field-text.ts +40 -2
  47. package/src/editor/client/preview/page-edit.ts +2 -2
  48. package/src/editor/client/preview/page-editor-registry.ts +4 -3
  49. package/src/editor/client/preview/page-editor.ts +3 -3
  50. package/src/editor/client/state/document-store.ts +20 -2
  51. package/src/editor/client/state/keeper.ts +51 -6
  52. package/src/editor/client/state/live.ts +45 -6
  53. package/src/editor/middleware.ts +3 -2
  54. package/src/editor/routes/try-again.ts +2 -1
  55. package/src/editor/server/admin-props.ts +2 -1
  56. package/src/editor/server/mode.ts +13 -3
  57. package/src/editor/styles/editor.css +2 -0
  58. package/src/editor/styles/levels/live.css +1 -1
  59. package/src/editor/types.ts +27 -6
  60. package/src/fields.ts +63 -15
  61. package/src/files-route.ts +9 -4
  62. package/src/forms/endpoint.ts +11 -16
  63. package/src/forms/rules.ts +9 -1
  64. package/src/head.ts +17 -4
  65. package/src/index.ts +53 -7
  66. package/src/links.ts +35 -0
  67. package/src/live/README.md +5 -2
  68. package/src/live/auth/auth.ts +4 -1
  69. package/src/live/auth/guard.ts +3 -2
  70. package/src/live/auth/index.ts +6 -7
  71. package/src/live/auth/login.ts +17 -8
  72. package/src/live/auth/middleware.ts +7 -3
  73. package/src/live/auth/request.ts +92 -11
  74. package/src/live/auth/views.ts +9 -4
  75. package/src/live/autosaves.ts +59 -16
  76. package/src/live/backend.ts +54 -24
  77. package/src/live/branches.ts +19 -0
  78. package/src/live/drafts-store.ts +4 -3
  79. package/src/live/editor.ts +92 -67
  80. package/src/live/git.ts +112 -34
  81. package/src/live/github.ts +128 -13
  82. package/src/live/locks.ts +92 -26
  83. package/src/live/mode.ts +177 -18
  84. package/src/live/notices.ts +100 -0
  85. package/src/live/redis.ts +161 -39
  86. package/src/live/runtime.ts +15 -4
  87. package/src/live/status-cache.ts +219 -0
  88. package/src/live/status.ts +14 -2
  89. package/src/live/timings.ts +24 -0
  90. package/src/media.ts +40 -7
  91. package/src/read.ts +2 -1
  92. package/src/rich-text.ts +30 -5
  93. package/src/serialize.ts +10 -1
@@ -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,
@@ -4,13 +4,16 @@
4
4
  * #live #failed #blocked #history). Live mode only.
5
5
  *
6
6
  * - **The bar's button**: "3 waiting" with who, and Publish; "Publishing…" with the
7
- * running bar; "Live · 14:12"; "Publish failed" with Try again; "Nothing waiting".
7
+ * running bar; "Live · 14:12"; "Publish failed" with Try again; "Taking longer than
8
+ * usual" (runs-anywhere Decision 10: not live 15 minutes on, no failure known), with
9
+ * Try again when a redeployer is set; "Nothing waiting".
8
10
  * Off while the server can't be reached or you're signed out.
9
11
  * - **The popover is the summary**: each waiting document, who changed it and when,
10
12
  * "Editing now", uncommitted changes included (the status merges them), the merge
11
13
  * notice where the draft was kept, then the final Publish button. Then publishing →
12
- * live, or failed with Try again, or failed with the line that a developer can
13
- * redeploy from Railway when there's no token (`hasToken: false`).
14
+ * live, or failed (or taking longer than usual) with Try again, or with the line that
15
+ * a developer can redeploy it when no redeployer is set (`hasRedeployer: false`;
16
+ * runs-anywhere Decision 11: no host's name).
14
17
  * - **History**: who published, when, which documents, and whether each went live,
15
18
  * in the site's time zone, named once. Read when the waiting popover opens (on
16
19
  * intent), so it's there when History is pressed, and the last read shows while
@@ -129,12 +132,12 @@ export function PublishButton() {
129
132
  </button>
130
133
  );
131
134
  }
132
- if (pub.state === 'failed' && !n) {
135
+ if ((pub.state === 'failed' || pub.state === 'slow') && !n) {
133
136
  return (
134
- <button {...common} className="pub fail" data-pub="failed">
137
+ <button {...common} className="pub fail" data-pub={pub.state}>
135
138
  <span className="wt">
136
139
  <Icon name="warn" size="sm" />
137
- Publish failed
140
+ {pub.state === 'slow' ? 'Taking longer than usual' : 'Publish failed'}
138
141
  </span>
139
142
  <span className={`pb${pub.canTryAgain ? '' : ' off'}`}>{pub.canTryAgain ? 'Try again' : 'Publish'}</span>
140
143
  </button>
@@ -280,14 +283,16 @@ export function WaitingPop() {
280
283
  </>,
281
284
  );
282
285
  }
283
- if (pub.state === 'failed' && !n) {
286
+ if ((pub.state === 'failed' || pub.state === 'slow') && !n) {
287
+ const slow = pub.state === 'slow';
288
+ const published = record ? `${names} ${record.titles.length === 1 ? 'was' : 'were'} published at ${clock(record.at, status.timeZone)}` : null;
284
289
  return wrap(
285
290
  <>
286
291
  <div className="pop-h">
287
292
  <div>
288
- <h2 style={{ color: 'var(--status-danger)' }}>The publish didn't go live</h2>
293
+ <h2 style={{ color: 'var(--status-danger)' }}>{slow ? 'This publish is taking longer than usual' : "The publish didn't go live"}</h2>
289
294
  <p className="sub">
290
- {record ? `${names} ${record.titles.length === 1 ? 'was' : 'were'} published at ${clock(record.at, status.timeZone)}, but` : 'The'} the live site couldn't be updated.
295
+ {slow ? `${published ? `${published}. ` : ''}It may have failed.` : `${published ? `${published}, but` : 'The'} the live site couldn't be updated.`}
291
296
  </p>
292
297
  </div>
293
298
  </div>
@@ -305,7 +310,7 @@ export function WaitingPop() {
305
310
  <div className="note danger" data-note="no-token">
306
311
  <Icon name="warn" />
307
312
  <div>
308
- <b>A developer can redeploy it from Railway</b>
313
+ <b>A developer can redeploy it</b>
309
314
  </div>
310
315
  </div>
311
316
  )}
@@ -597,6 +602,11 @@ export function HistoryPop() {
597
602
  <Icon name="warn" size="xs" />
598
603
  Failed
599
604
  </span>
605
+ ) : h.state === 'slow' ? (
606
+ <span className="chip danger" data-chip="slow">
607
+ <Icon name="warn" size="xs" />
608
+ Taking longer than usual
609
+ </span>
600
610
  ) : (
601
611
  <span className="chip waiting">Publishing</span>
602
612
  )}
@@ -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" />
@@ -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>
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Pieces every pane level is built from (the mockup's sidebar components): the
3
3
  * trail with the stacked level tabs, the current level's header, the notes every
4
- * level shows, and the Recent strip at the foot. A level is
4
+ * level shows (the GitHub access note among them, runs-anywhere Decision 5), and the
5
+ * Recent strip at the foot. A level is
5
6
  *
6
7
  * <>
7
8
  * <Trail>{level tabs}<CurrentHead … /></Trail>
@@ -76,13 +77,44 @@ export function CurrentHead(props: {
76
77
  }
77
78
 
78
79
  /**
79
- * Notes every level shows above its content: "Can't reach the server" (Decision 6),
80
+ * Notes every level shows above its content: GitHub access (live mode, below), then
81
+ * "Can't reach the server" (Decision 6),
80
82
  * or, when the server answered a write with a refusal (a 4xx: the edits don't fit
81
83
  * their base, the file was renamed or deleted while open), its own message. M3
82
84
  * retries with the next change or a reload (no timer), so M4's countdown, "Try now"
83
85
  * and unsent count are slots without data.
84
86
  */
85
87
  export function GlobalNotes() {
88
+ return (
89
+ <>
90
+ <GitHubAccessNote />
91
+ <KeepNotes />
92
+ </>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Runs-anywhere Decision 5: the site's GitHub access is wrong (a refused credential, a
98
+ * missing permission, a token expiring soon), for everyone, on every level, while it
99
+ * holds. Not dismissable. The sentence is the server's (`status.githubAccess`).
100
+ */
101
+ function GitHubAccessNote() {
102
+ const editor = useEditor();
103
+ const access = useStore(editor.liveState, (s) => s.status?.githubAccess ?? null);
104
+ if (editor.mode !== 'live' || !access) return null;
105
+ const expiring = access.kind === 'expiring';
106
+ return (
107
+ <div className={`note ${expiring ? 'warn' : 'danger'}`} role={expiring ? 'status' : 'alert'} data-note="github-access" data-kind={access.kind}>
108
+ <Icon name="warn" />
109
+ <div>
110
+ <b>{access.title}</b>
111
+ <p>{access.message}</p>
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function KeepNotes() {
86
118
  const editor = useEditor();
87
119
  const state = useStore(editor.keep, (k) => k.state);
88
120
  const error = useStore(editor.keep, (k) => k.error);
@@ -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
  };