@glw907/cairn-cms 0.56.0 → 0.56.2

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.
@@ -21,6 +21,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
21
21
  import { beforeNavigate } from '$app/navigation';
22
22
  import { page } from '$app/state';
23
23
  import BlocksIcon from '@lucide/svelte/icons/blocks';
24
+ import SquarePenIcon from '@lucide/svelte/icons/square-pen';
24
25
  import LinkIcon from '@lucide/svelte/icons/link';
25
26
  import FileSymlinkIcon from '@lucide/svelte/icons/file-symlink';
26
27
  import PanelRightIcon from '@lucide/svelte/icons/panel-right';
@@ -28,7 +29,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
28
29
  import CsrfField from './CsrfField.svelte';
29
30
  import MarkdownEditor from './MarkdownEditor.svelte';
30
31
  import EditorToolbar from './EditorToolbar.svelte';
31
- import ComponentInsertDialog, { insertableDefs } from './ComponentInsertDialog.svelte';
32
+ import ComponentInsertDialog, { insertableDefs, hasSchema } from './ComponentInsertDialog.svelte';
32
33
  import LinkPicker from './LinkPicker.svelte';
33
34
  import WebLinkDialog from './WebLinkDialog.svelte';
34
35
  import DeleteDialog from './DeleteDialog.svelte';
@@ -39,7 +40,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
39
40
  import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
40
41
  import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
41
42
  import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
42
- import type { ComponentRegistry } from '../render/registry.js';
43
+ import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
44
+ import { parseComponent, componentRoundTripSafety } from '../render/component-grammar.js';
43
45
  import type { IconSet } from '../render/glyph.js';
44
46
  import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
45
47
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
@@ -263,6 +265,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
263
265
  // Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
264
266
  // landing tab.
265
267
  let mode = $state<'write' | 'preview'>('write');
268
+ // Preview is read-only, so the insert controls the page renders into the toolbar disable with the
269
+ // strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
270
+ // is in scope before its first use.
271
+ const insertDisabled = $derived(mode === 'preview');
266
272
  let previewHtml = $state('');
267
273
  // True after a render call threw, so the preview pane can say so instead of going blank.
268
274
  let previewFailed = $state(false);
@@ -353,6 +359,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
353
359
  // preview knob, or a styleless document (behind the hint below) when the site sets none.
354
360
  const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
355
361
  let insert = $state.raw<(text: string) => void>(() => {});
362
+ // The editor's range-replace seam, registered by MarkdownEditor on mount; the dialog's Update
363
+ // routes through it to overwrite an edited block's source span. A no-op until then.
364
+ let replaceRange = $state.raw<(from: number, to: number, text: string) => void>(() => {});
356
365
  let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
357
366
  // The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
358
367
  // reads it for the Text field's default.
@@ -366,7 +375,9 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
366
375
  // breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
367
376
  let webLinkDialog = $state<DialogHandle | null>(null);
368
377
  let linkPicker = $state<DialogHandle | null>(null);
369
- let insertDialog = $state<DialogHandle | null>(null);
378
+ // The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
379
+ // drives editComponent(def, values, range) on it, beyond the shared open().
380
+ let insertDialog = $state<ComponentInsertDialog | null>(null);
370
381
  // The lifecycle dialogs, opened from the header's overflow menu.
371
382
  let deleteDialog = $state<DialogHandle | null>(null);
372
383
  let renameDialog = $state<DialogHandle | null>(null);
@@ -379,6 +390,92 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
379
390
  // by, so the toolbar trigger and the dialog appear and disappear together.
380
391
  const hasComponents = $derived(insertableDefs(registry).length > 0);
381
392
 
393
+ // The directive container at the editor caret, reported by MarkdownEditor whenever it changes
394
+ // (null outside any container). The Edit-block control resolves it against the registry and the
395
+ // round-trip safety gate below; its identity is the key the async gate guards against a stale
396
+ // result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
397
+ // matching interface is not exported.
398
+ type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
399
+ let caretComponent = $state<CaretComponent | null>(null);
400
+ // A def is actionable for guided edit when it has a schema (the same notion the insert catalog
401
+ // lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
402
+ // hasSchema so the two surfaces can never drift on what counts as editable.
403
+ function editableDef(name: string | null): ComponentDef | undefined {
404
+ if (!name) return undefined;
405
+ const def = registry?.get(name);
406
+ if (!def) return undefined;
407
+ return hasSchema(def) ? def : undefined;
408
+ }
409
+ // The resolved editability: the def, the source range, and the validated markdown when the caret
410
+ // sits on a known, schema-bearing component whose round-trip safety check passed for the CURRENT
411
+ // caret; null otherwise. The def, range, and markdown are captured from one caretComponent
412
+ // snapshot, so editBlock() never mixes a newer markdown with an older range. The Edit-block
413
+ // control enables only when this is set.
414
+ let editable = $state<{ def: ComponentDef; range: { from: number; to: number }; markdown: string } | null>(null);
415
+ // Why edit is unavailable, distinguishing "not on a component" from "on an unsafe one" so the
416
+ // disabled tooltip is honest. 'none' covers both no-caret-component and an unknown/template-only
417
+ // one (no guided form either way); 'unsafe' is a known component the safety gate refused.
418
+ let editReason = $state<'none' | 'unsafe'>('none');
419
+ // Resolve editability when the caret-component changes, async-safe. componentRoundTripSafety is
420
+ // async, so a slow check could resolve after a newer caret move; guard latest-wins on the
421
+ // caretComponent identity (the EditPage preview-debounce pattern), only applying a result when
422
+ // the caret has not moved since the check started. A block whose check did not pass for the
423
+ // current caret never enables edit.
424
+ $effect(() => {
425
+ const current = caretComponent;
426
+ const def = editableDef(current?.name ?? null);
427
+ if (!current || !def) {
428
+ editable = null;
429
+ editReason = 'none';
430
+ return;
431
+ }
432
+ let stale = false;
433
+ void componentRoundTripSafety(current.markdown, def)
434
+ .then((result) => {
435
+ if (stale || caretComponent !== current) return;
436
+ if (result.safe) {
437
+ editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
438
+ editReason = 'none';
439
+ } else {
440
+ editable = null;
441
+ editReason = 'unsafe';
442
+ }
443
+ })
444
+ .catch(() => {
445
+ // A parse throw during the safety check must never leave a stale block enabled. Guarded by
446
+ // the same latest-wins identity, fall back to the safe default of no editable block.
447
+ if (stale || caretComponent !== current) return;
448
+ editable = null;
449
+ editReason = 'none';
450
+ });
451
+ return () => {
452
+ stale = true;
453
+ };
454
+ });
455
+ // The Edit-block control's accessible label and tooltip: a plain reason in each state. Enabled
456
+ // names the action; the two disabled reasons are honest about why.
457
+ const editBlockLabel = $derived(
458
+ editable
459
+ ? 'Edit the component at the cursor'
460
+ : editReason === 'unsafe'
461
+ ? "This block can't be edited in the form. Edit it as markdown."
462
+ : 'Place the cursor in a component to edit it',
463
+ );
464
+ // Whether the Edit-block control is unavailable: either Preview hides the Write surface, or the
465
+ // caret is not on a safe, schema-bearing component. The control stays focusable and announced in
466
+ // this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
467
+ // technology; the dead click is made inert in editBlock().
468
+ const editBlockUnavailable = $derived(insertDisabled || !editable);
469
+ // Activate edit: parse the block into form values, then open the dialog in edit mode over the
470
+ // stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
471
+ // unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
472
+ // from the one editable snapshot, so a newer caret markdown is never paired with an older range.
473
+ async function editBlock() {
474
+ if (insertDisabled || !editable) return;
475
+ const values = await parseComponent(editable.markdown, editable.def);
476
+ insertDialog?.editComponent(editable.def, values, editable.range);
477
+ }
478
+
382
479
  // The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
383
480
  // when it has never been published); otherwise the live site matches and it reads Published.
384
481
  const status = $derived.by(() => {
@@ -563,10 +660,6 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
563
660
  mode = m;
564
661
  }
565
662
 
566
- // Preview is read-only, so the insert controls the page renders into the toolbar disable with
567
- // the strip's own format buttons.
568
- const insertDisabled = $derived(mode === 'preview');
569
-
570
663
  // The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
571
664
  // strip or the surface, without claiming the keys page-wide. The listener attaches
572
665
  // programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
@@ -903,6 +996,24 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
903
996
  >
904
997
  <BlocksIcon class="h-4 w-4" aria-hidden="true" />
905
998
  </button>
999
+ <!-- Edit block re-opens the component at the caret into the guided form. It is
1000
+ unavailable while Preview shows (like the insert controls) and whenever the caret is
1001
+ not on a safe, schema-bearing component; the tooltip names the reason in each state.
1002
+ The unavailable state uses aria-disabled, not the native disabled attribute, so the
1003
+ control stays focusable and its reason reaches assistive technology; the disabled
1004
+ look rides a class and editBlock() early-returns so the dead click is inert. -->
1005
+ <button
1006
+ type="button"
1007
+ class="btn btn-sm btn-ghost btn-square"
1008
+ class:btn-disabled={editBlockUnavailable}
1009
+ aria-haspopup="dialog"
1010
+ aria-label={editBlockLabel}
1011
+ title={editBlockLabel}
1012
+ aria-disabled={editBlockUnavailable}
1013
+ onclick={editBlock}
1014
+ >
1015
+ <SquarePenIcon class="h-4 w-4" aria-hidden="true" />
1016
+ </button>
906
1017
  {/if}
907
1018
  <button
908
1019
  type="button"
@@ -950,6 +1061,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
950
1061
  name="body"
951
1062
  {surface}
952
1063
  registerInsert={(fn) => (insert = fn)}
1064
+ onComponentAtCaret={(info) => (caretComponent = info)}
1065
+ registerReplaceRange={(fn) => (replaceRange = fn)}
953
1066
  registerInsertLink={(fn) => (insertLink = fn)}
954
1067
  registerGetSelection={(fn) => (getSelection = fn)}
955
1068
  registerFormat={(fn) => (format = fn)}
@@ -1239,7 +1352,16 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
1239
1352
  <form>, and a form nested in a form is invalid HTML the parser repairs by dropping the outer
1240
1353
  tag, which breaks the SSR'd document and hydration. The toolbar snippet's triggers drive them
1241
1354
  through their exported open(). -->
1242
- <ComponentInsertDialog bind:this={insertDialog} trigger={false} {registry} {insert} {icons} />
1355
+ <ComponentInsertDialog
1356
+ bind:this={insertDialog}
1357
+ trigger={false}
1358
+ {registry}
1359
+ {insert}
1360
+ update={(range, md) => replaceRange(range.from, range.to, md)}
1361
+ {icons}
1362
+ {render}
1363
+ preview={data.preview}
1364
+ />
1243
1365
  <WebLinkDialog bind:this={webLinkDialog} trigger={false} insert={insertLink} selection={getSelection} />
1244
1366
  <LinkPicker bind:this={linkPicker} trigger={false} linkTargets={data.linkTargets} insert={insertLink} />
1245
1367
 
@@ -10,6 +10,16 @@ through the adapter's render. Swapping the editor stays a one-file change.
10
10
  <script lang="ts">
11
11
  import { onMount, onDestroy } from 'svelte';
12
12
  import { applyMarkdownFormat, insertInlineLink, type FormatKind, type FormatResult } from './markdown-format.js';
13
+ import { fenceScan, caretContainerRange, directiveOpenerName } from './markdown-directives.js';
14
+
15
+ /** The directive container at the caret: the opener's name, the block's markdown, and the
16
+ * document character offsets of its inclusive line range. */
17
+ interface ComponentAtCaret {
18
+ name: string | null;
19
+ markdown: string;
20
+ from: number;
21
+ to: number;
22
+ }
13
23
 
14
24
  interface Props {
15
25
  /** The markdown source; bindable so the parent reads edits back. */
@@ -24,6 +34,12 @@ through the adapter's render. Swapping the editor stays a one-file change.
24
34
  registerGetSelection?: (get: () => string) => void;
25
35
  /** Receives a `(kind) => void` that transforms the current selection; the host's toolbar calls it. */
26
36
  registerFormat?: (format: (kind: FormatKind) => void) => void;
37
+ /** Reports the directive container at the caret (or null when outside any container) whenever
38
+ * the reported value changes; the host resolves it against the registry to offer Edit-block. */
39
+ onComponentAtCaret?: (info: ComponentAtCaret | null) => void;
40
+ /** Receives a `(from, to, text) => void` that overwrites a document span; the dialog's Update
41
+ * calls it to write an edited block back over its original range. */
42
+ registerReplaceRange?: (replace: (from: number, to: number, text: string) => void) => void;
27
43
  /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
28
44
  * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
29
45
  completionSources?: import('@codemirror/autocomplete').CompletionSource[];
@@ -43,6 +59,8 @@ through the adapter's render. Swapping the editor stays a one-file change.
43
59
  registerInsertLink,
44
60
  registerGetSelection,
45
61
  registerFormat,
62
+ onComponentAtCaret,
63
+ registerReplaceRange,
46
64
  completionSources = [],
47
65
  focusMode = false,
48
66
  typewriter = false,
@@ -365,6 +383,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
365
383
  surfaceCompartment.of(surface === 'prose' ? proseTheme : markupTheme),
366
384
  EditorView.updateListener.of((update) => {
367
385
  if (update.docChanged) value = update.state.doc.toString();
386
+ // A doc edit can change the block's span and a caret move can change which block the
387
+ // caret sits in, so the reporter runs on either; the dedupe below absorbs the no-ops.
388
+ if (onComponentAtCaret && (update.docChanged || update.selectionSet))
389
+ reportComponentAtCaret(update.state);
368
390
  }),
369
391
  ],
370
392
  }),
@@ -374,6 +396,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
374
396
  registerInsertLink?.(insertLink);
375
397
  registerGetSelection?.(selectedText);
376
398
  registerFormat?.(applyFormat);
399
+ registerReplaceRange?.(replaceRange);
400
+ // Report the caret's starting container once the editor exists, so a caret that mounts inside
401
+ // a block is known without waiting for the first move.
402
+ if (onComponentAtCaret) reportComponentAtCaret(view.state);
377
403
  mounted = true;
378
404
  });
379
405
 
@@ -406,6 +432,55 @@ through the adapter's render. Swapping the editor stays a one-file change.
406
432
  });
407
433
  });
408
434
 
435
+ // The last value handed to onComponentAtCaret, so the reporter fires only on a change. The
436
+ // identity compared is name + markdown + from + to. A pure caret move within one block leaves all
437
+ // four unchanged, so it does not refire; an edit inside the block changes the markdown even when
438
+ // it keeps the same length (an equal-length replacement leaves from and to unchanged), so the
439
+ // markdown must be part of the equality or such an edit would keep a stale report.
440
+ let lastCaretReport: ComponentAtCaret | null = null;
441
+
442
+ // Compute the directive container at the caret from a CodeMirror state and report it through
443
+ // onComponentAtCaret, deduped so a caret move within the same block does not refire. fenceScan
444
+ // lines are 0-based; doc.line(n) is 1-based, so the line range maps with a +1 on each bound.
445
+ function reportComponentAtCaret(state: import('@codemirror/state').EditorState) {
446
+ const doc = state.doc;
447
+ const lines: string[] = [];
448
+ for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
449
+ const caretLine = doc.lineAt(state.selection.main.head).number - 1;
450
+ const range = caretContainerRange(fenceScan(lines), caretLine);
451
+ let next: ComponentAtCaret | null = null;
452
+ if (range) {
453
+ const fromPos = doc.line(range.fromLine + 1).from;
454
+ const toPos = doc.line(range.toLine + 1).to;
455
+ next = {
456
+ name: directiveOpenerName(lines[range.fromLine] ?? ''),
457
+ markdown: doc.sliceString(fromPos, toPos),
458
+ from: fromPos,
459
+ to: toPos,
460
+ };
461
+ }
462
+ const prev = lastCaretReport;
463
+ const same =
464
+ prev === next ||
465
+ (prev !== null &&
466
+ next !== null &&
467
+ prev.name === next.name &&
468
+ prev.markdown === next.markdown &&
469
+ prev.from === next.from &&
470
+ prev.to === next.to);
471
+ if (same) return;
472
+ lastCaretReport = next;
473
+ onComponentAtCaret?.(next);
474
+ }
475
+
476
+ // Overwrite a document span with new text and drop the caret after it, mirroring insertAtCursor's
477
+ // dispatch shape. A no-op before the editor mounts, the same guard the other seams carry.
478
+ function replaceRange(from: number, to: number, text: string) {
479
+ if (!view) return;
480
+ view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
481
+ view.focus();
482
+ }
483
+
409
484
  function insertAtCursor(text: string) {
410
485
  if (!view) {
411
486
  value = value ? `${value}\n\n${text}` : text;
@@ -17,6 +17,17 @@ const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
17
17
  // never opens a real container.
18
18
  const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
19
19
 
20
+ /**
21
+ * The directive name from a container opener line (`:::callout{...}` -> `callout`,
22
+ * `::::cta[Book]` -> `cta`), or null when the line is not a named container opener. Reads the
23
+ * same FENCE match the scan uses: group 2 is the name, empty on a bare closer, so a closer or a
24
+ * non-fence line returns null.
25
+ */
26
+ export function directiveOpenerName(line: string): string | null {
27
+ const m = FENCE.exec(line);
28
+ return m && m[2] ? m[2] : null;
29
+ }
30
+
20
31
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
21
32
  export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
22
33
  if (FENCE.test(line)) return 'fence';
@@ -76,7 +76,7 @@ function isContainer(node: RootContent): node is RootContent & DirectiveNode {
76
76
 
77
77
  // Pin the bullet to `-` so a markdown body or slot that uses dash bullets round-trips unchanged
78
78
  // rather than drifting to remark-stringify's default `*`, which would silently mutate author content.
79
- const toMd = unified().use(remarkStringify, { bullet: '-' });
79
+ const toMd = unified().use(remarkDirective).use(remarkStringify, { bullet: '-' });
80
80
 
81
81
  /** Render mdast children back to trimmed markdown text. */
82
82
  function childrenToText(children: RootContent[]): string {
@@ -149,6 +149,48 @@ export function parseRawAttributeKeys(markdown: string, def: ComponentDef): stri
149
149
  return rawKeysFromRoot(findComponentRoot(markdown, def));
150
150
  }
151
151
 
152
+ /** The result of {@link componentRoundTripSafety}: whether re-opening a placed block into the
153
+ * guided form and re-serializing it is provably lossless. */
154
+ export type RoundTripSafety =
155
+ | { safe: true }
156
+ | { safe: false; reason: 'unknown-attribute' | 'undeclared-child' | 'not-idempotent' | 'not-a-component' };
157
+
158
+ /** Decide whether guided edit of this placed block is provably lossless. A block a person typed by
159
+ * hand can carry more than the schema models (an attribute the def does not list, a child container
160
+ * the def does not declare, slot content the form cannot represent stably), and parsing such a block
161
+ * into the form then re-serializing would silently drop it. The edit affordance is offered only when
162
+ * this returns `{ safe: true }`. Checks run in order and return the first failure:
163
+ *
164
+ * 1. `not-a-component`: the def's opening container is not present.
165
+ * 2. `unknown-attribute`: the block carries an attribute key the def does not declare.
166
+ * 3. `undeclared-child`: the root has a direct child container directive that is not a declared
167
+ * nested slot. Such a child would otherwise fold into the body slot and move on re-serialize.
168
+ * 4. `not-idempotent`: `parse -> serialize -> parse` does not recover the same values. */
169
+ export async function componentRoundTripSafety(markdown: string, def: ComponentDef): Promise<RoundTripSafety> {
170
+ const root = findComponentRoot(markdown, def);
171
+ if (!root) return { safe: false, reason: 'not-a-component' };
172
+
173
+ const declaredKeys = new Set((def.attributes ?? []).map((f) => f.key));
174
+ for (const key of parseRawAttributeKeys(markdown, def)) {
175
+ if (!declaredKeys.has(key)) return { safe: false, reason: 'unknown-attribute' };
176
+ }
177
+
178
+ const slotNames = new Set(nestedSlots(def).map((s) => s.name));
179
+ for (const child of root.children) {
180
+ if (isContainer(child) && !slotNames.has((child as DirectiveNode).name)) {
181
+ return { safe: false, reason: 'undeclared-child' };
182
+ }
183
+ }
184
+
185
+ // The values are plain strings, booleans, and string arrays in declared (object-key) order, so a
186
+ // stable JSON.stringify is a sufficient deep-equal.
187
+ const v1 = await parseComponent(markdown, def);
188
+ const v2 = await parseComponent(serializeComponent(def, v1), def);
189
+ if (JSON.stringify(v1) !== JSON.stringify(v2)) return { safe: false, reason: 'not-idempotent' };
190
+
191
+ return { safe: true };
192
+ }
193
+
152
194
  /** Parse the component once and derive both the guided-form values and the raw attribute keys.
153
195
  * Validation needs both, so this seam spares it the double parse that calling
154
196
  * {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
@@ -178,8 +220,22 @@ function isDirectiveLabel(node: RootContent): boolean {
178
220
 
179
221
  function readLabel(root: DirectiveNode): string | undefined {
180
222
  for (const child of root.children) {
181
- const p = child as { type: string; data?: { directiveLabel?: boolean }; children?: { value?: string }[] };
182
- if (p.type === 'paragraph' && p.data?.directiveLabel) return (p.children ?? []).map((c) => c.value ?? '').join('');
223
+ const p = child as {
224
+ type: string;
225
+ data?: { directiveLabel?: boolean };
226
+ children?: RootContent[];
227
+ };
228
+ if (p.type !== 'paragraph' || !p.data?.directiveLabel) continue;
229
+ const kids = p.children ?? [];
230
+ // When every label child is a plain text node, join the raw `.value`s. That keeps the pure-text
231
+ // path identical to before, so a literal `[` or `]` in the title is not re-escaped by the
232
+ // stringifier (serializeComponent already escapes brackets, and remark un-escapes them on parse).
233
+ // When the label carries inline markdown (a link, bold, emphasis), the text-only join would drop
234
+ // the markup, so stringify the children to recover the full inline source losslessly.
235
+ if (kids.every((c) => (c as { type: string }).type === 'text')) {
236
+ return kids.map((c) => (c as { value?: string }).value ?? '').join('');
237
+ }
238
+ return childrenToText(kids);
183
239
  }
184
240
  return undefined;
185
241
  }
@@ -1,5 +1,5 @@
1
1
  import { parseComponentWithRawKeys } from './component-grammar.js';
2
- import type { ComponentDef } from './registry.js';
2
+ import type { ComponentDef, ComponentValues } from './registry.js';
3
3
 
4
4
  /** A validation verdict: ok, or field-keyed error messages. */
5
5
  export type ComponentValidation = { ok: true } | { ok: false; errors: Record<string, string> };
@@ -18,6 +18,15 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
18
18
  }
19
19
  if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
20
20
  errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
21
+ continue;
22
+ }
23
+ if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
24
+ errors[field.key] = field.pattern.message;
25
+ continue;
26
+ }
27
+ if (field.validate) {
28
+ const message = runFieldValidator(def, field.key, () => field.validate!(v, values));
29
+ if (typeof message === 'string') errors[field.key] = message;
21
30
  }
22
31
  }
23
32
 
@@ -34,3 +43,15 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
34
43
 
35
44
  return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
36
45
  }
46
+
47
+ // Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
48
+ // the field is treated as valid and a dev-time warning names the component and field so the author
49
+ // can find the bug. A returned string is the field error; anything else (null) is clean.
50
+ function runFieldValidator(def: ComponentDef, key: string, call: () => string | null): string | null {
51
+ try {
52
+ return call();
53
+ } catch (err) {
54
+ console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
55
+ return null;
56
+ }
57
+ }
@@ -22,6 +22,12 @@ export interface AttributeField {
22
22
  options?: readonly string[];
23
23
  /** Helper text shown under the field. */
24
24
  help?: string;
25
+ /** A RegExp `source` to validate the value against, plus the message to show on a mismatch. */
26
+ pattern?: { source: string; message: string };
27
+ /** A pure, browser-safe cross-field validator. Returns an error string, or null when valid.
28
+ * Receives the field's value and the full {@link ComponentValues} so a rule can read sibling
29
+ * fields. The picker wraps the call in try/catch so an author's throw never crashes the form. */
30
+ validate?: (value: string | boolean, all: ComponentValues) => string | null;
25
31
  }
26
32
 
27
33
  export type SlotKind = 'markdown' | 'inline' | 'repeatable';
@@ -36,6 +42,9 @@ export interface SlotDef {
36
42
  help?: string;
37
43
  /** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
38
44
  itemFields?: AttributeField[];
45
+ /** For `kind: 'repeatable'`: derives a row's label from its item values and zero-based index.
46
+ * When it returns nothing, the picker falls back to `${label} ${index + 1}`. */
47
+ itemLabel?: (item: Record<string, string | boolean>, index: number) => string;
39
48
  }
40
49
 
41
50
  /** The structured input a component's `build` receives. The engine stamps the component's
@@ -75,6 +84,18 @@ export interface ComponentDef {
75
84
  attributes?: AttributeField[];
76
85
  /** The named content regions this component accepts. */
77
86
  slots?: SlotDef[];
87
+ /** A glyph key from the site IconSet, shown beside the label in the picker. */
88
+ icon?: string;
89
+ /** A category heading for the picker. Components order by declaration within a group. */
90
+ group?: string;
91
+ /** Omit from the top-level picker (for a nested or round-trip-only component). */
92
+ hidden?: boolean;
93
+ /** A structured sample the picker seeds the form with and renders through the same path a real
94
+ * insert takes. Declaring `preview` is what opts the component into the two-pane configure layout. */
95
+ preview?: {
96
+ attributes?: Record<string, string | boolean>;
97
+ slots?: Record<string, string | string[]>;
98
+ };
78
99
  }
79
100
 
80
101
  export interface ComponentRegistry {
@@ -145,3 +166,15 @@ export function emptyValues(def: ComponentDef): ComponentValues {
145
166
  }
146
167
  return { attributes, slots };
147
168
  }
169
+
170
+ /** Seed {@link ComponentValues} from a component's `preview` sample: the {@link emptyValues} base
171
+ * with `def.preview.attributes` and `def.preview.slots` overlaid (a shallow merge per side). When
172
+ * the def declares no `preview`, returns exactly the {@link emptyValues} output. */
173
+ export function previewValues(def: ComponentDef): ComponentValues {
174
+ const base = emptyValues(def);
175
+ if (!def.preview) return base;
176
+ return {
177
+ attributes: { ...base.attributes, ...def.preview.attributes },
178
+ slots: { ...base.slots, ...def.preview.slots },
179
+ };
180
+ }