@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.
Files changed (67) hide show
  1. package/dist/components/ConceptList.svelte +8 -4
  2. package/dist/components/ConceptList.svelte.d.ts.map +1 -1
  3. package/dist/components/EditPage.svelte +4 -6
  4. package/dist/components/EditPage.svelte.d.ts +1 -3
  5. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  6. package/dist/components/EditorToolbar.svelte +61 -0
  7. package/dist/components/EditorToolbar.svelte.d.ts +15 -0
  8. package/dist/components/EditorToolbar.svelte.d.ts.map +1 -0
  9. package/dist/components/MarkdownEditor.svelte +96 -57
  10. package/dist/components/MarkdownEditor.svelte.d.ts +5 -6
  11. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -1
  12. package/dist/components/markdown-format.d.ts +13 -0
  13. package/dist/components/markdown-format.d.ts.map +1 -0
  14. package/dist/components/markdown-format.js +23 -0
  15. package/dist/content/compose.d.ts +2 -2
  16. package/dist/content/compose.d.ts.map +1 -1
  17. package/dist/content/compose.js +2 -2
  18. package/dist/content/concepts.d.ts +7 -6
  19. package/dist/content/concepts.d.ts.map +1 -1
  20. package/dist/content/concepts.js +9 -6
  21. package/dist/content/ids.d.ts +14 -0
  22. package/dist/content/ids.d.ts.map +1 -1
  23. package/dist/content/ids.js +40 -0
  24. package/dist/content/permalink.d.ts +1 -0
  25. package/dist/content/permalink.d.ts.map +1 -1
  26. package/dist/content/permalink.js +1 -1
  27. package/dist/content/types.d.ts +12 -6
  28. package/dist/content/types.d.ts.map +1 -1
  29. package/dist/delivery/content-index.d.ts +1 -0
  30. package/dist/delivery/content-index.d.ts.map +1 -1
  31. package/dist/delivery/content-index.js +4 -2
  32. package/dist/delivery/site-index.d.ts +28 -0
  33. package/dist/delivery/site-index.d.ts.map +1 -0
  34. package/dist/delivery/site-index.js +38 -0
  35. package/dist/index.d.ts +6 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -2
  38. package/dist/nav/site-config.d.ts +5 -0
  39. package/dist/nav/site-config.d.ts.map +1 -1
  40. package/dist/nav/site-config.js +4 -0
  41. package/dist/render/pipeline.d.ts +1 -1
  42. package/dist/render/pipeline.js +1 -1
  43. package/dist/render/sanitize.js +2 -2
  44. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  45. package/dist/sveltekit/content-routes.js +18 -8
  46. package/dist/sveltekit/public-routes.d.ts +11 -12
  47. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  48. package/dist/sveltekit/public-routes.js +36 -35
  49. package/package.json +7 -3
  50. package/src/lib/components/ConceptList.svelte +8 -4
  51. package/src/lib/components/EditPage.svelte +4 -6
  52. package/src/lib/components/EditorToolbar.svelte +61 -0
  53. package/src/lib/components/MarkdownEditor.svelte +96 -57
  54. package/src/lib/components/markdown-format.ts +39 -0
  55. package/src/lib/content/compose.ts +3 -2
  56. package/src/lib/content/concepts.ts +10 -6
  57. package/src/lib/content/ids.ts +44 -0
  58. package/src/lib/content/permalink.ts +2 -2
  59. package/src/lib/content/types.ts +13 -6
  60. package/src/lib/delivery/content-index.ts +5 -2
  61. package/src/lib/delivery/site-index.ts +68 -0
  62. package/src/lib/index.ts +13 -1
  63. package/src/lib/nav/site-config.ts +8 -0
  64. package/src/lib/render/pipeline.ts +1 -1
  65. package/src/lib/render/sanitize.ts +2 -2
  66. package/src/lib/sveltekit/content-routes.ts +17 -7
  67. 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 ? '2026-05-my-entry' : 'about-us');
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" aria-label="Title" bind:value={title} required />
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" aria-label="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 +1 @@
1
- {"version":3,"file":"ConceptList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConceptList.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAG7D,UAAU,KAAK;IACb,qFAAqF;IACrF,IAAI,EAAE,QAAQ,CAAC;CAChB;AAsEH;;;GAGG;AACH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"ConceptList.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ConceptList.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAG7D,UAAU,KAAK;IACb,qFAAqF;IACrF,IAAI,EAAE,QAAQ,CAAC;CAChB;AA2EH;;;GAGG;AACH,QAAA,MAAM,WAAW,2CAAwC,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -1,6 +1,6 @@
1
1
  <!--
2
2
  @component
3
- The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the Carta
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, preview = [], render }: Props = $props();
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 (Carta is unsanitized).
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" plugins={preview} registerInsert={(fn) => (insert = fn)} />
104
+ <MarkdownEditor bind:value={body} name="body" registerInsert={(fn) => (insert = fn)} />
107
105
  </div>
108
106
  {#if showPreview}
109
107
  <section
@@ -7,15 +7,13 @@ interface Props {
7
7
  };
8
8
  /** The site's component registry, for the insert palette. */
9
9
  registry?: ComponentRegistry;
10
- /** Carta preview plugins from the adapter, for the design-accurate preview. */
11
- preview?: unknown[];
12
10
  /** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
13
11
  render?: (md: string, opts?: {
14
12
  stagger?: boolean;
15
13
  }) => string | Promise<string>;
16
14
  }
17
15
  /**
18
- * The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the Carta
16
+ * The differentiated editor: the per-concept frontmatter form (from `data.fields`) beside the
19
17
  * markdown editor and a live, design-accurate preview. The whole surface is one form posting to the
20
18
  * `?/save` action; the preview toggle persists per user in localStorage (spec §7.6).
21
19
  */
@@ -1 +1 @@
1
- {"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAK7D,UAAU,KAAK;IACb,gEAAgE;IAChE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,+EAA+E;IAC/E,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC;IACpB,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACjF;AAqJH;;;;GAIG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
1
+ {"version":3,"file":"EditPage.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditPage.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAK7D,UAAU,KAAK;IACb,gEAAgE;IAChE,IAAI,EAAE,QAAQ,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,6DAA6D;IAC7D,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,yFAAyF;IACzF,MAAM,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACjF;AAqJH;;;;GAIG;AACH,QAAA,MAAM,QAAQ,2CAAwC,CAAC;AACvD,KAAK,QAAQ,GAAG,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC;AAC5C,eAAe,QAAQ,CAAC"}
@@ -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>
@@ -0,0 +1,15 @@
1
+ import type { FormatKind } from './markdown-format.js';
2
+ interface Props {
3
+ /** Apply a markdown transform to the editor's current selection. */
4
+ format: (kind: FormatKind) => void;
5
+ }
6
+ /**
7
+ * The editor's formatting toolbar: bold, italic, heading, link, bulleted list, quote, code. Each button
8
+ * asks the host to apply a markdown transform to the current selection. Carta supplied this row before;
9
+ * cairn owns it now so the edit surface stays swappable. The glyphs are stroke SVG icons in the admin's
10
+ * house style (24x24 viewBox, `currentColor`, round caps), so the row matches the rest of the surface.
11
+ */
12
+ declare const EditorToolbar: import("svelte").Component<Props, {}, "">;
13
+ type EditorToolbar = ReturnType<typeof EditorToolbar>;
14
+ export default EditorToolbar;
15
+ //# sourceMappingURL=EditorToolbar.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"EditorToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/EditorToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAGrD,UAAU,KAAK;IACb,oEAAoE;IACpE,MAAM,EAAE,CAAC,IAAI,EAAE,UAAU,KAAK,IAAI,CAAC;CACpC;AAiDH;;;;;GAKG;AACH,QAAA,MAAM,aAAa,2CAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
@@ -1,81 +1,120 @@
1
1
  <!--
2
2
  @component
3
- The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over Carta exposing a bindable value
4
- and a cursor-insert callback. Carta and Shiki are client-only, so the editor mounts after the
5
- component does; until then the hidden field still carries the value so the form submits correctly.
6
- Swapping Carta for a bare CodeMirror editor stays a one-file change.
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, plugins = [], registerInsert }: Props = $props();
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
- // Carta and the MarkdownEditor component load only in the browser, after mount, so the server
39
- // bundle never pulls in Carta or Shiki (guarded by the carta-boundary test). The component keeps
40
- // its real type, so `value` stays bindable; the Carta constructor is reached through a cast
41
- // because the package entry does not surface the class by name.
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 mod = await import('carta-md');
47
- const CartaCtor = (
48
- mod as unknown as { Carta: new (options: { extensions?: unknown[]; sanitizer: false }) => CartaLike }
49
- ).Carta;
50
- const instance = new CartaCtor({
51
- extensions: plugins,
52
- // Sanitization is the site adapter's concern; the seam passes raw markdown through.
53
- sanitizer: false,
54
- });
55
- carta = instance;
56
- Editor = mod.MarkdownEditor;
57
- // Insert at the current cursor through carta.input once the editor is mounted; fall back to
58
- // appending while input is not yet populated (the pre-mount textarea phase).
59
- registerInsert?.((text: string) => {
60
- const inp = instance.input;
61
- if (inp) {
62
- const pos = inp.getSelection().start;
63
- const prefix = pos > 0 ? '\n\n' : '';
64
- inp.insertAt(pos, `${prefix}${text}`);
65
- inp.update();
66
- } else {
67
- value = value ? `${value}\n\n${text}` : text;
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} value={value} />
112
+ <input type="hidden" {name} {value} />
75
113
 
76
- {#if mounted && Editor && carta}
77
- {@const EditorComponent = Editor}
78
- <EditorComponent carta={carta as never} bind:value theme="default" mode="tabs" />
79
- {:else}
80
- <textarea class="textarea min-h-64 w-full font-mono text-sm" bind:value aria-label="Markdown source"></textarea>
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>
@@ -3,16 +3,15 @@ interface Props {
3
3
  value: string;
4
4
  /** The hidden field name the value is mirrored to for form submit. */
5
5
  name: string;
6
- /** Carta extensions from the adapter, for the design-accurate preview. */
7
- plugins?: unknown[];
8
6
  /** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
9
7
  registerInsert?: (insert: (text: string) => void) => void;
10
8
  }
11
9
  /**
12
- * The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over Carta exposing a bindable value
13
- * and a cursor-insert callback. Carta and Shiki are client-only, so the editor mounts after the
14
- * component does; until then the hidden field still carries the value so the form submits correctly.
15
- * Swapping Carta for a bare CodeMirror editor stays a one-file change.
10
+ * The `MarkdownEditor` seam (spec §6, seam 5): a thin wrapper over CodeMirror 6 exposing a bindable
11
+ * value and a cursor-insert callback. CodeMirror is client-only, so it mounts after the component does
12
+ * through a dynamic import; until then a plain textarea carries the value so the form still submits, and
13
+ * the hidden field mirrors the value throughout. The edit surface owns its toolbar; the design-accurate
14
+ * preview lives in EditPage through the adapter's render. Swapping the editor stays a one-file change.
16
15
  */
17
16
  declare const MarkdownEditor: import("svelte").Component<Props, {}, "value">;
18
17
  type MarkdownEditor = ReturnType<typeof MarkdownEditor>;
@@ -1 +1 @@
1
- {"version":3,"file":"MarkdownEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MarkdownEditor.svelte.ts"],"names":[],"mappings":"AAME,UAAU,KAAK;IACb,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,OAAO,CAAC,EAAE,OAAO,EAAE,CAAC;IACpB,oFAAoF;IACpF,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,IAAI,CAAC;CAC3D;AAuEH;;;;;GAKG;AACH,QAAA,MAAM,cAAc,gDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
1
+ {"version":3,"file":"MarkdownEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/MarkdownEditor.svelte.ts"],"names":[],"mappings":"AAQE,UAAU,KAAK;IACb,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,cAAc,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,KAAK,IAAI,CAAC;CAC3D;AA6GH;;;;;;GAMG;AACH,QAAA,MAAM,cAAc,gDAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
@@ -0,0 +1,13 @@
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
+ export interface FormatResult {
8
+ doc: string;
9
+ from: number;
10
+ to: number;
11
+ }
12
+ export declare function applyMarkdownFormat(doc: string, from: number, to: number, kind: FormatKind): FormatResult;
13
+ //# sourceMappingURL=markdown-format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"markdown-format.d.ts","sourceRoot":"","sources":["../../src/lib/components/markdown-format.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,IAAI,GAAG,MAAM,CAAC;AAE1F,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAKD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,YAAY,CAsBzG"}
@@ -0,0 +1,23 @@
1
+ const WRAP = { bold: '**', italic: '_', code: '`' };
2
+ const LINE_PREFIX = { heading: '# ', quote: '> ', ul: '- ' };
3
+ export function applyMarkdownFormat(doc, from, to, kind) {
4
+ if (kind === 'bold' || kind === 'italic' || kind === 'code') {
5
+ const marker = WRAP[kind];
6
+ const next = doc.slice(0, from) + marker + doc.slice(from, to) + marker + doc.slice(to);
7
+ return { doc: next, from: from + marker.length, to: to + marker.length };
8
+ }
9
+ if (kind === 'link') {
10
+ const text = doc.slice(from, to);
11
+ const placeholder = 'url';
12
+ const lead = `[${text}](`; // everything before the url placeholder
13
+ const inserted = `${lead}${placeholder})`;
14
+ const urlStart = from + lead.length;
15
+ return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: urlStart, to: urlStart + placeholder.length };
16
+ }
17
+ const prefix = LINE_PREFIX[kind];
18
+ const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
19
+ const region = doc.slice(lineStart, to);
20
+ const prefixed = region.replace(/^/gm, prefix);
21
+ const added = prefixed.length - region.length;
22
+ return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
23
+ }
@@ -1,7 +1,7 @@
1
- import type { CairnAdapter, CairnExtension, CairnRuntime } from './types.js';
1
+ import type { CairnAdapter, CairnExtension, CairnRuntime, ConceptUrlPolicy } from './types.js';
2
2
  /**
3
3
  * Fold an adapter and any extensions into the composed runtime (seam 2). Extension concepts
4
4
  * merge after the adapter's. The asset slot (seam 4) passes through untouched.
5
5
  */
6
- export declare function composeRuntime(adapter: CairnAdapter, extensions?: CairnExtension[]): CairnRuntime;
6
+ export declare function composeRuntime(adapter: CairnAdapter, extensions?: CairnExtension[], urlPolicy?: Record<string, ConceptUrlPolicy | undefined>): CairnRuntime;
7
7
  //# sourceMappingURL=compose.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../../src/lib/content/compose.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAc,YAAY,EAAE,cAAc,EAAE,YAAY,EAA+B,MAAM,YAAY,CAAC;AAGtH;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,YAAY,EACrB,UAAU,GAAE,cAAc,EAAO,GAChC,YAAY,CAuBd"}
1
+ {"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../../src/lib/content/compose.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAc,YAAY,EAAE,cAAc,EAAE,YAAY,EAAiB,gBAAgB,EAAgB,MAAM,YAAY,CAAC;AAGxI;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,YAAY,EACrB,UAAU,GAAE,cAAc,EAAO,EACjC,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,CAAM,GAC3D,YAAY,CAuBd"}
@@ -3,7 +3,7 @@ import { normalizeConcepts } from './concepts.js';
3
3
  * Fold an adapter and any extensions into the composed runtime (seam 2). Extension concepts
4
4
  * merge after the adapter's. The asset slot (seam 4) passes through untouched.
5
5
  */
6
- export function composeRuntime(adapter, extensions = []) {
6
+ export function composeRuntime(adapter, extensions = [], urlPolicy = {}) {
7
7
  const content = { ...adapter.content };
8
8
  const adminPanels = [];
9
9
  const fieldTypes = [];
@@ -19,7 +19,7 @@ export function composeRuntime(adapter, extensions = []) {
19
19
  }
20
20
  return {
21
21
  siteName: adapter.siteName,
22
- concepts: normalizeConcepts(content),
22
+ concepts: normalizeConcepts(content, urlPolicy),
23
23
  backend: adapter.backend,
24
24
  sender: adapter.sender,
25
25
  render: adapter.render,
@@ -1,4 +1,4 @@
1
- import type { ConceptConfig, ConceptDescriptor, RoutingRule } from './types.js';
1
+ import type { ConceptConfig, ConceptDescriptor, ConceptUrlPolicy, RoutingRule } from './types.js';
2
2
  /**
3
3
  * Concept-fixed routing, keyed by concept id (spec §7.2). Posts are dated feed entries;
4
4
  * pages are plain navigable structure. Not in adapter config. A future Fragments adds one
@@ -6,12 +6,13 @@ import type { ConceptConfig, ConceptDescriptor, RoutingRule } from './types.js';
6
6
  */
7
7
  export declare const CONCEPT_ROUTING: Readonly<Record<string, RoutingRule>>;
8
8
  /**
9
- * Normalize an adapter's declared concepts into uniform descriptors (seam 1). Each declared
10
- * key under `content` becomes one descriptor; an undeclared (`undefined`) concept is
11
- * skipped. `routing` is injectable so a contract test can prove a new concept attaches
12
- * additively; production passes the default `CONCEPT_ROUTING`.
9
+ * Normalize an adapter's declared concepts into uniform descriptors (seam 1). URL policy
10
+ * (`permalink`, `datePrefix`) comes from the YAML site-config, passed here as `urlPolicy` keyed by
11
+ * concept id; each value defaults when the YAML omits it (`/:slug` for Pages, `/<id>/:slug`
12
+ * otherwise; `datePrefix` defaults to `day`). `routing` is injectable so a contract test can prove
13
+ * a new concept attaches additively; production passes the default `CONCEPT_ROUTING`.
13
14
  */
14
- export declare function normalizeConcepts(content: Record<string, ConceptConfig | undefined>, routing?: Readonly<Record<string, RoutingRule>>): ConceptDescriptor[];
15
+ export declare function normalizeConcepts(content: Record<string, ConceptConfig | undefined>, urlPolicy?: Record<string, ConceptUrlPolicy | undefined>, routing?: Readonly<Record<string, RoutingRule>>): ConceptDescriptor[];
15
16
  /** Look up a normalized concept by id, or undefined when the site does not enable it. */
16
17
  export declare function findConcept(concepts: ConceptDescriptor[], id: string): ConceptDescriptor | undefined;
17
18
  //# sourceMappingURL=concepts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"concepts.d.ts","sourceRoot":"","sources":["../../src/lib/content/concepts.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEhF;;;;GAIG;AACH,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAGjE,CAAC;AAeF;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,SAAS,CAAC,EAClD,OAAO,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAmB,GAC/D,iBAAiB,EAAE,CAerB;AAED,yFAAyF;AACzF,wBAAgB,WAAW,CACzB,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,EAAE,EAAE,MAAM,GACT,iBAAiB,GAAG,SAAS,CAE/B"}
1
+ {"version":3,"file":"concepts.d.ts","sourceRoot":"","sources":["../../src/lib/content/concepts.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,aAAa,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAElG;;;;GAIG;AACH,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAGjE,CAAC;AAeF;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,SAAS,CAAC,EAClD,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,CAAM,EAC5D,OAAO,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAmB,GAC/D,iBAAiB,EAAE,CAiBrB;AAED,yFAAyF;AACzF,wBAAgB,WAAW,CACzB,QAAQ,EAAE,iBAAiB,EAAE,EAC7B,EAAE,EAAE,MAAM,GACT,iBAAiB,GAAG,SAAS,CAE/B"}
@@ -18,22 +18,25 @@ function defaultPermalink(id) {
18
18
  return id === 'pages' ? '/:slug' : `/${id}/:slug`;
19
19
  }
20
20
  /**
21
- * Normalize an adapter's declared concepts into uniform descriptors (seam 1). Each declared
22
- * key under `content` becomes one descriptor; an undeclared (`undefined`) concept is
23
- * skipped. `routing` is injectable so a contract test can prove a new concept attaches
24
- * additively; production passes the default `CONCEPT_ROUTING`.
21
+ * Normalize an adapter's declared concepts into uniform descriptors (seam 1). URL policy
22
+ * (`permalink`, `datePrefix`) comes from the YAML site-config, passed here as `urlPolicy` keyed by
23
+ * concept id; each value defaults when the YAML omits it (`/:slug` for Pages, `/<id>/:slug`
24
+ * otherwise; `datePrefix` defaults to `day`). `routing` is injectable so a contract test can prove
25
+ * a new concept attaches additively; production passes the default `CONCEPT_ROUTING`.
25
26
  */
26
- export function normalizeConcepts(content, routing = CONCEPT_ROUTING) {
27
+ export function normalizeConcepts(content, urlPolicy = {}, routing = CONCEPT_ROUTING) {
27
28
  const descriptors = [];
28
29
  for (const [id, config] of Object.entries(content)) {
29
30
  if (!config)
30
31
  continue;
32
+ const policy = urlPolicy[id] ?? {};
31
33
  descriptors.push({
32
34
  id,
33
35
  label: config.label ?? defaultLabel(id),
34
36
  dir: config.dir,
35
37
  routing: routing[id] ?? DEFAULT_ROUTING,
36
- permalink: config.permalink ?? defaultPermalink(id),
38
+ permalink: policy.permalink ?? defaultPermalink(id),
39
+ datePrefix: policy.datePrefix ?? 'day',
37
40
  fields: config.fields,
38
41
  validate: config.validate,
39
42
  });
@@ -14,4 +14,18 @@ export declare function filenameFromId(id: string): string;
14
14
  * single hyphen; leading and trailing hyphens are trimmed.
15
15
  */
16
16
  export declare function slugify(title: string): string;
17
+ /** Filename date-prefix granularity for a dated concept: the leading `YYYY[-MM[-DD]]-` on the stem. */
18
+ export type DatePrefix = 'year' | 'month' | 'day';
19
+ /**
20
+ * The URL slug for an id. A dated concept passes its `datePrefix` and the leading date prefix is
21
+ * stripped when present; a non-dated concept passes `null` and the id is returned verbatim. Only
22
+ * the leading prefix is removed, so a year-like tail (a post titled "2024 Recap") stays in the slug.
23
+ */
24
+ export declare function slugFromId(id: string, datePrefix: DatePrefix | null): string;
25
+ /**
26
+ * Compose a dated entry's id from a `YYYY-MM-DD` date, a date-free slug, and the concept's
27
+ * granularity: the date truncated to the granularity, a hyphen, then the slug. Throws on a
28
+ * malformed date so a bad create fails before touching git.
29
+ */
30
+ export declare function composeDatedId(date: string, slug: string, datePrefix: DatePrefix): string;
17
31
  //# sourceMappingURL=ids.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../../src/lib/content/ids.ts"],"names":[],"mappings":"AAOA,qGAAqG;AACrG,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,yDAAyD;AACzD,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM7C"}
1
+ {"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../../src/lib/content/ids.ts"],"names":[],"mappings":"AAOA,qGAAqG;AACrG,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,yDAAyD;AACzD,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM7C;AAED,uGAAuG;AACvG,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC;AASlD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,MAAM,CAG5E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,MAAM,CAiBzF"}
@@ -31,3 +31,43 @@ export function slugify(title) {
31
31
  .replace(/[^a-z0-9]+/g, '-')
32
32
  .replace(/^-+|-+$/g, '');
33
33
  }
34
+ /** The leading date-prefix shape for each granularity. */
35
+ const DATE_PREFIX_RE = {
36
+ year: /^\d{4}-/,
37
+ month: /^\d{4}-\d{2}-/,
38
+ day: /^\d{4}-\d{2}-\d{2}-/,
39
+ };
40
+ /**
41
+ * The URL slug for an id. A dated concept passes its `datePrefix` and the leading date prefix is
42
+ * stripped when present; a non-dated concept passes `null` and the id is returned verbatim. Only
43
+ * the leading prefix is removed, so a year-like tail (a post titled "2024 Recap") stays in the slug.
44
+ */
45
+ export function slugFromId(id, datePrefix) {
46
+ if (!datePrefix)
47
+ return id;
48
+ return id.replace(DATE_PREFIX_RE[datePrefix], '');
49
+ }
50
+ /**
51
+ * Compose a dated entry's id from a `YYYY-MM-DD` date, a date-free slug, and the concept's
52
+ * granularity: the date truncated to the granularity, a hyphen, then the slug. Throws on a
53
+ * malformed date so a bad create fails before touching git.
54
+ */
55
+ export function composeDatedId(date, slug, datePrefix) {
56
+ const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
57
+ if (!m)
58
+ throw new Error(`composeDatedId: malformed date "${date}"`);
59
+ const [, year, month, day] = m;
60
+ let prefix;
61
+ switch (datePrefix) {
62
+ case 'year':
63
+ prefix = year;
64
+ break;
65
+ case 'month':
66
+ prefix = `${year}-${month}`;
67
+ break;
68
+ case 'day':
69
+ prefix = `${year}-${month}-${day}`;
70
+ break;
71
+ }
72
+ return `${prefix}-${slug}`;
73
+ }