@glw907/cairn-cms 0.38.0 → 0.40.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 (56) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +6 -5
  3. package/dist/components/AdminLayout.svelte +53 -0
  4. package/dist/components/ComponentInsertDialog.svelte +27 -13
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
  6. package/dist/components/ConceptList.svelte +13 -3
  7. package/dist/components/DeleteDialog.svelte +18 -7
  8. package/dist/components/DeleteDialog.svelte.d.ts +11 -1
  9. package/dist/components/EditPage.svelte +575 -70
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +202 -29
  12. package/dist/components/EditorToolbar.svelte.d.ts +12 -4
  13. package/dist/components/LinkPicker.svelte +14 -6
  14. package/dist/components/LinkPicker.svelte.d.ts +9 -2
  15. package/dist/components/MarkdownEditor.svelte +80 -34
  16. package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
  17. package/dist/components/MarkdownHelpDialog.svelte +58 -0
  18. package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
  19. package/dist/components/RenameDialog.svelte +13 -4
  20. package/dist/components/RenameDialog.svelte.d.ts +9 -1
  21. package/dist/components/WebLinkDialog.svelte +89 -0
  22. package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
  23. package/dist/components/cairn-admin.css +353 -4
  24. package/dist/components/editor-highlight.d.ts +9 -0
  25. package/dist/components/editor-highlight.js +62 -0
  26. package/dist/components/markdown-directives.d.ts +7 -0
  27. package/dist/components/markdown-directives.js +22 -0
  28. package/dist/components/markdown-format.d.ts +1 -1
  29. package/dist/components/markdown-format.js +91 -12
  30. package/dist/content/pending.d.ts +9 -0
  31. package/dist/content/pending.js +24 -0
  32. package/dist/github/branches.d.ts +11 -0
  33. package/dist/github/branches.js +75 -0
  34. package/dist/log/events.d.ts +1 -1
  35. package/dist/sveltekit/content-routes.d.ts +22 -1
  36. package/dist/sveltekit/content-routes.js +312 -72
  37. package/package.json +3 -2
  38. package/src/lib/components/AdminLayout.svelte +53 -0
  39. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  40. package/src/lib/components/ConceptList.svelte +13 -3
  41. package/src/lib/components/DeleteDialog.svelte +18 -7
  42. package/src/lib/components/EditPage.svelte +575 -70
  43. package/src/lib/components/EditorToolbar.svelte +202 -29
  44. package/src/lib/components/LinkPicker.svelte +14 -6
  45. package/src/lib/components/MarkdownEditor.svelte +80 -34
  46. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  47. package/src/lib/components/RenameDialog.svelte +13 -4
  48. package/src/lib/components/WebLinkDialog.svelte +89 -0
  49. package/src/lib/components/cairn-admin.css +26 -4
  50. package/src/lib/components/editor-highlight.ts +67 -0
  51. package/src/lib/components/markdown-directives.ts +23 -0
  52. package/src/lib/components/markdown-format.ts +118 -13
  53. package/src/lib/content/pending.ts +24 -0
  54. package/src/lib/github/branches.ts +83 -0
  55. package/src/lib/log/events.ts +3 -0
  56. package/src/lib/sveltekit/content-routes.ts +391 -73
@@ -0,0 +1,89 @@
1
+ <!--
2
+ @component
3
+ The Web link control and its modal: the way to link out to an ordinary web address, beside the
4
+ Link to page picker that handles internal targets. Two fields, the address and an optional display
5
+ text; when the editor holds a selection it arrives as the default text, and the insert seam wraps
6
+ that selection either way. Built on a native <dialog>, following the LinkPicker a11y conventions,
7
+ and opened by the host's Ctrl/Cmd+K shortcut through the exported open().
8
+ -->
9
+ <script lang="ts">
10
+ interface Props {
11
+ /** Insert an inline link at the editor cursor; the editor's registerInsertLink seam. */
12
+ insert: (href: string, title: string) => void;
13
+ /** Read the editor's current selection, for the Text field's default. */
14
+ selection?: () => string;
15
+ /** Disable the trigger; the host sets it while Preview shows. */
16
+ disabled?: boolean;
17
+ /** Render the built-in Web link trigger. False mounts only the dialog, for a host that
18
+ * supplies its own trigger and opens the dialog through the exported open(). */
19
+ trigger?: boolean;
20
+ }
21
+
22
+ let { insert, selection, disabled = false, trigger = true }: Props = $props();
23
+
24
+ let dialog = $state<HTMLDialogElement | null>(null);
25
+ let hrefInput = $state<HTMLInputElement | null>(null);
26
+ let href = $state('');
27
+ let text = $state('');
28
+
29
+ /** Open the dialog with fresh fields; the edit page's Ctrl/Cmd+K shortcut calls it too. */
30
+ export function open() {
31
+ href = '';
32
+ text = selection?.() ?? '';
33
+ dialog?.showModal();
34
+ // showModal() lands focus on the first focusable element (the header Close button), so move
35
+ // it to the address input the dialog exists for (WCAG 2.4.3). A microtask defers past the
36
+ // dialog's own focus handling, the RenameDialog recipe.
37
+ queueMicrotask(() => hrefInput?.focus());
38
+ }
39
+ function close() {
40
+ dialog?.close();
41
+ }
42
+ function submit(e: SubmitEvent) {
43
+ e.preventDefault();
44
+ // With no text and no selection the address itself becomes the display text, so the link
45
+ // never renders as an invisible pair of brackets.
46
+ insert(href, text.trim() || href);
47
+ close();
48
+ }
49
+ </script>
50
+
51
+ {#if trigger}
52
+ <button
53
+ type="button"
54
+ class="btn btn-sm btn-ghost"
55
+ aria-haspopup="dialog"
56
+ aria-label="Web link (Ctrl+K)"
57
+ title="Web link (Ctrl+K)"
58
+ {disabled}
59
+ onclick={open}
60
+ >
61
+ Web link
62
+ </button>
63
+ {/if}
64
+
65
+ <dialog class="modal" aria-labelledby="cairn-web-link-dialog-title" bind:this={dialog}>
66
+ <div class="modal-box">
67
+ <div class="mb-3 flex items-center justify-between">
68
+ <h2 id="cairn-web-link-dialog-title" class="text-base font-semibold">Add a web link</h2>
69
+ <button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
70
+ </div>
71
+ <form onsubmit={submit} class="flex flex-col gap-3">
72
+ <label class="flex flex-col gap-1">
73
+ <span class="text-sm font-medium">Web address</span>
74
+ <input class="input w-full" type="url" required placeholder="https://…" bind:value={href} bind:this={hrefInput} />
75
+ </label>
76
+ <label class="flex flex-col gap-1">
77
+ <span class="text-sm font-medium">Text</span>
78
+ <input class="input w-full" placeholder="What the link says" bind:value={text} />
79
+ </label>
80
+ <div class="flex justify-end gap-2">
81
+ <button type="button" class="btn btn-sm" onclick={close}>Cancel</button>
82
+ <button type="submit" class="btn btn-sm btn-primary">Add link</button>
83
+ </div>
84
+ </form>
85
+ </div>
86
+ <form method="dialog" class="modal-backdrop">
87
+ <button tabindex="-1" aria-label="Close">close</button>
88
+ </form>
89
+ </dialog>
@@ -24,18 +24,22 @@
24
24
  --color-primary-content: oklch(98% 0.012 293);
25
25
  --color-secondary: oklch(45% 0.02 75);
26
26
  --color-secondary-content: oklch(98% 0.004 75);
27
- --color-accent: oklch(58% 0.16 300);
27
+ /* 54% holds AA for the editor's directive ink on base-100 (5.28:1) and on its own 8% accent
28
+ tint (4.75:1); 58% failed the tint at 4.04:1. A locked margin, like the dark nav pair. */
29
+ --color-accent: oklch(54% 0.16 300);
28
30
  --color-accent-content: oklch(98% 0.012 300);
29
31
  --color-neutral: oklch(32% 0.012 75);
30
32
  --color-neutral-content: oklch(96% 0.004 75);
31
33
 
32
- --color-info: oklch(60% 0.12 240);
34
+ /* The info, success, and error tones sit dark enough for >= 4.5:1 white text (WCAG AA) on the
35
+ badge and alert fills. Measured: info 5.12:1, success 4.94:1, error 4.83:1. */
36
+ --color-info: oklch(52% 0.12 240);
33
37
  --color-info-content: oklch(98% 0.012 240);
34
- --color-success: oklch(58% 0.12 150);
38
+ --color-success: oklch(52% 0.12 150);
35
39
  --color-success-content: oklch(98% 0.012 150);
36
40
  --color-warning: oklch(75% 0.15 70);
37
41
  --color-warning-content: oklch(26% 0.05 70);
38
- --color-error: oklch(58% 0.2 25);
42
+ --color-error: oklch(56% 0.2 25);
39
43
  --color-error-content: oklch(98% 0.012 25);
40
44
 
41
45
  /* Accessible muted text tones: >= 4.5:1 contrast on base-100/base-200. */
@@ -181,6 +185,24 @@
181
185
  0 2px 4px color-mix(in oklch, var(--color-primary) 26%, transparent),
182
186
  0 10px 24px -5px color-mix(in oklch, var(--color-primary) 48%, transparent);
183
187
  }
188
+
189
+ /* The hoisted document title is a text-entry surface like the editor: browsers treat a focused
190
+ text input as keyboard-modal, so the page-wide 2px focus-visible ring above would shout
191
+ through every typing session. It gets the editor's quiet always-on hairline instead (WCAG
192
+ 2.4.7), at the 70% primary mix that clears the 3:1 non-text contrast floor on both themes
193
+ (WCAG 1.4.11). The class-plus-pseudo selector outranks the bare :focus-visible rule. */
194
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .cairn-doc-title:focus {
195
+ outline: 1px solid color-mix(in oklab, var(--color-primary) 70%, transparent);
196
+ outline-offset: -1px;
197
+ }
198
+
199
+ /* The admin topbar plus the edit page's sticky action header stack about 120px of veil over the
200
+ top of the content column, and a control the browser scrolls into view could land hidden
201
+ beneath them (WCAG 2.4.11 Focus Not Obscured). The scroll margin keeps any focus or fragment
202
+ scroll clear of the whole sticky stack. */
203
+ :where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) :is(a, button, input, textarea, select, summary, [tabindex]) {
204
+ scroll-margin-top: 8.5rem;
205
+ }
184
206
  }
185
207
 
186
208
  /* Respect a reduced-motion preference inside the admin. DaisyUI's modal, drawer, and the admin's
@@ -0,0 +1,67 @@
1
+ // The editor's syntax colors and the directive machinery decorations. Colors reference the Warm
2
+ // Stone CSS variables so light and dark themes both resolve, and every token pair must hold WCAG
3
+ // AA against --color-base-100 (checked in the design pass).
4
+ import { HighlightStyle } from '@codemirror/language';
5
+ import { tags } from '@lezer/highlight';
6
+ import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from '@codemirror/view';
7
+ import { RangeSetBuilder } from '@codemirror/state';
8
+ import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
9
+
10
+ /** Markdown token colors over the admin theme variables. */
11
+ export function cairnHighlightStyle(): HighlightStyle {
12
+ return HighlightStyle.define([
13
+ { tag: tags.heading, color: 'var(--color-primary)', fontWeight: '700' },
14
+ { tag: tags.strong, fontWeight: '700' },
15
+ { tag: tags.emphasis, fontStyle: 'italic' },
16
+ { tag: tags.strikethrough, textDecoration: 'line-through' },
17
+ { tag: tags.link, color: 'var(--color-info)' },
18
+ { tag: tags.url, color: 'var(--color-info)' },
19
+ { tag: tags.quote, color: 'var(--color-muted)', fontStyle: 'italic' },
20
+ { tag: tags.monospace, color: 'var(--color-accent)' },
21
+ { tag: tags.processingInstruction, color: 'var(--color-muted)' },
22
+ { tag: tags.list, color: 'var(--color-muted)' },
23
+ ]);
24
+ }
25
+
26
+ // The machinery lines explain themselves on hover, so an editor who has never seen ::: syntax
27
+ // learns what the line is without leaving the page.
28
+ const MACHINERY_HINT = 'Layout marker. Edit the text between these lines and leave this line as it is.';
29
+
30
+ const fenceLine = Decoration.line({ class: 'cm-cairn-directive-fence', attributes: { title: MACHINERY_HINT } });
31
+ const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
32
+ const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
33
+
34
+ function buildDirectiveDecorations(view: EditorView): DecorationSet {
35
+ const builder = new RangeSetBuilder<Decoration>();
36
+ for (const { from, to } of view.visibleRanges) {
37
+ for (let pos = from; pos <= to; ) {
38
+ const line = view.state.doc.lineAt(pos);
39
+ const kind = directiveLineKind(line.text);
40
+ if (kind === 'fence') builder.add(line.from, line.from, fenceLine);
41
+ else if (kind === 'leaf') builder.add(line.from, line.from, leafLine);
42
+ else {
43
+ for (const r of findInlineDirectives(line.text)) {
44
+ builder.add(line.from + r.from, line.from + r.to, inlineMark);
45
+ }
46
+ }
47
+ pos = line.to + 1;
48
+ }
49
+ }
50
+ return builder.finish();
51
+ }
52
+
53
+ /** Line and mark decorations flagging remark-directive machinery. */
54
+ export function cairnDirectivePlugin() {
55
+ return ViewPlugin.fromClass(
56
+ class {
57
+ decorations: DecorationSet;
58
+ constructor(view: EditorView) {
59
+ this.decorations = buildDirectiveDecorations(view);
60
+ }
61
+ update(update: ViewUpdate) {
62
+ if (update.docChanged || update.viewportChanged) this.decorations = buildDirectiveDecorations(update.view);
63
+ }
64
+ },
65
+ { decorations: (v) => v.decorations },
66
+ );
67
+ }
@@ -0,0 +1,23 @@
1
+ // Remark-directive detection for the editor's machinery highlighting (spec: directive syntax is
2
+ // styled distinctly so an editor can tell component scaffolding from prose). Pure functions; the
3
+ // CodeMirror decoration plugin wraps them.
4
+
5
+ const FENCE = /^\s{0,3}:::+\s*[\w-]*\s*(\{[^}]*\})?\s*$/;
6
+ const LEAF = /^\s{0,3}::[\w-]+(\[[^\]]*\])?(\{[^}]*\})?\s*$/;
7
+ const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
8
+
9
+ /** Classify a whole line as a container fence, a leaf directive, or neither. */
10
+ export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
11
+ if (FENCE.test(line)) return 'fence';
12
+ if (LEAF.test(line)) return 'leaf';
13
+ return null;
14
+ }
15
+
16
+ /** Inline directive ranges (`:name[...]{...}`) within a line of text. */
17
+ export function findInlineDirectives(text: string): { from: number; to: number }[] {
18
+ const out: { from: number; to: number }[] = [];
19
+ for (const m of text.matchAll(INLINE)) {
20
+ out.push({ from: m.index, to: m.index + m[0].length });
21
+ }
22
+ return out;
23
+ }
@@ -10,7 +10,21 @@ import { visit } from 'unist-util-visit';
10
10
  import type { Link } from 'mdast';
11
11
  import { escapeLinkText } from '../content/links.js';
12
12
 
13
- export type FormatKind = 'bold' | 'italic' | 'code' | 'heading' | 'quote' | 'ul' | 'link';
13
+ export type FormatKind =
14
+ | 'bold'
15
+ | 'italic'
16
+ | 'code'
17
+ | 'strike'
18
+ | 'h2'
19
+ | 'h3'
20
+ | 'quote'
21
+ | 'ul'
22
+ | 'ol'
23
+ | 'task'
24
+ | 'codeblock'
25
+ | 'hr'
26
+ | 'table'
27
+ | 'link';
14
28
 
15
29
  export interface FormatResult {
16
30
  doc: string;
@@ -18,14 +32,92 @@ export interface FormatResult {
18
32
  to: number;
19
33
  }
20
34
 
21
- const WRAP: Record<'bold' | 'italic' | 'code', string> = { bold: '**', italic: '_', code: '`' };
22
- const LINE_PREFIX: Record<'heading' | 'quote' | 'ul', string> = { heading: '# ', quote: '> ', ul: '- ' };
35
+ type WrapKind = 'bold' | 'italic' | 'code' | 'strike';
36
+ type LineKind = 'h2' | 'h3' | 'quote' | 'ul' | 'ol' | 'task';
37
+
38
+ const WRAP: Record<WrapKind, string> = { bold: '**', italic: '_', code: '`', strike: '~~' };
39
+
40
+ /**
41
+ * Per-kind line-prefix behavior. `prefix` builds the marker for the line's 0-based index (only ol
42
+ * varies by line). `exact` matches a line already carrying this kind's own marker; when every
43
+ * selected line matches, the format toggles off. `strip` matches a competing marker to replace
44
+ * before prefixing, so h2 on an h3 line swaps the level instead of stacking. Quote and ul keep
45
+ * their original add-only behavior, so they carry neither regex.
46
+ */
47
+ const LINE: Record<LineKind, { prefix: (i: number) => string; exact?: RegExp; strip?: RegExp }> = {
48
+ h2: { prefix: () => '## ', exact: /^## /, strip: /^#{1,6} / },
49
+ h3: { prefix: () => '### ', exact: /^### /, strip: /^#{1,6} / },
50
+ quote: { prefix: () => '> ' },
51
+ ul: { prefix: () => '- ' },
52
+ ol: { prefix: (i) => `${i + 1}. `, exact: /^\d+\. /, strip: /^\d+\. / },
53
+ task: { prefix: () => '- [ ] ', exact: /^- \[[ xX]\] /, strip: /^- \[[ xX]\] / },
54
+ };
55
+
56
+ const TABLE_GRID =
57
+ '| Column 1 | Column 2 |\n| -------- | -------- |\n| | |\n| | |';
58
+
59
+ /** Wrap the selection in `marker`, or unwrap when the markers are already there (inside or just
60
+ * outside the selection). The returned range covers the text without its markers either way. */
61
+ function toggleWrap(doc: string, from: number, to: number, marker: string): FormatResult {
62
+ const m = marker.length;
63
+ const sel = doc.slice(from, to);
64
+ if (sel.length >= 2 * m && sel.startsWith(marker) && sel.endsWith(marker)) {
65
+ const inner = sel.slice(m, sel.length - m);
66
+ return { doc: doc.slice(0, from) + inner + doc.slice(to), from, to: to - 2 * m };
67
+ }
68
+ if (from >= m && doc.slice(from - m, from) === marker && doc.slice(to, to + m) === marker) {
69
+ return { doc: doc.slice(0, from - m) + sel + doc.slice(to + m), from: from - m, to: to - m };
70
+ }
71
+ const next = doc.slice(0, from) + marker + sel + marker + doc.slice(to);
72
+ return { doc: next, from: from + m, to: to + m };
73
+ }
74
+
75
+ /** Apply a line-prefix kind to every selected line. When the kind toggles and every line already
76
+ * carries its marker, the markers come off; otherwise competing markers are replaced and each
77
+ * line gains the kind's prefix. The selection shifts with the first line's edit and stretches
78
+ * by the total length change, the same mechanics the original single-prefix version had. */
79
+ function applyLinePrefix(doc: string, from: number, to: number, kind: LineKind): FormatResult {
80
+ const { prefix, exact, strip } = LINE[kind];
81
+ const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
82
+ const lines = doc.slice(lineStart, to).split('\n');
83
+ const next =
84
+ exact && lines.every((line) => exact.test(line))
85
+ ? lines.map((line) => line.replace(exact, ''))
86
+ : lines.map((line, i) => prefix(i) + (strip ? line.replace(strip, '') : line));
87
+ const region = next.join('\n');
88
+ const firstDelta = next[0].length - lines[0].length;
89
+ const totalDelta = region.length - (to - lineStart);
90
+ return {
91
+ doc: doc.slice(0, lineStart) + region + doc.slice(to),
92
+ from: Math.max(lineStart, from + firstDelta),
93
+ to: to + totalDelta,
94
+ };
95
+ }
96
+
97
+ /** Fence the selected lines in triple backticks on their own lines, or remove the fences when the
98
+ * lines just above and below the selection already are fences. */
99
+ function toggleCodeFence(doc: string, from: number, to: number): FormatResult {
100
+ const lineStart = doc.lastIndexOf('\n', from - 1) + 1;
101
+ const lineEndRaw = doc.indexOf('\n', to);
102
+ const lineEnd = lineEndRaw === -1 ? doc.length : lineEndRaw;
103
+ const prevStart = lineStart > 0 ? doc.lastIndexOf('\n', lineStart - 2) + 1 : -1;
104
+ const prevLine = prevStart >= 0 ? doc.slice(prevStart, lineStart - 1) : null;
105
+ const nextEndRaw = lineEnd < doc.length ? doc.indexOf('\n', lineEnd + 1) : -1;
106
+ const nextEnd = nextEndRaw === -1 ? doc.length : nextEndRaw;
107
+ const nextLine = lineEnd < doc.length ? doc.slice(lineEnd + 1, nextEnd) : null;
108
+ if (prevLine === '```' && nextLine === '```') {
109
+ const removedBefore = lineStart - prevStart; // the opening fence line and its newline
110
+ const next = doc.slice(0, prevStart) + doc.slice(lineStart, lineEnd) + doc.slice(nextEnd);
111
+ return { doc: next, from: from - removedBefore, to: to - removedBefore };
112
+ }
113
+ const open = '```\n';
114
+ const next = doc.slice(0, lineStart) + open + doc.slice(lineStart, lineEnd) + '\n```' + doc.slice(lineEnd);
115
+ return { doc: next, from: from + open.length, to: to + open.length };
116
+ }
23
117
 
24
118
  export function applyMarkdownFormat(doc: string, from: number, to: number, kind: FormatKind): FormatResult {
25
- if (kind === 'bold' || kind === 'italic' || kind === 'code') {
26
- const marker = WRAP[kind];
27
- const next = doc.slice(0, from) + marker + doc.slice(from, to) + marker + doc.slice(to);
28
- return { doc: next, from: from + marker.length, to: to + marker.length };
119
+ if (kind === 'bold' || kind === 'italic' || kind === 'code' || kind === 'strike') {
120
+ return toggleWrap(doc, from, to, WRAP[kind]);
29
121
  }
30
122
 
31
123
  if (kind === 'link') {
@@ -37,12 +129,25 @@ export function applyMarkdownFormat(doc: string, from: number, to: number, kind:
37
129
  return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: urlStart, to: urlStart + placeholder.length };
38
130
  }
39
131
 
40
- const prefix = LINE_PREFIX[kind];
41
- const lineStart = doc.lastIndexOf('\n', from - 1) + 1; // 0 when the selection is on the first line
42
- const region = doc.slice(lineStart, to);
43
- const prefixed = region.replace(/^/gm, prefix);
44
- const added = prefixed.length - region.length;
45
- return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
132
+ if (kind === 'codeblock') return toggleCodeFence(doc, from, to);
133
+
134
+ if (kind === 'hr') {
135
+ const inserted = '\n\n---\n\n';
136
+ const at = from + inserted.length;
137
+ return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: at, to: at };
138
+ }
139
+
140
+ if (kind === 'table') {
141
+ const inserted = `\n\n${TABLE_GRID}\n\n`;
142
+ const cellStart = from + inserted.indexOf('Column 1');
143
+ return {
144
+ doc: doc.slice(0, from) + inserted + doc.slice(to),
145
+ from: cellStart,
146
+ to: cellStart + 'Column 1'.length,
147
+ };
148
+ }
149
+
150
+ return applyLinePrefix(doc, from, to, kind);
46
151
  }
47
152
 
48
153
  /**
@@ -0,0 +1,24 @@
1
+ // The pending-branch codec (publish-workflow spec): a pending entry lives on
2
+ // `cairn/<conceptKey>/<id>`, and the ref's existence is the only pending state. Concept ids and
3
+ // entry ids are slug-safe, so the name needs no escaping; the parser is the codec's inverse.
4
+
5
+ /** Every pending branch sits under this prefix; one matching-refs call lists them all. */
6
+ export const PENDING_PREFIX = 'cairn/';
7
+
8
+ /** The branch name holding an entry's pending edits. */
9
+ export function pendingBranch(concept: string, id: string): string {
10
+ return `${PENDING_PREFIX}${concept}/${id}`;
11
+ }
12
+
13
+ /** Parse a branch name or fully qualified ref back to its entry, or null for any other ref. */
14
+ export function parsePendingBranch(ref: string): { concept: string; id: string } | null {
15
+ const name = ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : ref;
16
+ if (!name.startsWith(PENDING_PREFIX)) return null;
17
+ const rest = name.slice(PENDING_PREFIX.length);
18
+ const slash = rest.indexOf('/');
19
+ if (slash <= 0) return null;
20
+ const concept = rest.slice(0, slash);
21
+ const id = rest.slice(slash + 1);
22
+ if (!id || id.includes('/')) return null;
23
+ return { concept, id };
24
+ }
@@ -0,0 +1,83 @@
1
+ // Branch (ref) operations for the publish workflow, over the Git Data API. A pending entry's
2
+ // branch is created lazily at first save, listed by the `cairn/` prefix to derive pending
3
+ // state, and deleted by publish and discard. All three are covered by the App's contents
4
+ // permission; no PRs are involved.
5
+ import type { RepoRef } from './types.js';
6
+
7
+ const API = 'https://api.github.com';
8
+
9
+ function headers(token: string): Record<string, string> {
10
+ return {
11
+ Accept: 'application/vnd.github+json',
12
+ 'User-Agent': 'cairn-cms',
13
+ 'X-GitHub-Api-Version': '2022-11-28',
14
+ Authorization: `Bearer ${token}`,
15
+ 'Content-Type': 'application/json',
16
+ };
17
+ }
18
+
19
+ function gitUrl(repo: RepoRef, suffix: string): string {
20
+ return `${API}/repos/${repo.owner}/${repo.repo}/git/${suffix}`;
21
+ }
22
+
23
+ /** The head commit sha of a branch, or null when the branch does not exist. */
24
+ export async function branchHeadSha(repo: RepoRef, branch: string, token: string): Promise<string | null> {
25
+ const res = await fetch(gitUrl(repo, `ref/heads/${encodeURIComponent(branch)}`), { headers: headers(token) });
26
+ // The 404 probe is a hot path (every editLoad); drain the body so the connection frees
27
+ // immediately instead of pinning one of workerd's six until GC.
28
+ if (res.status === 404) {
29
+ await res.body?.cancel();
30
+ return null;
31
+ }
32
+ if (!res.ok) throw new Error(`GitHub ref ${branch} failed: ${res.status} ${await res.text()}`);
33
+ return ((await res.json()) as { object: { sha: string } }).object.sha;
34
+ }
35
+
36
+ /** Create `branch` pointing at `fromSha`. Throws on any failure including an existing ref. */
37
+ export async function createBranch(repo: RepoRef, branch: string, fromSha: string, token: string): Promise<void> {
38
+ const res = await fetch(gitUrl(repo, 'refs'), {
39
+ method: 'POST',
40
+ headers: headers(token),
41
+ body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: fromSha }),
42
+ });
43
+ if (!res.ok) throw new Error(`GitHub branch create ${branch} failed: ${res.status} ${await res.text()}`);
44
+ await res.body?.cancel();
45
+ }
46
+
47
+ /** Delete `branch`. A 404 (already gone) is success: the desired state holds. */
48
+ export async function deleteBranch(repo: RepoRef, branch: string, token: string): Promise<void> {
49
+ const res = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(branch)}`), {
50
+ method: 'DELETE',
51
+ headers: headers(token),
52
+ });
53
+ if (!res.ok && res.status !== 404) {
54
+ throw new Error(`GitHub branch delete ${branch} failed: ${res.status} ${await res.text()}`);
55
+ }
56
+ await res.body?.cancel();
57
+ }
58
+
59
+ /** The rel="next" URL from a GitHub Link header, or null on the last page. */
60
+ function nextPageUrl(link: string | null): string | null {
61
+ if (!link) return null;
62
+ for (const part of link.split(',')) {
63
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
64
+ if (match) return match[1];
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /** Branch names under `prefix`, sorted. The matching-refs API paginates at 30 by default, so a
70
+ * site with 31+ pending entries would silently truncate; request the 100-per-page maximum and
71
+ * follow the Link rel="next" chain until exhausted. */
72
+ export async function listBranches(repo: RepoRef, prefix: string, token: string): Promise<string[]> {
73
+ const names: string[] = [];
74
+ let url: string | null = `${gitUrl(repo, `matching-refs/heads/${prefix}`)}?per_page=100`;
75
+ while (url) {
76
+ const res: Response = await fetch(url, { headers: headers(token) });
77
+ if (!res.ok) throw new Error(`GitHub matching-refs ${prefix} failed: ${res.status} ${await res.text()}`);
78
+ const refs = (await res.json()) as { ref: string }[];
79
+ names.push(...refs.map((r) => r.ref.replace(/^refs\/heads\//, '')));
80
+ url = nextPageUrl(res.headers.get('Link'));
81
+ }
82
+ return names;
83
+ }
@@ -10,4 +10,7 @@ export type CairnLogEvent =
10
10
  | 'auth.session.destroyed'
11
11
  | 'commit.succeeded'
12
12
  | 'commit.failed'
13
+ | 'entry.published'
14
+ | 'entry.discarded'
15
+ | 'publish.failed'
13
16
  | 'guard.rejected';