@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.
- package/CHANGELOG.md +61 -0
- package/README.md +10 -3
- package/dist/components/ComponentForm.svelte +175 -46
- package/dist/components/ComponentForm.svelte.d.ts +22 -8
- package/dist/components/ComponentInsertDialog.svelte +379 -26
- package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
- package/dist/components/EditPage.svelte +130 -8
- package/dist/components/MarkdownEditor.svelte +75 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +14 -0
- package/dist/components/cairn-admin.css +125 -0
- package/dist/components/markdown-directives.d.ts +7 -0
- package/dist/components/markdown-directives.js +10 -0
- package/dist/render/component-grammar.d.ts +20 -0
- package/dist/render/component-grammar.js +47 -3
- package/dist/render/component-validate.js +22 -0
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +12 -0
- package/package.json +2 -1
- package/src/lib/components/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +130 -8
- package/src/lib/components/MarkdownEditor.svelte +75 -0
- package/src/lib/components/markdown-directives.ts +11 -0
- package/src/lib/render/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/registry.ts +33 -0
|
@@ -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
|
-
|
|
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
|
|
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 {
|
|
182
|
-
|
|
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
|
+
}
|