@glw907/cairn-cms 0.18.0 → 0.24.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 (106) hide show
  1. package/dist/components/DeleteDialog.svelte +81 -0
  2. package/dist/components/DeleteDialog.svelte.d.ts +21 -0
  3. package/dist/components/DeleteDialog.svelte.d.ts.map +1 -0
  4. package/dist/components/EditPage.svelte +127 -8
  5. package/dist/components/EditPage.svelte.d.ts +8 -0
  6. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  7. package/dist/components/LinkPicker.svelte +109 -0
  8. package/dist/components/LinkPicker.svelte.d.ts +18 -0
  9. package/dist/components/LinkPicker.svelte.d.ts.map +1 -0
  10. package/dist/components/MarkdownEditor.svelte +33 -3
  11. package/dist/components/MarkdownEditor.svelte.d.ts +5 -0
  12. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -1
  13. package/dist/components/RenameDialog.svelte +72 -0
  14. package/dist/components/RenameDialog.svelte.d.ts +20 -0
  15. package/dist/components/RenameDialog.svelte.d.ts.map +1 -0
  16. package/dist/components/index.d.ts +3 -0
  17. package/dist/components/index.d.ts.map +1 -1
  18. package/dist/components/index.js +3 -0
  19. package/dist/components/link-completion.d.ts +16 -0
  20. package/dist/components/link-completion.d.ts.map +1 -0
  21. package/dist/components/link-completion.js +48 -0
  22. package/dist/components/markdown-format.d.ts +25 -5
  23. package/dist/components/markdown-format.d.ts.map +1 -1
  24. package/dist/components/markdown-format.js +85 -0
  25. package/dist/content/concepts.d.ts.map +1 -1
  26. package/dist/content/concepts.js +7 -0
  27. package/dist/content/frontmatter.d.ts +8 -0
  28. package/dist/content/frontmatter.d.ts.map +1 -1
  29. package/dist/content/frontmatter.js +19 -0
  30. package/dist/content/ids.d.ts +7 -0
  31. package/dist/content/ids.d.ts.map +1 -1
  32. package/dist/content/ids.js +11 -0
  33. package/dist/content/links.d.ts +7 -0
  34. package/dist/content/links.d.ts.map +1 -1
  35. package/dist/content/links.js +11 -0
  36. package/dist/content/manifest.d.ts +15 -1
  37. package/dist/content/manifest.d.ts.map +1 -1
  38. package/dist/content/manifest.js +45 -3
  39. package/dist/content/types.d.ts +6 -0
  40. package/dist/content/types.d.ts.map +1 -1
  41. package/dist/content/validate.d.ts.map +1 -1
  42. package/dist/content/validate.js +8 -1
  43. package/dist/delivery/content-index.d.ts +7 -0
  44. package/dist/delivery/content-index.d.ts.map +1 -1
  45. package/dist/delivery/content-index.js +7 -0
  46. package/dist/delivery/head.d.ts +2 -0
  47. package/dist/delivery/head.d.ts.map +1 -0
  48. package/dist/delivery/head.js +4 -0
  49. package/dist/delivery/index.d.ts +0 -1
  50. package/dist/delivery/index.d.ts.map +1 -1
  51. package/dist/delivery/index.js +0 -1
  52. package/dist/delivery/manifest.d.ts.map +1 -1
  53. package/dist/delivery/manifest.js +7 -0
  54. package/dist/github/repo.d.ts.map +1 -1
  55. package/dist/github/repo.js +8 -1
  56. package/dist/index.d.ts +7 -4
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -3
  59. package/dist/render/pipeline.d.ts +4 -0
  60. package/dist/render/pipeline.d.ts.map +1 -1
  61. package/dist/render/pipeline.js +3 -1
  62. package/dist/render/registry.d.ts +1 -1
  63. package/dist/render/registry.d.ts.map +1 -1
  64. package/dist/render/rehype-dispatch.d.ts +5 -0
  65. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  66. package/dist/render/rehype-dispatch.js +12 -1
  67. package/dist/render/remark-directives.d.ts.map +1 -1
  68. package/dist/render/remark-directives.js +15 -6
  69. package/dist/render/sanitize-schema.d.ts +4 -3
  70. package/dist/render/sanitize-schema.d.ts.map +1 -1
  71. package/dist/render/sanitize-schema.js +6 -5
  72. package/dist/sveltekit/content-routes.d.ts +11 -2
  73. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  74. package/dist/sveltekit/content-routes.js +157 -9
  75. package/dist/sveltekit/public-routes.d.ts +1 -0
  76. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  77. package/dist/sveltekit/public-routes.js +1 -1
  78. package/package.json +7 -1
  79. package/src/lib/components/DeleteDialog.svelte +81 -0
  80. package/src/lib/components/EditPage.svelte +127 -8
  81. package/src/lib/components/LinkPicker.svelte +109 -0
  82. package/src/lib/components/MarkdownEditor.svelte +33 -3
  83. package/src/lib/components/RenameDialog.svelte +72 -0
  84. package/src/lib/components/index.ts +3 -0
  85. package/src/lib/components/link-completion.ts +57 -0
  86. package/src/lib/components/markdown-format.ts +82 -0
  87. package/src/lib/content/concepts.ts +9 -0
  88. package/src/lib/content/frontmatter.ts +21 -0
  89. package/src/lib/content/ids.ts +12 -0
  90. package/src/lib/content/links.ts +13 -0
  91. package/src/lib/content/manifest.ts +55 -3
  92. package/src/lib/content/types.ts +6 -0
  93. package/src/lib/content/validate.ts +6 -1
  94. package/src/lib/delivery/content-index.ts +13 -0
  95. package/src/lib/delivery/head.ts +4 -0
  96. package/src/lib/delivery/index.ts +0 -1
  97. package/src/lib/delivery/manifest.ts +6 -0
  98. package/src/lib/github/repo.ts +8 -1
  99. package/src/lib/index.ts +10 -2
  100. package/src/lib/render/pipeline.ts +6 -1
  101. package/src/lib/render/registry.ts +1 -1
  102. package/src/lib/render/rehype-dispatch.ts +12 -1
  103. package/src/lib/render/remark-directives.ts +16 -5
  104. package/src/lib/render/sanitize-schema.ts +6 -5
  105. package/src/lib/sveltekit/content-routes.ts +178 -11
  106. package/src/lib/sveltekit/public-routes.ts +2 -1
@@ -9,7 +9,7 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
9
9
  <script lang="ts">
10
10
  import { onMount, onDestroy } from 'svelte';
11
11
  import EditorToolbar from './EditorToolbar.svelte';
12
- import { applyMarkdownFormat, type FormatKind } from './markdown-format.js';
12
+ import { applyMarkdownFormat, insertInlineLink, type FormatKind } from './markdown-format.js';
13
13
 
14
14
  interface Props {
15
15
  /** The markdown source; bindable so the parent reads edits back. */
@@ -18,9 +18,14 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
18
18
  name: string;
19
19
  /** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
20
20
  registerInsert?: (insert: (text: string) => void) => void;
21
+ /** Receives a `(href, title) => void` that inserts an inline link; the link picker calls it. */
22
+ registerInsertLink?: (insert: (href: string, title: string) => void) => void;
23
+ /** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
24
+ * type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
25
+ completionSources?: import('@codemirror/autocomplete').CompletionSource[];
21
26
  }
22
27
 
23
- let { value = $bindable(), name, registerInsert }: Props = $props();
28
+ let { value = $bindable(), name, registerInsert, registerInsertLink, completionSources = [] }: Props = $props();
24
29
 
25
30
  let host = $state<HTMLDivElement | null>(null);
26
31
  let mounted = $state(false);
@@ -35,6 +40,7 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
35
40
  const markdownMod = await import('@codemirror/lang-markdown');
36
41
  const commandsMod = await import('@codemirror/commands');
37
42
  const languageMod = await import('@codemirror/language');
43
+ const autocompleteMod = await import('@codemirror/autocomplete');
38
44
 
39
45
  if (!host) return;
40
46
 
@@ -56,8 +62,13 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
56
62
  doc: value,
57
63
  extensions: [
58
64
  commandsMod.history(),
59
- keymap.of([...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
65
+ keymap.of([...autocompleteMod.completionKeymap, ...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
60
66
  markdownMod.markdown(),
67
+ ...(completionSources.length
68
+ ? // interactionDelay 0: the popup opens only on an explicit `[[` trigger, so the default
69
+ // accidental-accept guard adds no value and would swallow an immediate Enter into a newline.
70
+ [autocompleteMod.autocompletion({ override: completionSources, interactionDelay: 0 })]
71
+ : []),
61
72
  EditorView.lineWrapping,
62
73
  languageMod.syntaxHighlighting(languageMod.defaultHighlightStyle, { fallback: true }),
63
74
  theme,
@@ -69,6 +80,7 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
69
80
  });
70
81
 
71
82
  registerInsert?.(insertAtCursor);
83
+ registerInsertLink?.(insertLink);
72
84
  mounted = true;
73
85
  });
74
86
 
@@ -96,6 +108,24 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
96
108
  view.focus();
97
109
  }
98
110
 
111
+ function insertLink(href: string, title: string) {
112
+ if (!view) {
113
+ // The editor has not mounted yet; append the link to the raw value so a pick is never lost,
114
+ // mirroring insertAtCursor's pre-mount fallback.
115
+ const link = insertInlineLink('', 0, 0, href, title).doc;
116
+ value = value ? `${value} ${link}` : link;
117
+ return;
118
+ }
119
+ const { from, to } = view.state.selection.main;
120
+ const doc = view.state.doc.toString();
121
+ const next = insertInlineLink(doc, from, to, href, title);
122
+ view.dispatch({
123
+ changes: { from: 0, to: doc.length, insert: next.doc },
124
+ selection: { anchor: next.from, head: next.to },
125
+ });
126
+ view.focus();
127
+ }
128
+
99
129
  function applyFormat(kind: FormatKind) {
100
130
  if (!view) return;
101
131
  const { from, to } = view.state.selection.main;
@@ -0,0 +1,72 @@
1
+ <!--
2
+ @component
3
+ The Change URL control and its modal. The author edits the URL slug; on submit the ?/rename action
4
+ moves the entry and rewrites every inbound cairn link in one commit, so no internal link breaks. A
5
+ dated post keeps its date; only the slug changes. Built on a native <dialog>, following the
6
+ DeleteDialog a11y conventions.
7
+ -->
8
+ <script lang="ts">
9
+ interface Props {
10
+ /** The concept this entry belongs to, e.g. "posts". Posted with the confirm. */
11
+ conceptId: string;
12
+ /** The entry id within its concept. Posted with the confirm. */
13
+ id: string;
14
+ /** A human label for the concept, e.g. "Post", used in the prompts. */
15
+ label: string;
16
+ /** The current slug, prefilled into the input. */
17
+ slug: string;
18
+ }
19
+
20
+ let { conceptId, id, label, slug }: Props = $props();
21
+
22
+ let dialog = $state<HTMLDialogElement | null>(null);
23
+ let slugInput = $state<HTMLInputElement | null>(null);
24
+ // Seeded on open() rather than from the prop at declaration, so the input prefills with the
25
+ // current slug each time the dialog opens without capturing only the initial prop value.
26
+ let nextSlug = $state('');
27
+
28
+ function open() {
29
+ nextSlug = slug;
30
+ dialog?.showModal();
31
+ // showModal() lands focus on the first focusable element (the header Close button), so move
32
+ // it to the slug input the dialog exists for, and select the prefill so the author can replace
33
+ // it in one keystroke (WCAG 2.4.3). A microtask defers past the dialog's own focus handling.
34
+ queueMicrotask(() => {
35
+ slugInput?.focus();
36
+ slugInput?.select();
37
+ });
38
+ }
39
+ function close() {
40
+ dialog?.close();
41
+ }
42
+ </script>
43
+
44
+ <button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" onclick={open}>Change URL</button>
45
+
46
+ <dialog class="modal" aria-labelledby="cairn-rename-dialog-title" bind:this={dialog}>
47
+ <div class="modal-box">
48
+ <div class="mb-3 flex items-center justify-between">
49
+ <h2 id="cairn-rename-dialog-title" class="text-base font-semibold">Change this {label.toLowerCase()} URL</h2>
50
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
51
+ </div>
52
+ <form method="POST" action="?/rename" class="flex flex-col gap-3">
53
+ <input type="hidden" name="concept" value={conceptId} />
54
+ <input type="hidden" name="id" value={id} />
55
+ <label class="flex flex-col gap-1">
56
+ <span class="text-sm font-medium">URL slug</span>
57
+ <input class="input" name="slug" bind:value={nextSlug} bind:this={slugInput} autocomplete="off" />
58
+ </label>
59
+ <p class="text-xs text-[var(--color-muted)]">
60
+ Links from other pages update automatically, so nothing breaks. The new URL slug will be
61
+ <code class="text-xs">{nextSlug}</code>.
62
+ </p>
63
+ <div class="flex justify-end gap-2">
64
+ <button type="button" class="btn btn-sm" onclick={close}>Cancel</button>
65
+ <button type="submit" class="btn btn-sm btn-primary">Change URL</button>
66
+ </div>
67
+ </form>
68
+ </div>
69
+ <form method="dialog" class="modal-backdrop">
70
+ <button tabindex="-1" aria-label="Close">close</button>
71
+ </form>
72
+ </dialog>
@@ -11,3 +11,6 @@ export { default as ComponentInsertDialog } from './ComponentInsertDialog.svelte
11
11
  export { default as ComponentForm } from './ComponentForm.svelte';
12
12
  export { default as IconPicker } from './IconPicker.svelte';
13
13
  export { default as NavTree } from './NavTree.svelte';
14
+ export { default as LinkPicker } from './LinkPicker.svelte';
15
+ export { default as DeleteDialog } from './DeleteDialog.svelte';
16
+ export { default as RenameDialog } from './RenameDialog.svelte';
@@ -0,0 +1,57 @@
1
+ // cairn-cms: the [[ link autocomplete (content-graph design). The matcher and the completion
2
+ // builder are pure so they unit-test without a DOM; cairnLinkCompletionSource is a thin adapter
3
+ // to CodeMirror's CompletionSource. The editor wires the source through a generic completionSources
4
+ // prop, so this stays the only link-aware piece and the seam itself knows nothing about links.
5
+ import type { Completion, CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
6
+ import { syntaxTree } from '@codemirror/language';
7
+ import type { LinkTarget } from '../content/manifest.js';
8
+ import { formatCairnToken, escapeLinkText } from '../content/links.js';
9
+
10
+ /** The known concepts in display order; an unlisted concept sorts after these under its own name. */
11
+ const CONCEPT_SECTIONS: Record<string, { name: string; rank: number }> = {
12
+ pages: { name: 'Pages', rank: 0 },
13
+ posts: { name: 'Posts', rank: 1 },
14
+ };
15
+
16
+ function sectionFor(concept: string): { name: string; rank: number } {
17
+ return CONCEPT_SECTIONS[concept] ?? { name: concept.charAt(0).toUpperCase() + concept.slice(1), rank: 2 };
18
+ }
19
+
20
+ /** The open `[[query` before the cursor, or null. The query stops at a closing bracket or a newline,
21
+ * so a finished `[[x]]` link and ordinary prose never trigger. `from` is the index of the `[[`. */
22
+ export function matchCairnTrigger(before: string): { query: string; from: number } | null {
23
+ const match = /\[\[([^[\]\n]*)$/.exec(before);
24
+ return match ? { query: match[1], from: match.index } : null;
25
+ }
26
+
27
+ /** The completion options for a query: a case-insensitive title substring match, each option grouped
28
+ * by concept, a draft marked and a post date shown in the detail, and the apply text the full link. */
29
+ export function linkCompletions(targets: LinkTarget[], query: string): Completion[] {
30
+ const q = query.trim().toLowerCase();
31
+ const matched = q ? targets.filter((t) => t.title.toLowerCase().includes(q)) : targets;
32
+ return matched.map((t) => ({
33
+ label: t.title,
34
+ section: sectionFor(t.concept),
35
+ detail: t.draft ? 'Draft' : t.date,
36
+ apply: `[${escapeLinkText(t.title)}](${formatCairnToken(t)})`,
37
+ }));
38
+ }
39
+
40
+ /** A CodeMirror CompletionSource over the site's link targets, triggered by `[[`. It replaces the
41
+ * whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
42
+ * filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
43
+ export function cairnLinkCompletionSource(targets: LinkTarget[]): CompletionSource {
44
+ return (context: CompletionContext): CompletionResult | null => {
45
+ const line = context.state.doc.lineAt(context.pos);
46
+ const before = context.state.sliceDoc(line.from, context.pos);
47
+ const trigger = matchCairnTrigger(before);
48
+ if (!trigger) return null;
49
+ // Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
50
+ // the build resolver does not look inside code. The node name carries "Code" for both forms.
51
+ const node = syntaxTree(context.state).resolveInner(context.pos, -1);
52
+ for (let n: typeof node | null = node; n; n = n.parent) {
53
+ if (/Code/.test(n.name)) return null;
54
+ }
55
+ return { from: line.from + trigger.from, options: linkCompletions(targets, trigger.query), filter: false };
56
+ };
57
+ }
@@ -3,6 +3,13 @@
3
3
  * selection range to a new document and a new selection, with no DOM. The MarkdownEditor view
4
4
  * dispatches the result; keeping the logic here lets it unit-test without a browser.
5
5
  */
6
+ import { unified } from 'unified';
7
+ import remarkParse from 'remark-parse';
8
+ import remarkGfm from 'remark-gfm';
9
+ import { visit } from 'unist-util-visit';
10
+ import type { Link } from 'mdast';
11
+ import { escapeLinkText } from '../content/links.js';
12
+
6
13
  export type FormatKind = 'bold' | 'italic' | 'code' | 'heading' | 'quote' | 'ul' | 'link';
7
14
 
8
15
  export interface FormatResult {
@@ -37,3 +44,78 @@ export function applyMarkdownFormat(doc: string, from: number, to: number, kind:
37
44
  const added = prefixed.length - region.length;
38
45
  return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
39
46
  }
47
+
48
+ /**
49
+ * Insert an inline markdown link at the selection. With a non-empty selection the selected text
50
+ * becomes the display text; with an empty selection the title is the display text. The cursor
51
+ * collapses just after the inserted link. Unlike the block insert, this adds no surrounding
52
+ * blank lines, since a link is inline. Pure, so the editor dispatches the result.
53
+ */
54
+ export function insertInlineLink(doc: string, from: number, to: number, href: string, title: string): FormatResult {
55
+ const text = from < to ? doc.slice(from, to) : escapeLinkText(title);
56
+ const inserted = `[${text}](${href})`;
57
+ const end = from + inserted.length;
58
+ return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: end, to: end };
59
+ }
60
+
61
+ /** Concatenate a link node's text-child values. The parser has already unescaped them, so a source
62
+ * `Notes \[draft\]` yields `Notes [draft]`. Used instead of mdast-util-to-string, which is not a
63
+ * direct dependency. Non-text children (a nested emphasis, say) contribute no value, which is fine
64
+ * for the picker-produced links this fix targets. */
65
+ function linkText(node: Link): string {
66
+ return node.children.map((c) => ('value' in c ? c.value : '')).join('');
67
+ }
68
+
69
+ /**
70
+ * Unwrap every cairn: link whose href is exactly `href`, replacing it with its plain display text.
71
+ * The save guard's one-click fix calls this to drop a broken link while keeping the words. The
72
+ * document is parsed with the same remark pipeline extractCairnLinks uses, so the two agree on what
73
+ * a link is. Each matching link node is located by its source offsets and spliced out from last to
74
+ * first, which leaves the rest of the document exact and unescapes the display text. A token inside
75
+ * a code span or fence is not a link node, so it is never touched, and a link with a different url
76
+ * is left in place.
77
+ */
78
+ export function unwrapCairnLink(doc: string, href: string): string {
79
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
80
+ const spans: { start: number; end: number; text: string }[] = [];
81
+ visit(tree, 'link', (node: Link) => {
82
+ if (node.url !== href) return;
83
+ const start = node.position?.start?.offset;
84
+ const end = node.position?.end?.offset;
85
+ if (start == null || end == null) return;
86
+ spans.push({ start, end, text: linkText(node) });
87
+ });
88
+ spans.sort((a, b) => b.start - a.start);
89
+ let out = doc;
90
+ for (const span of spans) {
91
+ out = out.slice(0, span.start) + span.text + out.slice(span.end);
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
98
+ * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
99
+ * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
100
+ * span is not a link node and is never touched. Each matching node's source span is rewritten from
101
+ * last to first, replacing only the `](oldHref` run so the label and title stay exact.
102
+ */
103
+ export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
104
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
105
+ const spans: { start: number; end: number }[] = [];
106
+ visit(tree, 'link', (node: Link) => {
107
+ if (node.url !== oldHref) return;
108
+ const start = node.position?.start?.offset;
109
+ const end = node.position?.end?.offset;
110
+ if (start == null || end == null) return;
111
+ spans.push({ start, end });
112
+ });
113
+ spans.sort((a, b) => b.start - a.start);
114
+ let out = doc;
115
+ for (const span of spans) {
116
+ const src = out.slice(span.start, span.end);
117
+ const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
118
+ out = out.slice(0, span.start) + rewritten + out.slice(span.end);
119
+ }
120
+ return out;
121
+ }
@@ -43,6 +43,14 @@ export function normalizeConcepts(
43
43
  const descriptors: ConceptDescriptor[] = [];
44
44
  for (const [id, config] of Object.entries(content)) {
45
45
  if (!config) continue;
46
+ const summaryFields = config.summaryFields ?? [];
47
+ const declared = new Set(config.schema.fields.map((field) => field.name));
48
+ const undeclared = summaryFields.find((key) => !declared.has(key));
49
+ if (undeclared !== undefined) {
50
+ throw new Error(
51
+ `cairn: concept "${id}" summaryFields key "${undeclared}" is not a declared field`,
52
+ );
53
+ }
46
54
  const policy = urlPolicy[id] ?? {};
47
55
  descriptors.push({
48
56
  id,
@@ -52,6 +60,7 @@ export function normalizeConcepts(
52
60
  permalink: policy.permalink ?? defaultPermalink(id),
53
61
  datePrefix: policy.datePrefix ?? 'day',
54
62
  fields: config.schema.fields,
63
+ summaryFields,
55
64
  validate: config.schema.validate,
56
65
  });
57
66
  }
@@ -56,6 +56,27 @@ export function dateInputValue(value: unknown): string {
56
56
  return '';
57
57
  }
58
58
 
59
+ /**
60
+ * True when `s` is a canonical zero-padded `YYYY-MM-DD` string naming a real calendar date.
61
+ * Rejects a wrong format, an impossible month or day, and a JS date-rollover such as
62
+ * `2026-02-30` (which `Date` would silently roll forward to March 2). The committed form a
63
+ * date field carries is exactly this canonical shape, which is what the form and
64
+ * `dateInputValue` emit, so a value outside it is a hand-edit or odd-YAML error.
65
+ */
66
+ export function isCalendarDate(s: string): boolean {
67
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
68
+ if (!match) return false;
69
+ const year = Number(match[1]);
70
+ const month = Number(match[2]);
71
+ const day = Number(match[3]);
72
+ const date = new Date(Date.UTC(year, month - 1, day));
73
+ return (
74
+ date.getUTCFullYear() === year &&
75
+ date.getUTCMonth() === month - 1 &&
76
+ date.getUTCDate() === day
77
+ );
78
+ }
79
+
59
80
  /** Reassemble a markdown file from frontmatter and body for committing. */
60
81
  export function serializeMarkdown(frontmatter: object, body: string): string {
61
82
  return matter.stringify(body, frontmatter);
@@ -80,3 +80,15 @@ export function composeDatedId(date: string, slug: string, datePrefix: DatePrefi
80
80
  }
81
81
  return `${prefix}-${slug}`;
82
82
  }
83
+
84
+ /**
85
+ * Rename an id by swapping its slug, keeping any date prefix. slugFromId strips only the leading
86
+ * date prefix, so the id is exactly its prefix followed by its slug; this replaces the slug suffix
87
+ * with newSlug. A non-dated concept passes null, so the whole id is the slug and the id becomes
88
+ * newSlug. The caller validates newSlug with isValidId first.
89
+ */
90
+ export function renameId(oldId: string, newSlug: string, datePrefix: DatePrefix | null): string {
91
+ const oldSlug = slugFromId(oldId, datePrefix);
92
+ const prefix = oldId.slice(0, oldId.length - oldSlug.length);
93
+ return prefix + newSlug;
94
+ }
@@ -30,6 +30,19 @@ export function parseCairnToken(href: string): CairnRef | null {
30
30
  return { concept, id };
31
31
  }
32
32
 
33
+ /** Write the `cairn:<concept>/<id>` token for a ref. The inverse of parseCairnToken, so the editor
34
+ * link picker and the autocomplete write exactly the form the resolver reads back. */
35
+ export function formatCairnToken(ref: CairnRef): string {
36
+ return `cairn:${ref.concept}/${ref.id}`;
37
+ }
38
+
39
+ /** Escape the characters that would break a markdown link's display text: a backslash and the
40
+ * square brackets that delimit the text. Used where a content title becomes link display text,
41
+ * so an unbalanced bracket in a title cannot truncate the generated link. */
42
+ export function escapeLinkText(text: string): string {
43
+ return text.replace(/[\\[\]]/g, (ch) => `\\${ch}`);
44
+ }
45
+
33
46
  /** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
34
47
  * Parses the body as mdast, so a token inside a code span or fence is never matched. */
35
48
  export function extractCairnLinks(body: string): CairnRef[] {
@@ -97,13 +97,47 @@ export function serializeManifest(manifest: Manifest): string {
97
97
  return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
98
98
  }
99
99
 
100
- /** Parse a committed manifest. Throws on malformed JSON or the wrong shape. */
100
+ /** Parse a committed manifest. Throws on malformed JSON, a wrong version, or a malformed entry, so
101
+ * every reader (the save guard, the delete path, the preview) sees a well-formed graph or a clear
102
+ * error. The build regenerates the manifest, so a real file is always canonical; this guards a
103
+ * hand-edited or truncated one. */
101
104
  export function parseManifest(raw: string): Manifest {
102
105
  const data = JSON.parse(raw) as unknown;
103
- if (!data || typeof data !== 'object' || !Array.isArray((data as { entries?: unknown }).entries)) {
106
+ if (!data || typeof data !== 'object') {
104
107
  throw new Error('content manifest: malformed file, expected { version, entries: [] }');
105
108
  }
106
- return { version: 1, entries: (data as Manifest).entries };
109
+ const obj = data as { version?: unknown; entries?: unknown };
110
+ if (obj.version !== 1) {
111
+ throw new Error(`content manifest: unsupported version ${String(obj.version)}, expected 1`);
112
+ }
113
+ if (!Array.isArray(obj.entries)) {
114
+ throw new Error('content manifest: malformed file, expected { version, entries: [] }');
115
+ }
116
+ for (const entry of obj.entries) {
117
+ const e = entry as Record<string, unknown>;
118
+ const ok =
119
+ e &&
120
+ typeof e.id === 'string' &&
121
+ typeof e.concept === 'string' &&
122
+ typeof e.title === 'string' &&
123
+ typeof e.permalink === 'string' &&
124
+ typeof e.draft === 'boolean' &&
125
+ (e.date === undefined || typeof e.date === 'string') &&
126
+ Array.isArray(e.links);
127
+ if (!ok) {
128
+ throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
129
+ }
130
+ // Validate each link element's shape, not just that links is an array. inboundLinks and the
131
+ // delete guard read l.concept and l.id, so a string, null, or id-less element would read as
132
+ // undefined and silently drop a real inbound linker. Reject it here instead.
133
+ for (const link of e.links as unknown[]) {
134
+ const l = link as Record<string, unknown> | null;
135
+ if (!l || typeof l !== 'object' || typeof l.concept !== 'string' || typeof l.id !== 'string') {
136
+ throw new Error(`content manifest: malformed link ${JSON.stringify(link)} in entry ${JSON.stringify(e)}`);
137
+ }
138
+ }
139
+ }
140
+ return { version: 1, entries: obj.entries as ManifestEntry[] };
107
141
  }
108
142
 
109
143
  /** Throw if the committed manifest drifts from what the corpus says. Both sides are compared in the
@@ -130,6 +164,24 @@ export function removeEntry(manifest: Manifest, concept: string, id: string): Ma
130
164
  return { version: 1, entries: manifest.entries.filter((e) => !(e.concept === concept && e.id === id)) };
131
165
  }
132
166
 
167
+ /** One inbound linker: enough to name it and link to its edit page in the delete guard. */
168
+ export interface InboundLink {
169
+ concept: string;
170
+ id: string;
171
+ title: string;
172
+ permalink: string;
173
+ }
174
+
175
+ /** Every entry whose outbound edges point at the target, excluding the target itself. The delete
176
+ * guard reads this to name "what links here"; the backlinks panel will reuse it. Pure over the
177
+ * manifest, so the request-time delete path and a unit test call it the same way. */
178
+ export function inboundLinks(manifest: Manifest, concept: string, id: string): InboundLink[] {
179
+ return manifest.entries
180
+ .filter((e) => !(e.concept === concept && e.id === id))
181
+ .filter((e) => e.links.some((l) => l.concept === concept && l.id === id))
182
+ .map((e) => ({ concept: e.concept, id: e.id, title: e.title, permalink: e.permalink }));
183
+ }
184
+
133
185
  /** A resolver backed by manifest targets, for the admin preview. A miss returns undefined, so the
134
186
  * render step marks the link broken rather than throwing. The build resolver throws instead. */
135
187
  export function manifestLinkResolver(targets: { concept: string; id: string; permalink: string }[]): LinkResolve {
@@ -110,6 +110,9 @@ export interface ConceptConfig<S extends ConceptSchema = ConceptSchema> {
110
110
  label?: string;
111
111
  /** The concept's schema: the form projection, the generated validator, and the inferred type. */
112
112
  schema: S;
113
+ /** Frontmatter keys to surface on each `ContentSummary.fields`, so a list card reads an authored
114
+ * field without a per-entry detail read. Each key should also be declared in `schema`. */
115
+ summaryFields?: string[];
113
116
  }
114
117
 
115
118
  /**
@@ -215,6 +218,9 @@ export interface ConceptDescriptor {
215
218
  /** Filename date-prefix granularity for a dated concept; resolved by `normalizeConcepts`. */
216
219
  datePrefix: DatePrefix;
217
220
  fields: FrontmatterField[];
221
+ /** Frontmatter keys the index copies onto each summary's `fields` record. `normalizeConcepts`
222
+ * resolves it to `[]` when a concept omits `summaryFields`. */
223
+ summaryFields: string[];
218
224
  validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
219
225
  }
220
226
 
@@ -3,7 +3,7 @@
3
3
  // validator stays thin (engine-fat rule). Saving runs the concept's validator on the
4
4
  // server before any commit; invalid input bounces to the form (spec §7.4).
5
5
  import type { FrontmatterField, ValidationResult } from './types.js';
6
- import { dateInputValue } from './frontmatter.js';
6
+ import { dateInputValue, isCalendarDate } from './frontmatter.js';
7
7
 
8
8
  /**
9
9
  * Validate raw frontmatter against a field list. Required text and date fields must be
@@ -34,12 +34,17 @@ export function validateFields(
34
34
  case 'freetags': {
35
35
  const list = Array.isArray(value) ? value.map(String) : [];
36
36
  if (field.required && list.length === 0) errors[field.name] = `${field.label} is required`;
37
+ else if (field.type === 'tags') {
38
+ const unknown = list.find((tag) => !field.options.includes(tag));
39
+ if (unknown !== undefined) errors[field.name] = `${field.label} contains an unknown value: ${unknown}`;
40
+ }
37
41
  if (list.length > 0) data[field.name] = list;
38
42
  break;
39
43
  }
40
44
  case 'date': {
41
45
  const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
42
46
  if (field.required && text === '') errors[field.name] = `${field.label} is required`;
47
+ else if (text !== '' && !isCalendarDate(text)) errors[field.name] = `${field.label} must be a valid date (YYYY-MM-DD)`;
43
48
  if (text !== '') data[field.name] = text;
44
49
  break;
45
50
  }
@@ -16,6 +16,9 @@ export interface RawFile {
16
16
 
17
17
  /** The cheap, plain-data view of one entry, for lists, feeds, and the sitemap. */
18
18
  export interface ContentSummary {
19
+ /** The descriptor id this entry belongs to, e.g. "posts". Lets a list or page branch per
20
+ * concept without re-deriving it from a proxy like `entry.date`. */
21
+ concept: string;
19
22
  id: string;
20
23
  slug: string;
21
24
  permalink: string;
@@ -26,6 +29,10 @@ export interface ContentSummary {
26
29
  excerpt: string;
27
30
  wordCount: number;
28
31
  draft: boolean;
32
+ /** The frontmatter keys the descriptor nominated via `summaryFields`, read off the validated,
33
+ * normalized frontmatter. Held in a separate record so a nominated key cannot collide with a
34
+ * typed summary field. Empty when the concept declares no `summaryFields`. */
35
+ fields: Record<string, unknown>;
29
36
  }
30
37
 
31
38
  /** The detail view: a summary plus the frontmatter and the body to render. The frontmatter
@@ -98,7 +105,12 @@ export function createContentIndex<F = Record<string, unknown>>(
98
105
  problems.push({ id, draft, errors: result.errors });
99
106
  continue;
100
107
  }
108
+ const summaryFieldValues: Record<string, unknown> = {};
109
+ for (const key of descriptor.summaryFields) {
110
+ if (key in result.data) summaryFieldValues[key] = result.data[key];
111
+ }
101
112
  entries.push({
113
+ concept: descriptor.id,
102
114
  id,
103
115
  slug,
104
116
  permalink: permalink(descriptor, { id, slug, date }),
@@ -109,6 +121,7 @@ export function createContentIndex<F = Record<string, unknown>>(
109
121
  excerpt: deriveExcerpt(body, { description: asString(raw.description) }),
110
122
  wordCount: wordCount(body),
111
123
  draft,
124
+ fields: summaryFieldValues,
112
125
  frontmatter: result.data as F,
113
126
  body,
114
127
  });
@@ -0,0 +1,4 @@
1
+ // cairn-cms: the delivery head component entry (@glw907/cairn-cms/delivery/head). CairnHead lives
2
+ // behind its own export so importing a delivery data helper from /delivery never pulls a .svelte
3
+ // module into the graph. A node-environment data import then needs no Svelte plugin.
4
+ export { default as CairnHead } from './CairnHead.svelte';
@@ -34,4 +34,3 @@ export type {
34
34
  TagIndexData,
35
35
  EntryData,
36
36
  } from '../sveltekit/public-routes.js';
37
- export { default as CairnHead } from './CairnHead.svelte';
@@ -5,6 +5,7 @@
5
5
  // the build (the backstop). The admin preview uses manifestLinkResolver instead.
6
6
  import { siteDescriptors } from './site-descriptors.js';
7
7
  import { fromGlob } from './content-index.js';
8
+ import { parseMarkdown } from '../content/frontmatter.js';
8
9
  import { emptyManifest, manifestEntryFromFile } from '../content/manifest.js';
9
10
  import type { Manifest } from '../content/manifest.js';
10
11
  import type { LinkResolve } from '../content/links.js';
@@ -21,6 +22,11 @@ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: Si
21
22
  for (const descriptor of siteDescriptors(adapter, config)) {
22
23
  const record = globRecord[descriptor.id] ?? {};
23
24
  for (const file of fromGlob(record)) {
25
+ // Validate the same way createContentIndex does, so the manifest and the site index agree on
26
+ // which entries exist. A validation failure is excluded from both; otherwise the preview would
27
+ // resolve a link the build then rejects as a missing target.
28
+ const { frontmatter, body } = parseMarkdown(file.raw);
29
+ if (!descriptor.validate(frontmatter, body).ok) continue;
24
30
  manifest.entries.push(manifestEntryFromFile(descriptor, file));
25
31
  }
26
32
  }
@@ -216,7 +216,14 @@ export async function commitFiles(
216
216
  headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
217
217
  body: JSON.stringify({ base_tree: baseTree, tree }),
218
218
  });
219
- if (!treeRes.ok) throw new Error(`GitHub tree create failed: ${treeRes.status} ${await treeRes.text()}`);
219
+ if (!treeRes.ok) {
220
+ // A 422 means an entry is unprocessable against the base tree, which a delete of an
221
+ // already-removed path produces (a concurrent delete or rename got there first). Treat it as
222
+ // the same non-fast-forward conflict the ref PATCH surfaces, so the caller fails safe with the
223
+ // reload-and-retry path instead of a raw 500.
224
+ if (treeRes.status === 422) throw new CommitConflictError(`${repo.branch} (tree create)`);
225
+ throw new Error(`GitHub tree create failed: ${treeRes.status} ${await treeRes.text()}`);
226
+ }
220
227
  const newTree = ((await treeRes.json()) as { sha: string }).sha;
221
228
 
222
229
  const commitRes = await fetch(gitUrl(repo, 'commits'), {
package/src/lib/index.ts CHANGED
@@ -52,7 +52,7 @@ export type { DatePrefix } from './content/ids.js';
52
52
  // Internal-link token and the committed content manifest (content-graph design). The corpus
53
53
  // builder and the request-time resolver ship from the delivery entry; this surface is the
54
54
  // grammar, the manifest operations, and their types a migrating site adopts.
55
- export { parseCairnToken, extractCairnLinks } from './content/links.js';
55
+ export { parseCairnToken, extractCairnLinks, formatCairnToken, escapeLinkText } from './content/links.js';
56
56
  export type { CairnRef, LinkResolve } from './content/links.js';
57
57
  export {
58
58
  serializeManifest,
@@ -63,8 +63,9 @@ export {
63
63
  removeEntry,
64
64
  manifestEntryFromFile,
65
65
  manifestLinkResolver,
66
+ inboundLinks,
66
67
  } from './content/manifest.js';
67
- export type { Manifest, ManifestEntry, LinkTarget } from './content/manifest.js';
68
+ export type { Manifest, ManifestEntry, LinkTarget, InboundLink } from './content/manifest.js';
68
69
  // Render engine (Plan 04): generic directive pipeline; sites own the component registry.
69
70
  export { defineRegistry, emptyValues } from './render/registry.js';
70
71
  export type {
@@ -91,6 +92,7 @@ export {
91
92
  strProp,
92
93
  iconSpan,
93
94
  cardShell,
95
+ headRow,
94
96
  markFirstList,
95
97
  } from './render/rehype-dispatch.js';
96
98
  export type { MakeIcon } from './render/rehype-dispatch.js';
@@ -154,3 +156,9 @@ export { readSeoFields, resolveImageUrl } from './delivery/seo-fields.js';
154
156
  export type { SeoFields } from './delivery/seo-fields.js';
155
157
  export { paginate } from './delivery/paginate.js';
156
158
  export type { Page } from './delivery/paginate.js';
159
+ // Root superset of the delivery route surface: a wrong guess from root for a route loader or a
160
+ // response helper now resolves. The CairnHead component stays out of root so the root barrel stays
161
+ // node-importable for the unit suite; it resolves from @glw907/cairn-cms/delivery/head.
162
+ export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './delivery/responses.js';
163
+ export { createPublicRoutes } from './sveltekit/public-routes.js';
164
+ export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData } from './sveltekit/public-routes.js';