@glw907/cairn-cms 0.7.0 → 0.9.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.
- package/dist/components/ConceptList.svelte +8 -4
- package/dist/components/ConceptList.svelte.d.ts.map +1 -1
- package/dist/components/EditPage.svelte +4 -6
- package/dist/components/EditPage.svelte.d.ts +1 -3
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/EditorToolbar.svelte +61 -0
- package/dist/components/EditorToolbar.svelte.d.ts +15 -0
- package/dist/components/EditorToolbar.svelte.d.ts.map +1 -0
- package/dist/components/MarkdownEditor.svelte +96 -57
- package/dist/components/MarkdownEditor.svelte.d.ts +5 -6
- package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -1
- package/dist/components/markdown-format.d.ts +13 -0
- package/dist/components/markdown-format.d.ts.map +1 -0
- package/dist/components/markdown-format.js +23 -0
- package/dist/content/compose.d.ts +2 -2
- package/dist/content/compose.d.ts.map +1 -1
- package/dist/content/compose.js +2 -2
- package/dist/content/concepts.d.ts +7 -6
- package/dist/content/concepts.d.ts.map +1 -1
- package/dist/content/concepts.js +9 -6
- package/dist/content/ids.d.ts +14 -0
- package/dist/content/ids.d.ts.map +1 -1
- package/dist/content/ids.js +40 -0
- package/dist/content/permalink.d.ts +1 -0
- package/dist/content/permalink.d.ts.map +1 -1
- package/dist/content/permalink.js +1 -1
- package/dist/content/types.d.ts +12 -6
- package/dist/content/types.d.ts.map +1 -1
- package/dist/delivery/content-index.d.ts +1 -0
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +4 -2
- package/dist/delivery/site-index.d.ts +28 -0
- package/dist/delivery/site-index.d.ts.map +1 -0
- package/dist/delivery/site-index.js +38 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/nav/site-config.d.ts +5 -0
- package/dist/nav/site-config.d.ts.map +1 -1
- package/dist/nav/site-config.js +4 -0
- package/dist/render/pipeline.d.ts +1 -1
- package/dist/render/pipeline.js +1 -1
- package/dist/render/sanitize.js +2 -2
- package/dist/sveltekit/content-routes.d.ts.map +1 -1
- package/dist/sveltekit/content-routes.js +18 -8
- package/dist/sveltekit/public-routes.d.ts +11 -12
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +36 -35
- package/package.json +7 -3
- package/src/lib/components/ConceptList.svelte +8 -4
- package/src/lib/components/EditPage.svelte +4 -6
- package/src/lib/components/EditorToolbar.svelte +61 -0
- package/src/lib/components/MarkdownEditor.svelte +96 -57
- package/src/lib/components/markdown-format.ts +39 -0
- package/src/lib/content/compose.ts +3 -2
- package/src/lib/content/concepts.ts +10 -6
- package/src/lib/content/ids.ts +44 -0
- package/src/lib/content/permalink.ts +2 -2
- package/src/lib/content/types.ts +13 -6
- package/src/lib/delivery/content-index.ts +5 -2
- package/src/lib/delivery/site-index.ts +68 -0
- package/src/lib/index.ts +13 -1
- package/src/lib/nav/site-config.ts +8 -0
- package/src/lib/render/pipeline.ts +1 -1
- package/src/lib/render/sanitize.ts +2 -2
- package/src/lib/sveltekit/content-routes.ts +17 -7
- package/src/lib/sveltekit/public-routes.ts +38 -36
|
@@ -17,9 +17,14 @@ plus a new-entry form. The slug auto-derives from the title until the author edi
|
|
|
17
17
|
let title = $state('');
|
|
18
18
|
let slug = $state('');
|
|
19
19
|
let slugEdited = $state(false);
|
|
20
|
+
// Default the date client-side so the SSR pass and hydration agree across UTC midnight.
|
|
21
|
+
let dateDefault = $state('');
|
|
22
|
+
$effect(() => {
|
|
23
|
+
dateDefault = new Date().toISOString().slice(0, 10);
|
|
24
|
+
});
|
|
20
25
|
|
|
21
26
|
const derivedSlug = $derived(slugEdited ? slug : slugify(title));
|
|
22
|
-
const slugPlaceholder = $derived(data.dated ? '
|
|
27
|
+
const slugPlaceholder = $derived(data.dated ? 'my-entry' : 'about-us');
|
|
23
28
|
</script>
|
|
24
29
|
|
|
25
30
|
<header class="mb-4 flex items-center justify-between">
|
|
@@ -58,14 +63,13 @@ plus a new-entry form. The slug auto-derives from the title until the author edi
|
|
|
58
63
|
<h2 class="text-sm font-semibold">New entry</h2>
|
|
59
64
|
<label class="flex flex-col gap-1">
|
|
60
65
|
<span class="text-sm font-medium">Title</span>
|
|
61
|
-
<input class="input" name="title"
|
|
66
|
+
<input class="input" name="title" bind:value={title} required />
|
|
62
67
|
</label>
|
|
63
68
|
<label class="flex flex-col gap-1">
|
|
64
69
|
<span class="text-sm font-medium">Slug</span>
|
|
65
70
|
<input
|
|
66
71
|
class="input"
|
|
67
72
|
name="slug"
|
|
68
|
-
aria-label="Slug"
|
|
69
73
|
placeholder={slugPlaceholder}
|
|
70
74
|
value={derivedSlug}
|
|
71
75
|
oninput={(e) => { slugEdited = true; slug = e.currentTarget.value; }}
|
|
@@ -74,7 +78,7 @@ plus a new-entry form. The slug auto-derives from the title until the author edi
|
|
|
74
78
|
{#if data.dated}
|
|
75
79
|
<label class="flex flex-col gap-1">
|
|
76
80
|
<span class="text-sm font-medium">Date</span>
|
|
77
|
-
<input class="input" type="date" name="date"
|
|
81
|
+
<input class="input" type="date" name="date" value={dateDefault} />
|
|
78
82
|
</label>
|
|
79
83
|
{/if}
|
|
80
84
|
<button type="submit" class="btn btn-primary self-start">Create</button>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
3
|
+
The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
|
|
4
4
|
markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
|
|
5
5
|
`?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
|
|
6
6
|
-->
|
|
@@ -18,13 +18,11 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
18
18
|
data: EditData & { siteName: string };
|
|
19
19
|
/** The site's component registry, for the insert palette. */
|
|
20
20
|
registry?: ComponentRegistry;
|
|
21
|
-
/** Carta preview plugins from the adapter, for the design-accurate preview. */
|
|
22
|
-
preview?: unknown[];
|
|
23
21
|
/** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
|
|
24
22
|
render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
let { data, registry,
|
|
25
|
+
let { data, registry, render }: Props = $props();
|
|
28
26
|
|
|
29
27
|
// `body` is local editor state seeded once from the prop; it diverges as the user types.
|
|
30
28
|
// untrack() captures the initial value without subscribing to future prop changes.
|
|
@@ -46,7 +44,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
// Render the design-accurate preview as the body changes, debounced, and sanitize before the DOM.
|
|
49
|
-
// The sanitize is the one barrier between editor-authored markdown and the page (
|
|
47
|
+
// The sanitize is the one barrier between editor-authored markdown and the page (the editor is unsanitized).
|
|
50
48
|
// previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
|
|
51
49
|
// async render call resolves after a newer one has started, the stale result is discarded.
|
|
52
50
|
let previewRun = 0;
|
|
@@ -103,7 +101,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
103
101
|
|
|
104
102
|
<div class="lg:order-1">
|
|
105
103
|
<div class="rounded-box border border-base-300 bg-base-100 overflow-hidden">
|
|
106
|
-
<MarkdownEditor bind:value={body} name="body"
|
|
104
|
+
<MarkdownEditor bind:value={body} name="body" registerInsert={(fn) => (insert = fn)} />
|
|
107
105
|
</div>
|
|
108
106
|
{#if showPreview}
|
|
109
107
|
<section
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The editor's formatting toolbar: bold, italic, heading, link, bulleted list, quote, code. Each button
|
|
4
|
+
asks the host to apply a markdown transform to the current selection. Carta supplied this row before;
|
|
5
|
+
cairn owns it now so the edit surface stays swappable. The glyphs are stroke SVG icons in the admin's
|
|
6
|
+
house style (24x24 viewBox, `currentColor`, round caps), so the row matches the rest of the surface.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { FormatKind } from './markdown-format.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** Apply a markdown transform to the editor's current selection. */
|
|
13
|
+
format: (kind: FormatKind) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let { format }: Props = $props();
|
|
17
|
+
|
|
18
|
+
// Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
|
|
19
|
+
// markup stays declarative (no per-icon raw html). Paths follow the house outline style.
|
|
20
|
+
const buttons: { kind: FormatKind; label: string; paths: string[] }[] = [
|
|
21
|
+
{ kind: 'bold', label: 'Bold', paths: ['M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8'] },
|
|
22
|
+
{ kind: 'italic', label: 'Italic', paths: ['M19 4h-9', 'M14 20H5', 'M15 4 9 20'] },
|
|
23
|
+
{ kind: 'heading', label: 'Heading', paths: ['M6 4v16', 'M18 4v16', 'M6 12h12'] },
|
|
24
|
+
{
|
|
25
|
+
kind: 'link',
|
|
26
|
+
label: 'Link',
|
|
27
|
+
paths: [
|
|
28
|
+
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
29
|
+
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71',
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{ kind: 'ul', label: 'Bulleted list', paths: ['M8 6h13', 'M8 12h13', 'M8 18h13', 'M3 6h.01', 'M3 12h.01', 'M3 18h.01'] },
|
|
33
|
+
{
|
|
34
|
+
kind: 'quote',
|
|
35
|
+
label: 'Quote',
|
|
36
|
+
paths: [
|
|
37
|
+
'M16 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
|
|
38
|
+
'M5 3a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2 1 1 0 0 1 1 1v1a2 2 0 0 1-2 2 1 1 0 0 0-1 1 1 1 0 0 0 1 1 4 4 0 0 0 4-4V5a2 2 0 0 0-2-2z',
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{ kind: 'code', label: 'Code', paths: ['M16 18l6-6-6-6', 'M8 6l-6 6 6 6'] },
|
|
42
|
+
];
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<div class="border-base-300 bg-base-200 flex gap-1 border-b p-1" role="toolbar" aria-label="Formatting">
|
|
46
|
+
{#each buttons as button (button.kind)}
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
class="btn btn-ghost btn-sm btn-square"
|
|
50
|
+
aria-label={button.label}
|
|
51
|
+
title={button.label}
|
|
52
|
+
onclick={() => format(button.kind)}
|
|
53
|
+
>
|
|
54
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
55
|
+
{#each button.paths as d (d)}
|
|
56
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d={d} />
|
|
57
|
+
{/each}
|
|
58
|
+
</svg>
|
|
59
|
+
</button>
|
|
60
|
+
{/each}
|
|
61
|
+
</div>
|
|
@@ -1,81 +1,120 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over
|
|
4
|
-
and a cursor-insert callback.
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
|
|
4
|
+
value and a cursor-insert callback. CodeMirror is client-only, so it mounts after the component does
|
|
5
|
+
through a dynamic import; until then a plain textarea carries the value so the form still submits, and
|
|
6
|
+
the hidden field mirrors the value throughout. The edit surface owns its toolbar; the design-accurate
|
|
7
|
+
preview lives in EditPage through the adapter's render. Swapping the editor stays a one-file change.
|
|
7
8
|
-->
|
|
8
9
|
<script lang="ts">
|
|
9
|
-
import { onMount } from 'svelte';
|
|
10
|
+
import { onMount, onDestroy } from 'svelte';
|
|
11
|
+
import EditorToolbar from './EditorToolbar.svelte';
|
|
12
|
+
import { applyMarkdownFormat, type FormatKind } from './markdown-format.js';
|
|
10
13
|
|
|
11
14
|
interface Props {
|
|
12
15
|
/** The markdown source; bindable so the parent reads edits back. */
|
|
13
16
|
value: string;
|
|
14
17
|
/** The hidden field name the value is mirrored to for form submit. */
|
|
15
18
|
name: string;
|
|
16
|
-
/** Carta extensions from the adapter, for the design-accurate preview. */
|
|
17
|
-
plugins?: unknown[];
|
|
18
19
|
/** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
|
|
19
20
|
registerInsert?: (insert: (text: string) => void) => void;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
let { value = $bindable(), name,
|
|
23
|
-
|
|
24
|
-
// Local structural type for the Carta editing surface this seam uses. carta-md re-exports its
|
|
25
|
-
// Svelte components from the package entry, so its `Carta` class is not reachable as a named
|
|
26
|
-
// export under NodeNext; a structural type stays compatible without naming it (the shape
|
|
27
|
-
// legacy/src/lib/editor.ts relied on, verified against carta-md@4.11).
|
|
28
|
-
interface CartaInput {
|
|
29
|
-
getSelection(): { start: number };
|
|
30
|
-
insertAt(position: number, text: string): void;
|
|
31
|
-
update(): boolean;
|
|
32
|
-
}
|
|
33
|
-
interface CartaLike {
|
|
34
|
-
input?: CartaInput;
|
|
35
|
-
}
|
|
23
|
+
let { value = $bindable(), name, registerInsert }: Props = $props();
|
|
36
24
|
|
|
25
|
+
let host = $state<HTMLDivElement | null>(null);
|
|
37
26
|
let mounted = $state(false);
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
let Editor = $state<(typeof import('carta-md'))['MarkdownEditor'] | null>(null);
|
|
43
|
-
let carta = $state<CartaLike | null>(null);
|
|
27
|
+
// The CodeMirror view, untyped at the runtime boundary because @codemirror/* loads only in the
|
|
28
|
+
// browser. The type-only `import(...)` annotation is erased; the value import is dynamic in onMount,
|
|
29
|
+
// so the server bundle never pulls CodeMirror (guarded by the editor-boundary test).
|
|
30
|
+
let view: import('@codemirror/view').EditorView | null = null;
|
|
44
31
|
|
|
45
32
|
onMount(async () => {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
33
|
+
const viewMod = await import('@codemirror/view');
|
|
34
|
+
const stateMod = await import('@codemirror/state');
|
|
35
|
+
const markdownMod = await import('@codemirror/lang-markdown');
|
|
36
|
+
const commandsMod = await import('@codemirror/commands');
|
|
37
|
+
const languageMod = await import('@codemirror/language');
|
|
38
|
+
|
|
39
|
+
if (!host) return;
|
|
40
|
+
|
|
41
|
+
const { EditorView, keymap } = viewMod;
|
|
42
|
+
const theme = EditorView.theme(
|
|
43
|
+
{
|
|
44
|
+
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '0.875rem' },
|
|
45
|
+
'.cm-content': { fontFamily: 'ui-monospace, monospace', padding: '0.75rem', lineHeight: '1.7' },
|
|
46
|
+
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
|
|
47
|
+
'&.cm-focused': { outline: '2px solid var(--color-primary)', outlineOffset: '-2px' },
|
|
48
|
+
'.cm-line': { padding: '0' },
|
|
49
|
+
},
|
|
50
|
+
{ dark: false },
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
view = new EditorView({
|
|
54
|
+
parent: host,
|
|
55
|
+
state: stateMod.EditorState.create({
|
|
56
|
+
doc: value,
|
|
57
|
+
extensions: [
|
|
58
|
+
commandsMod.history(),
|
|
59
|
+
keymap.of([...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
|
|
60
|
+
markdownMod.markdown(),
|
|
61
|
+
EditorView.lineWrapping,
|
|
62
|
+
languageMod.syntaxHighlighting(languageMod.defaultHighlightStyle, { fallback: true }),
|
|
63
|
+
theme,
|
|
64
|
+
EditorView.updateListener.of((update) => {
|
|
65
|
+
if (update.docChanged) value = update.state.doc.toString();
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
}),
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
registerInsert?.(insertAtCursor);
|
|
70
72
|
mounted = true;
|
|
71
73
|
});
|
|
74
|
+
|
|
75
|
+
onDestroy(() => view?.destroy());
|
|
76
|
+
|
|
77
|
+
// Reconcile an externally reassigned `value` into the mounted editor. A no-op until `view` exists,
|
|
78
|
+
// and the doc-equality guard ignores the updateListener's own writes so the two never feed back.
|
|
79
|
+
$effect(() => {
|
|
80
|
+
const incoming = value;
|
|
81
|
+
if (!view) return;
|
|
82
|
+
const current = view.state.doc.toString();
|
|
83
|
+
if (incoming === current) return;
|
|
84
|
+
view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
function insertAtCursor(text: string) {
|
|
88
|
+
if (!view) {
|
|
89
|
+
value = value ? `${value}\n\n${text}` : text;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const pos = view.state.selection.main.head;
|
|
93
|
+
const prefix = pos > 0 ? '\n\n' : '';
|
|
94
|
+
const insert = `${prefix}${text}`;
|
|
95
|
+
view.dispatch({ changes: { from: pos, insert }, selection: { anchor: pos + insert.length } });
|
|
96
|
+
view.focus();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function applyFormat(kind: FormatKind) {
|
|
100
|
+
if (!view) return;
|
|
101
|
+
const { from, to } = view.state.selection.main;
|
|
102
|
+
const doc = view.state.doc.toString();
|
|
103
|
+
const next = applyMarkdownFormat(doc, from, to, kind);
|
|
104
|
+
view.dispatch({
|
|
105
|
+
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
106
|
+
selection: { anchor: next.from, head: next.to },
|
|
107
|
+
});
|
|
108
|
+
view.focus();
|
|
109
|
+
}
|
|
72
110
|
</script>
|
|
73
111
|
|
|
74
|
-
<input type="hidden" {name}
|
|
112
|
+
<input type="hidden" {name} {value} />
|
|
75
113
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
<
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
{/if}
|
|
114
|
+
<div class="border-base-300 overflow-hidden rounded-box border">
|
|
115
|
+
<EditorToolbar format={applyFormat} />
|
|
116
|
+
<div bind:this={host}></div>
|
|
117
|
+
{#if !mounted}
|
|
118
|
+
<textarea class="textarea min-h-64 w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
|
|
119
|
+
{/if}
|
|
120
|
+
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure markdown selection transforms for the editor toolbar. Each call maps a document and a
|
|
3
|
+
* selection range to a new document and a new selection, with no DOM. The MarkdownEditor view
|
|
4
|
+
* dispatches the result; keeping the logic here lets it unit-test without a browser.
|
|
5
|
+
*/
|
|
6
|
+
export type FormatKind = 'bold' | 'italic' | 'code' | 'heading' | 'quote' | 'ul' | 'link';
|
|
7
|
+
|
|
8
|
+
export interface FormatResult {
|
|
9
|
+
doc: string;
|
|
10
|
+
from: number;
|
|
11
|
+
to: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const WRAP: Record<'bold' | 'italic' | 'code', string> = { bold: '**', italic: '_', code: '`' };
|
|
15
|
+
const LINE_PREFIX: Record<'heading' | 'quote' | 'ul', string> = { heading: '# ', quote: '> ', ul: '- ' };
|
|
16
|
+
|
|
17
|
+
export function applyMarkdownFormat(doc: string, from: number, to: number, kind: FormatKind): FormatResult {
|
|
18
|
+
if (kind === 'bold' || kind === 'italic' || kind === 'code') {
|
|
19
|
+
const marker = WRAP[kind];
|
|
20
|
+
const next = doc.slice(0, from) + marker + doc.slice(from, to) + marker + doc.slice(to);
|
|
21
|
+
return { doc: next, from: from + marker.length, to: to + marker.length };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (kind === 'link') {
|
|
25
|
+
const text = doc.slice(from, to);
|
|
26
|
+
const placeholder = 'url';
|
|
27
|
+
const lead = `[${text}](`; // everything before the url placeholder
|
|
28
|
+
const inserted = `${lead}${placeholder})`;
|
|
29
|
+
const urlStart = from + lead.length;
|
|
30
|
+
return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: urlStart, to: urlStart + placeholder.length };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const prefix = LINE_PREFIX[kind];
|
|
34
|
+
const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
|
|
35
|
+
const region = doc.slice(lineStart, to);
|
|
36
|
+
const prefixed = region.replace(/^/gm, prefix);
|
|
37
|
+
const added = prefixed.length - region.length;
|
|
38
|
+
return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
|
|
39
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the same way and contributes the same kinds of things: nav entries, route logic,
|
|
4
4
|
// concepts, components, field types, and save hooks. Shaped now so the extension contract
|
|
5
5
|
// is additive later.
|
|
6
|
-
import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, FieldTypeDef } from './types.js';
|
|
6
|
+
import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, ConceptUrlPolicy, FieldTypeDef } from './types.js';
|
|
7
7
|
import { normalizeConcepts } from './concepts.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -13,6 +13,7 @@ import { normalizeConcepts } from './concepts.js';
|
|
|
13
13
|
export function composeRuntime(
|
|
14
14
|
adapter: CairnAdapter,
|
|
15
15
|
extensions: CairnExtension[] = [],
|
|
16
|
+
urlPolicy: Record<string, ConceptUrlPolicy | undefined> = {},
|
|
16
17
|
): CairnRuntime {
|
|
17
18
|
const content: Record<string, ConceptConfig | undefined> = { ...adapter.content };
|
|
18
19
|
const adminPanels: AdminPanel[] = [];
|
|
@@ -26,7 +27,7 @@ export function composeRuntime(
|
|
|
26
27
|
}
|
|
27
28
|
return {
|
|
28
29
|
siteName: adapter.siteName,
|
|
29
|
-
concepts: normalizeConcepts(content),
|
|
30
|
+
concepts: normalizeConcepts(content, urlPolicy),
|
|
30
31
|
backend: adapter.backend,
|
|
31
32
|
sender: adapter.sender,
|
|
32
33
|
render: adapter.render,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (id, label, directory, concept-fixed routing, fields, validator) the admin reads. A
|
|
4
4
|
// future Fragments concept attaches by adding one key under `content` and one routing
|
|
5
5
|
// entry, with no reshape here.
|
|
6
|
-
import type { ConceptConfig, ConceptDescriptor, RoutingRule } from './types.js';
|
|
6
|
+
import type { ConceptConfig, ConceptDescriptor, ConceptUrlPolicy, RoutingRule } from './types.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Concept-fixed routing, keyed by concept id (spec §7.2). Posts are dated feed entries;
|
|
@@ -29,24 +29,28 @@ function defaultPermalink(id: string): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Normalize an adapter's declared concepts into uniform descriptors (seam 1).
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* Normalize an adapter's declared concepts into uniform descriptors (seam 1). URL policy
|
|
33
|
+
* (`permalink`, `datePrefix`) comes from the YAML site-config, passed here as `urlPolicy` keyed by
|
|
34
|
+
* concept id; each value defaults when the YAML omits it (`/:slug` for Pages, `/<id>/:slug`
|
|
35
|
+
* otherwise; `datePrefix` defaults to `day`). `routing` is injectable so a contract test can prove
|
|
36
|
+
* a new concept attaches additively; production passes the default `CONCEPT_ROUTING`.
|
|
36
37
|
*/
|
|
37
38
|
export function normalizeConcepts(
|
|
38
39
|
content: Record<string, ConceptConfig | undefined>,
|
|
40
|
+
urlPolicy: Record<string, ConceptUrlPolicy | undefined> = {},
|
|
39
41
|
routing: Readonly<Record<string, RoutingRule>> = CONCEPT_ROUTING,
|
|
40
42
|
): ConceptDescriptor[] {
|
|
41
43
|
const descriptors: ConceptDescriptor[] = [];
|
|
42
44
|
for (const [id, config] of Object.entries(content)) {
|
|
43
45
|
if (!config) continue;
|
|
46
|
+
const policy = urlPolicy[id] ?? {};
|
|
44
47
|
descriptors.push({
|
|
45
48
|
id,
|
|
46
49
|
label: config.label ?? defaultLabel(id),
|
|
47
50
|
dir: config.dir,
|
|
48
51
|
routing: routing[id] ?? DEFAULT_ROUTING,
|
|
49
|
-
permalink:
|
|
52
|
+
permalink: policy.permalink ?? defaultPermalink(id),
|
|
53
|
+
datePrefix: policy.datePrefix ?? 'day',
|
|
50
54
|
fields: config.fields,
|
|
51
55
|
validate: config.validate,
|
|
52
56
|
});
|
package/src/lib/content/ids.ts
CHANGED
|
@@ -36,3 +36,47 @@ export function slugify(title: string): string {
|
|
|
36
36
|
.replace(/[^a-z0-9]+/g, '-')
|
|
37
37
|
.replace(/^-+|-+$/g, '');
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Filename date-prefix granularity for a dated concept: the leading `YYYY[-MM[-DD]]-` on the stem. */
|
|
41
|
+
export type DatePrefix = 'year' | 'month' | 'day';
|
|
42
|
+
|
|
43
|
+
/** The leading date-prefix shape for each granularity. */
|
|
44
|
+
const DATE_PREFIX_RE: Record<DatePrefix, RegExp> = {
|
|
45
|
+
year: /^\d{4}-/,
|
|
46
|
+
month: /^\d{4}-\d{2}-/,
|
|
47
|
+
day: /^\d{4}-\d{2}-\d{2}-/,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The URL slug for an id. A dated concept passes its `datePrefix` and the leading date prefix is
|
|
52
|
+
* stripped when present; a non-dated concept passes `null` and the id is returned verbatim. Only
|
|
53
|
+
* the leading prefix is removed, so a year-like tail (a post titled "2024 Recap") stays in the slug.
|
|
54
|
+
*/
|
|
55
|
+
export function slugFromId(id: string, datePrefix: DatePrefix | null): string {
|
|
56
|
+
if (!datePrefix) return id;
|
|
57
|
+
return id.replace(DATE_PREFIX_RE[datePrefix], '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compose a dated entry's id from a `YYYY-MM-DD` date, a date-free slug, and the concept's
|
|
62
|
+
* granularity: the date truncated to the granularity, a hyphen, then the slug. Throws on a
|
|
63
|
+
* malformed date so a bad create fails before touching git.
|
|
64
|
+
*/
|
|
65
|
+
export function composeDatedId(date: string, slug: string, datePrefix: DatePrefix): string {
|
|
66
|
+
const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
67
|
+
if (!m) throw new Error(`composeDatedId: malformed date "${date}"`);
|
|
68
|
+
const [, year, month, day] = m;
|
|
69
|
+
let prefix: string;
|
|
70
|
+
switch (datePrefix) {
|
|
71
|
+
case 'year':
|
|
72
|
+
prefix = year;
|
|
73
|
+
break;
|
|
74
|
+
case 'month':
|
|
75
|
+
prefix = `${year}-${month}`;
|
|
76
|
+
break;
|
|
77
|
+
case 'day':
|
|
78
|
+
prefix = `${year}-${month}-${day}`;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return `${prefix}-${slug}`;
|
|
82
|
+
}
|
|
@@ -20,10 +20,10 @@ function dateParts(date?: string): { year: string; month: string; day: string }
|
|
|
20
20
|
*/
|
|
21
21
|
export function permalink(
|
|
22
22
|
descriptor: ConceptDescriptor,
|
|
23
|
-
entry: { id: string; date?: string },
|
|
23
|
+
entry: { id: string; slug: string; date?: string },
|
|
24
24
|
): string {
|
|
25
25
|
return descriptor.permalink.replace(/:(\w+)/g, (_match, token: string) => {
|
|
26
|
-
if (token === 'slug') return entry.
|
|
26
|
+
if (token === 'slug') return entry.slug;
|
|
27
27
|
if (token === 'year' || token === 'month' || token === 'day') {
|
|
28
28
|
const parts = dateParts(entry.date);
|
|
29
29
|
if (!parts) {
|
package/src/lib/content/types.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// descriptors are plain data so a `load` function can hand them across the server-to-client
|
|
9
9
|
// boundary to the editor form.
|
|
10
10
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
11
|
+
import type { DatePrefix } from './ids.js';
|
|
11
12
|
|
|
12
13
|
/** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
|
|
13
14
|
interface FieldBase {
|
|
@@ -84,13 +85,17 @@ export interface ConceptConfig {
|
|
|
84
85
|
fields: FrontmatterField[];
|
|
85
86
|
/** Validate submitted frontmatter before any commit. */
|
|
86
87
|
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A concept's URL policy, set per concept in the YAML site-config (not the adapter). `permalink` is
|
|
92
|
+
* a `/`-prefixed pattern of literal segments and the tokens `:slug`, `:year`, `:month`, `:day`.
|
|
93
|
+
* `datePrefix` is the filename date-prefix granularity for a dated concept. Both default in
|
|
94
|
+
* `normalizeConcepts` when omitted.
|
|
95
|
+
*/
|
|
96
|
+
export interface ConceptUrlPolicy {
|
|
93
97
|
permalink?: string;
|
|
98
|
+
datePrefix?: DatePrefix;
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
/** The GitHub App backend a site reads from and commits to (spec §8). Plain data the GitHub engine (Plan 03) consumes. */
|
|
@@ -175,6 +180,8 @@ export interface ConceptDescriptor {
|
|
|
175
180
|
routing: RoutingRule;
|
|
176
181
|
/** The resolved permalink pattern, defaulted by `normalizeConcepts`. */
|
|
177
182
|
permalink: string;
|
|
183
|
+
/** Filename date-prefix granularity for a dated concept; resolved by `normalizeConcepts`. */
|
|
184
|
+
datePrefix: DatePrefix;
|
|
178
185
|
fields: FrontmatterField[];
|
|
179
186
|
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
180
187
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// returns cheap plain-data summaries plus an on-demand detail lookup. It is concept-generic:
|
|
4
4
|
// every operation reads the descriptor and its routing rule, never a hardcoded concept id.
|
|
5
5
|
import { parseMarkdown } from '../content/frontmatter.js';
|
|
6
|
-
import { idFromFilename } from '../content/ids.js';
|
|
6
|
+
import { idFromFilename, slugFromId } from '../content/ids.js';
|
|
7
7
|
import { permalink } from '../content/permalink.js';
|
|
8
8
|
import { deriveExcerpt, wordCount } from './excerpt.js';
|
|
9
9
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
@@ -17,6 +17,7 @@ export interface RawFile {
|
|
|
17
17
|
/** The cheap, plain-data view of one entry, for lists, feeds, and the sitemap. */
|
|
18
18
|
export interface ContentSummary {
|
|
19
19
|
id: string;
|
|
20
|
+
slug: string;
|
|
20
21
|
permalink: string;
|
|
21
22
|
title: string;
|
|
22
23
|
date?: string;
|
|
@@ -70,11 +71,13 @@ function asTags(value: unknown): string[] {
|
|
|
70
71
|
export function createContentIndex(files: RawFile[], descriptor: ConceptDescriptor): ContentIndex {
|
|
71
72
|
const entries: ContentEntry[] = files.map((file) => {
|
|
72
73
|
const id = idFromFilename(basename(file.path));
|
|
74
|
+
const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
|
|
73
75
|
const { frontmatter, body } = parseMarkdown(file.raw);
|
|
74
76
|
const date = asDate(frontmatter.date);
|
|
75
77
|
return {
|
|
76
78
|
id,
|
|
77
|
-
|
|
79
|
+
slug,
|
|
80
|
+
permalink: permalink(descriptor, { id, slug, date }),
|
|
78
81
|
title: asString(frontmatter.title) ?? id,
|
|
79
82
|
date,
|
|
80
83
|
updated: asDate(frontmatter.updated),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// cairn-cms: the site-level content index (dated-slug design). It unions every concept's
|
|
2
|
+
// per-concept index into one cross-concept resolver: a single byPermalink map a catch-all route
|
|
3
|
+
// matches a request path against, one entries() list the prerenderer walks, and the per-concept
|
|
4
|
+
// indexes for concept-scoped archive, tag, and feed loaders. A duplicate permalink throws at build.
|
|
5
|
+
import type { ConceptDescriptor } from '../content/types.js';
|
|
6
|
+
import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
|
|
7
|
+
|
|
8
|
+
/** One concept's descriptor paired with its built index. */
|
|
9
|
+
export interface ConceptIndex {
|
|
10
|
+
descriptor: ConceptDescriptor;
|
|
11
|
+
index: ContentIndex;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The cross-concept query surface a catch-all route and the sitemap read. */
|
|
15
|
+
export interface SiteIndex {
|
|
16
|
+
/** Resolve a request path (with or without a trailing slash) to its entry, or undefined. */
|
|
17
|
+
byPermalink(path: string): ContentEntry | undefined;
|
|
18
|
+
/** Newer/older neighbors within the entry's own concept, for prev/next links. */
|
|
19
|
+
adjacent(entry: ContentSummary): { newer?: ContentSummary; older?: ContentSummary };
|
|
20
|
+
/** Every entry's path across concepts, leading slash stripped, for SvelteKit `[...path]` prerender. */
|
|
21
|
+
entries(): { path: string }[];
|
|
22
|
+
/** One concept's index, for its archive, tag, and feed loaders. */
|
|
23
|
+
concept(id: string): ContentIndex | undefined;
|
|
24
|
+
/** Every non-draft summary across concepts, for the site-wide sitemap. */
|
|
25
|
+
all(): ContentSummary[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Strip a trailing slash from a path, keeping the root "/" intact. */
|
|
29
|
+
function normalizePath(path: string): string {
|
|
30
|
+
return path.length > 1 ? path.replace(/\/+$/, '') : path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Union per-concept indexes into a site-level resolver; throw on a duplicate permalink. */
|
|
34
|
+
export function createSiteIndex(concepts: ConceptIndex[]): SiteIndex {
|
|
35
|
+
const byPath = new Map<string, { index: ContentIndex; id: string }>();
|
|
36
|
+
const byId = new Map<string, ContentIndex>();
|
|
37
|
+
for (const { descriptor, index } of concepts) {
|
|
38
|
+
byId.set(descriptor.id, index);
|
|
39
|
+
for (const summary of index.all()) {
|
|
40
|
+
const existing = byPath.get(summary.permalink);
|
|
41
|
+
if (existing) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`site index: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
byPath.set(summary.permalink, { index, id: summary.id });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
byPermalink(path) {
|
|
51
|
+
const hit = byPath.get(normalizePath(path));
|
|
52
|
+
return hit ? hit.index.byId(hit.id) : undefined;
|
|
53
|
+
},
|
|
54
|
+
adjacent(entry) {
|
|
55
|
+
const hit = byPath.get(entry.permalink);
|
|
56
|
+
return hit ? hit.index.adjacent(entry.id) : {};
|
|
57
|
+
},
|
|
58
|
+
entries() {
|
|
59
|
+
return [...byPath.keys()].map((p) => ({ path: p.replace(/^\//, '') }));
|
|
60
|
+
},
|
|
61
|
+
concept(id) {
|
|
62
|
+
return byId.get(id);
|
|
63
|
+
},
|
|
64
|
+
all() {
|
|
65
|
+
return concepts.flatMap(({ index }) => index.all());
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|