@glw907/cairn-cms 0.38.0 → 0.41.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 (97) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +7 -6
  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 +22 -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 +604 -75
  10. package/dist/components/EditPage.svelte.d.ts +8 -1
  11. package/dist/components/EditorToolbar.svelte +206 -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/link-completion.js +10 -3
  27. package/dist/components/markdown-directives.d.ts +7 -0
  28. package/dist/components/markdown-directives.js +22 -0
  29. package/dist/components/markdown-format.d.ts +1 -1
  30. package/dist/components/markdown-format.js +91 -12
  31. package/dist/content/pending.d.ts +9 -0
  32. package/dist/content/pending.js +24 -0
  33. package/dist/diagnostics/conditions.d.ts +8 -1
  34. package/dist/diagnostics/conditions.js +68 -1
  35. package/dist/doctor/bin.d.ts +2 -0
  36. package/dist/doctor/bin.js +44 -0
  37. package/dist/doctor/check-send.d.ts +3 -0
  38. package/dist/doctor/check-send.js +43 -0
  39. package/dist/doctor/checks-cloudflare.d.ts +5 -0
  40. package/dist/doctor/checks-cloudflare.js +200 -0
  41. package/dist/doctor/checks-github.d.ts +2 -0
  42. package/dist/doctor/checks-github.js +57 -0
  43. package/dist/doctor/checks-local.d.ts +5 -0
  44. package/dist/doctor/checks-local.js +112 -0
  45. package/dist/doctor/cloudflare-api.d.ts +7 -0
  46. package/dist/doctor/cloudflare-api.js +24 -0
  47. package/dist/doctor/index.d.ts +23 -0
  48. package/dist/doctor/index.js +68 -0
  49. package/dist/doctor/report.d.ts +5 -0
  50. package/dist/doctor/report.js +21 -0
  51. package/dist/doctor/run.d.ts +8 -0
  52. package/dist/doctor/run.js +20 -0
  53. package/dist/doctor/types.d.ts +41 -0
  54. package/dist/doctor/types.js +10 -0
  55. package/dist/doctor/wrangler-config.d.ts +12 -0
  56. package/dist/doctor/wrangler-config.js +125 -0
  57. package/dist/github/branches.d.ts +11 -0
  58. package/dist/github/branches.js +75 -0
  59. package/dist/github/signing.d.ts +3 -1
  60. package/dist/github/signing.js +13 -5
  61. package/dist/log/events.d.ts +1 -1
  62. package/dist/sveltekit/content-routes.d.ts +22 -1
  63. package/dist/sveltekit/content-routes.js +320 -72
  64. package/package.json +8 -5
  65. package/src/lib/components/AdminLayout.svelte +53 -0
  66. package/src/lib/components/ComponentInsertDialog.svelte +27 -13
  67. package/src/lib/components/ConceptList.svelte +22 -3
  68. package/src/lib/components/DeleteDialog.svelte +18 -7
  69. package/src/lib/components/EditPage.svelte +604 -75
  70. package/src/lib/components/EditorToolbar.svelte +206 -29
  71. package/src/lib/components/LinkPicker.svelte +14 -6
  72. package/src/lib/components/MarkdownEditor.svelte +80 -34
  73. package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
  74. package/src/lib/components/RenameDialog.svelte +13 -4
  75. package/src/lib/components/WebLinkDialog.svelte +89 -0
  76. package/src/lib/components/cairn-admin.css +26 -4
  77. package/src/lib/components/editor-highlight.ts +67 -0
  78. package/src/lib/components/link-completion.ts +10 -3
  79. package/src/lib/components/markdown-directives.ts +23 -0
  80. package/src/lib/components/markdown-format.ts +118 -13
  81. package/src/lib/content/pending.ts +24 -0
  82. package/src/lib/diagnostics/conditions.ts +75 -2
  83. package/src/lib/doctor/bin.ts +45 -0
  84. package/src/lib/doctor/check-send.ts +43 -0
  85. package/src/lib/doctor/checks-cloudflare.ts +222 -0
  86. package/src/lib/doctor/checks-github.ts +63 -0
  87. package/src/lib/doctor/checks-local.ts +119 -0
  88. package/src/lib/doctor/cloudflare-api.ts +33 -0
  89. package/src/lib/doctor/index.ts +93 -0
  90. package/src/lib/doctor/report.ts +30 -0
  91. package/src/lib/doctor/run.ts +23 -0
  92. package/src/lib/doctor/types.ts +52 -0
  93. package/src/lib/doctor/wrangler-config.ts +142 -0
  94. package/src/lib/github/branches.ts +83 -0
  95. package/src/lib/github/signing.ts +13 -6
  96. package/src/lib/log/events.ts +4 -0
  97. package/src/lib/sveltekit/content-routes.ts +400 -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
+ }
@@ -3,10 +3,14 @@
3
3
  // to CodeMirror's CompletionSource. The editor wires the source through a generic completionSources
4
4
  // prop, so this stays the only link-aware piece and the seam itself knows nothing about links.
5
5
  import type { Completion, CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
6
- import { syntaxTree } from '@codemirror/language';
7
6
  import type { LinkTarget } from '../content/manifest.js';
8
7
  import { formatCairnToken, escapeLinkText } from '../content/links.js';
9
8
 
9
+ // EditPage imports this module statically, so a static @codemirror value import here would pull
10
+ // CodeMirror into a consumer's server bundle. syntaxTree resolves lazily inside the source
11
+ // instead (a CompletionSource may return a Promise), cached after the first completion.
12
+ let langMod: typeof import('@codemirror/language') | null = null;
13
+
10
14
  /** The known concepts in display order; an unlisted concept sorts after these under its own name. */
11
15
  const CONCEPT_SECTIONS: Record<string, { name: string; rank: number }> = {
12
16
  pages: { name: 'Pages', rank: 0 },
@@ -41,14 +45,17 @@ export function linkCompletions(targets: LinkTarget[], query: string): Completio
41
45
  * whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
42
46
  * filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
43
47
  export function cairnLinkCompletionSource(targets: LinkTarget[]): CompletionSource {
44
- return (context: CompletionContext): CompletionResult | null => {
48
+ return async (context: CompletionContext): Promise<CompletionResult | null> => {
45
49
  const line = context.state.doc.lineAt(context.pos);
46
50
  const before = context.state.sliceDoc(line.from, context.pos);
47
51
  const trigger = matchCairnTrigger(before);
48
52
  if (!trigger) return null;
49
53
  // Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
50
54
  // the build resolver does not look inside code. The node name carries "Code" for both forms.
51
- const node = syntaxTree(context.state).resolveInner(context.pos, -1);
55
+ langMod ??= await import('@codemirror/language');
56
+ // The first completion awaits the import above, so the request may already be stale here.
57
+ if (context.aborted) return null;
58
+ const node = langMod.syntaxTree(context.state).resolveInner(context.pos, -1);
52
59
  for (let n: typeof node | null = node; n; n = n.parent) {
53
60
  if (/Code/.test(n.name)) return null;
54
61
  }
@@ -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
+ }
@@ -18,19 +18,27 @@ export interface CairnCondition {
18
18
  why: string;
19
19
  /** The fix, often a command. */
20
20
  remediation: string;
21
- /** Anchor into the readiness checklist doc, filled in when that doc lands (Pass 3). */
21
+ /**
22
+ * The condition's section in the readiness checklist, written as
23
+ * 'cloudflare-readiness.md#<heading-slug>' so a doc can link it relative to docs/guides/.
24
+ * The check:readiness gate parses the part after '#' and asserts the heading exists; two
25
+ * conditions may share a section. Every entry carries one unless the gate's allowlist
26
+ * excuses it.
27
+ */
22
28
  docsAnchor?: string;
23
29
  /** The log vocabulary event this condition correlates with, if any. */
24
30
  logEvent?: CairnLogEvent;
25
31
  }
26
32
 
27
- const REGISTRY: Record<string, CairnCondition> = {
33
+ // Exported for the freeze test only; resolve entries through condition() everywhere else.
34
+ export const REGISTRY: Record<string, CairnCondition> = {
28
35
  'edge.https-not-forced': {
29
36
  id: 'edge.https-not-forced',
30
37
  severity: 'blocker',
31
38
  title: 'Always Use HTTPS is off',
32
39
  why: 'The JS-free admin sign-in posts a form, and the framework CSRF guard rejects a form POST whose origin scheme does not match, so an admin reached over http hits an opaque 403.',
33
40
  remediation: 'Turn on Always Use HTTPS for the zone under SSL/TLS, Edge Certificates, and keep HSTS on.',
41
+ docsAnchor: 'cloudflare-readiness.md#force-https-at-the-edge',
34
42
  logEvent: 'guard.rejected',
35
43
  },
36
44
  'auth.csrf-token-invalid': {
@@ -39,6 +47,7 @@ const REGISTRY: Record<string, CairnCondition> = {
39
47
  title: 'Admin CSRF token check failed',
40
48
  why: 'An admin form POST carried no valid __Host-cairn_csrf double-submit token, usually a stale tab or blocked cookies.',
41
49
  remediation: 'Open the sign-in page fresh, allow cookies for the site, and request a new link.',
50
+ docsAnchor: 'cloudflare-readiness.md#admin-csrf-token-rejected',
42
51
  logEvent: 'guard.rejected',
43
52
  },
44
53
  'auth.csrf-origin-mismatch': {
@@ -47,6 +56,7 @@ const REGISTRY: Record<string, CairnCondition> = {
47
56
  title: 'Non-admin form Origin rejected',
48
57
  why: "A non-admin unsafe form POST carried an Origin that did not match the site, so cairn's restored framework Origin check rejected it.",
49
58
  remediation: 'Post the form from the same origin, or check a proxy that strips or rewrites the Origin header.',
59
+ docsAnchor: 'cloudflare-readiness.md#non-admin-origin-rejected',
50
60
  logEvent: 'guard.rejected',
51
61
  },
52
62
  'email.sender-not-onboarded': {
@@ -55,6 +65,7 @@ const REGISTRY: Record<string, CairnCondition> = {
55
65
  title: 'Email sending domain is not onboarded',
56
66
  why: 'The from-address domain has no enabled Cloudflare sending subdomain, so env.EMAIL.send has no aligned sender and the magic-link send throws E_SENDER_NOT_VERIFIED. No editor can sign in.',
57
67
  remediation: 'Onboard the sending domain with `wrangler email sending enable <domain>`, then re-deploy. The domain must match branding.from.',
68
+ docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
58
69
  logEvent: 'auth.link.send_failed',
59
70
  },
60
71
  'email.send-failed': {
@@ -63,10 +74,72 @@ const REGISTRY: Record<string, CairnCondition> = {
63
74
  title: 'Magic-link email send failed',
64
75
  why: 'The magic-link send threw for a reason other than a missing sender onboarding (a delivery error, a binding misconfiguration, or a custom sender failure), so the editor never received a link.',
65
76
  remediation: 'Read the auth.link.send_failed log record (the code and error fields) in Workers Logs, and check the EMAIL binding and the sender configuration.',
77
+ docsAnchor: 'cloudflare-readiness.md#onboard-the-sending-domain',
66
78
  logEvent: 'auth.link.send_failed',
67
79
  },
80
+ 'config.bindings-missing': {
81
+ id: 'config.bindings-missing',
82
+ severity: 'blocker',
83
+ title: 'Wrangler bindings are missing',
84
+ why: 'The wrangler config declares no send_email binding named EMAIL or no D1 binding named AUTH_DB, so the magic-link send or the session store has nothing to call and no editor can sign in.',
85
+ remediation: 'Declare the send_email binding as EMAIL and the d1_databases binding as AUTH_DB in wrangler.jsonc (or wrangler.toml), then re-deploy.',
86
+ docsAnchor: 'cloudflare-readiness.md#deploy-the-worker-with-its-bindings',
87
+ },
88
+ 'config.observability-off': {
89
+ id: 'config.observability-off',
90
+ severity: 'warning',
91
+ title: 'Workers Logs has no sink',
92
+ why: 'observability.enabled is not true in the wrangler config, so the structured log records go nowhere and a runtime failure leaves nothing to read.',
93
+ remediation: 'Set observability.enabled to true in wrangler.jsonc, then re-deploy.',
94
+ docsAnchor: 'cloudflare-readiness.md#turn-on-observability',
95
+ },
96
+ 'config.csrf-disable-missing': {
97
+ id: 'config.csrf-disable-missing',
98
+ severity: 'warning',
99
+ title: 'Framework CSRF check is not handed off',
100
+ why: "The CSRF authority is not handed to cairn cleanly. Either svelte.config.js does not carry csrf: { checkOrigin: false }, so SvelteKit's own Origin check runs ahead of cairn's guard and rejects an admin form POST that arrives without an Origin header, or the disable is present with no cairn guard wired in src/hooks.server.ts, which leaves the site with no CSRF protection at all.",
101
+ remediation: "Set csrf: { checkOrigin: false } in svelte.config.js and wire createAuthGuard into src/hooks.server.ts; cairn's guard owns the Origin and double-submit token checks.",
102
+ docsAnchor: 'cloudflare-readiness.md#hand-cairn-the-csrf-authority',
103
+ },
104
+ 'config.site-config-invalid': {
105
+ id: 'config.site-config-invalid',
106
+ severity: 'blocker',
107
+ title: 'Site config does not validate',
108
+ why: 'site.config.yaml fails to parse or fails the URL-policy validation, so the build and the admin cannot resolve the content concepts.',
109
+ remediation: 'Correct site.config.yaml; the parse or validation error names the failing field or URL-policy rule.',
110
+ docsAnchor: 'cloudflare-readiness.md#validate-the-site-config',
111
+ },
112
+ 'edge.hsts-off': {
113
+ id: 'edge.hsts-off',
114
+ severity: 'warning',
115
+ title: 'HSTS is off',
116
+ why: 'The zone sends no Strict-Transport-Security header with a meaningful max-age, so browsers do not pin https and a later http visit can still hit the admin guard rejection.',
117
+ remediation: 'Turn on HSTS for the zone under SSL/TLS, Edge Certificates, with a max-age of at least six months.',
118
+ docsAnchor: 'cloudflare-readiness.md#turn-on-hsts',
119
+ },
120
+ 'auth.store-unreachable': {
121
+ id: 'auth.store-unreachable',
122
+ severity: 'blocker',
123
+ title: 'Auth store is unreachable',
124
+ why: 'The AUTH_DB D1 database is missing, lacks the auth schema, or holds no owner row, so no magic-link token can be minted and nobody can sign in.',
125
+ remediation: 'Create the database, apply the auth schema with `wrangler d1 execute <db> --remote --file ./migrations/0000_auth.sql`, seed the owner row, and check the AUTH_DB binding id in wrangler.jsonc.',
126
+ docsAnchor: 'cloudflare-readiness.md#provision-the-auth-store',
127
+ },
128
+ 'github.app-unreachable': {
129
+ id: 'github.app-unreachable',
130
+ severity: 'blocker',
131
+ title: 'GitHub App is unreachable',
132
+ why: 'The App key fails to parse, the App fails to authenticate, the installation token fails to mint, or the repository refuses a read, so saves and publishes cannot commit.',
133
+ remediation: 'Check GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY_B64 against the App settings, and confirm the App is installed on the repository.',
134
+ docsAnchor: 'cloudflare-readiness.md#install-the-github-app',
135
+ logEvent: 'github.unreachable',
136
+ },
68
137
  };
69
138
 
139
+ // The registry is shared identity, never working state; freeze every entry and the map itself.
140
+ for (const entry of Object.values(REGISTRY)) Object.freeze(entry);
141
+ Object.freeze(REGISTRY);
142
+
70
143
  /** Resolve a condition by id. Throws on an unknown id, since ids are compile-time constants. */
71
144
  export function condition(id: string): CairnCondition {
72
145
  const found = REGISTRY[id];
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // cairn-doctor: the environment preflight. A thin shell over index.ts (where the unit tests
3
+ // reach the logic): parse the flags, assemble the context with the real fetch and filesystem,
4
+ // run the default registry plus the opt-in live send, print the report. Bad flags go to
5
+ // stderr with exit 2; a failed check exits 1; a clean or all-skip run exits 0. The codes go
6
+ // through process.exitCode, never process.exit, so a piped stdout flushes the whole report
7
+ // before the process ends.
8
+ import { readFile } from 'node:fs/promises';
9
+ import { resolve } from 'node:path';
10
+ import { liveSendCheck } from './check-send.js';
11
+ import { contextFromEnv, defaultChecks, formatReport, parseArgs, runDoctor } from './index.js';
12
+
13
+ async function main(): Promise<void> {
14
+ let args: ReturnType<typeof parseArgs>;
15
+ try {
16
+ args = parseArgs(process.argv.slice(2));
17
+ } catch (err) {
18
+ console.error(err instanceof Error ? err.message : String(err));
19
+ process.exitCode = 2;
20
+ return;
21
+ }
22
+
23
+ const cwd = process.cwd();
24
+ const ctx = {
25
+ ...contextFromEnv(process.env, args, cwd),
26
+ fetch: globalThis.fetch,
27
+ readFile: async (relPath: string): Promise<string | null> => {
28
+ try {
29
+ return await readFile(resolve(cwd, relPath), 'utf8');
30
+ } catch (err) {
31
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
32
+ throw err;
33
+ }
34
+ },
35
+ };
36
+
37
+ const checks = defaultChecks();
38
+ if (args.sendTest) checks.push(liveSendCheck(args.sendTest));
39
+
40
+ const { results, failed } = await runDoctor(checks, ctx);
41
+ console.log(formatReport(results));
42
+ process.exitCode = failed > 0 ? 1 : 0;
43
+ }
44
+
45
+ await main();