@glw907/cairn-cms 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/dist/components/EditPage.svelte +94 -16
- package/dist/components/EditPage.svelte.d.ts +4 -1
- package/dist/components/EditorToolbar.svelte +79 -8
- package/dist/components/EditorToolbar.svelte.d.ts +10 -2
- package/dist/components/MarkdownEditor.svelte +20 -2
- package/dist/components/cairn-admin.css +57 -9
- package/dist/components/editor-highlight.d.ts +1 -0
- package/dist/components/editor-highlight.js +31 -8
- package/dist/components/markdown-directives.d.ts +10 -0
- package/dist/components/markdown-directives.js +54 -1
- package/dist/components/preview-doc.d.ts +27 -0
- package/dist/components/preview-doc.js +64 -0
- package/dist/content/compose.js +1 -0
- package/dist/content/types.d.ts +33 -0
- package/dist/diagnostics/conditions.js +24 -0
- package/dist/doctor/bin.js +30 -12
- package/dist/doctor/check-floors.d.ts +15 -0
- package/dist/doctor/check-floors.js +107 -0
- package/dist/doctor/check-probe.d.ts +3 -0
- package/dist/doctor/check-probe.js +123 -0
- package/dist/doctor/checks-github.js +1 -1
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +28 -2
- package/dist/doctor/cloudflare-api.js +2 -2
- package/dist/doctor/index.d.ts +28 -3
- package/dist/doctor/index.js +47 -6
- package/dist/doctor/types.d.ts +2 -0
- package/dist/doctor/wrangler-config.d.ts +4 -0
- package/dist/doctor/wrangler-config.js +11 -0
- package/dist/env.d.ts +2 -1
- package/dist/env.js +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/sveltekit/content-routes.d.ts +5 -1
- package/dist/sveltekit/content-routes.js +25 -17
- package/dist/sveltekit/guard.d.ts +8 -2
- package/dist/sveltekit/guard.js +3 -1
- package/dist/sveltekit/nav-routes.js +3 -9
- package/dist/vite/index.d.ts +16 -0
- package/dist/vite/index.js +57 -13
- package/package.json +2 -2
- package/src/lib/components/EditPage.svelte +94 -16
- package/src/lib/components/EditorToolbar.svelte +79 -8
- package/src/lib/components/MarkdownEditor.svelte +20 -2
- package/src/lib/components/cairn-admin.css +59 -0
- package/src/lib/components/editor-highlight.ts +32 -7
- package/src/lib/components/markdown-directives.ts +51 -1
- package/src/lib/components/preview-doc.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/types.ts +32 -0
- package/src/lib/diagnostics/conditions.ts +24 -0
- package/src/lib/doctor/bin.ts +35 -10
- package/src/lib/doctor/check-floors.ts +124 -0
- package/src/lib/doctor/check-probe.ts +138 -0
- package/src/lib/doctor/checks-github.ts +3 -1
- package/src/lib/doctor/checks-local.ts +28 -2
- package/src/lib/doctor/cloudflare-api.ts +4 -2
- package/src/lib/doctor/index.ts +67 -6
- package/src/lib/doctor/types.ts +2 -0
- package/src/lib/doctor/wrangler-config.ts +11 -0
- package/src/lib/env.ts +9 -4
- package/src/lib/index.ts +2 -0
- package/src/lib/sveltekit/content-routes.ts +29 -17
- package/src/lib/sveltekit/guard.ts +4 -2
- package/src/lib/sveltekit/nav-routes.ts +3 -10
- package/src/lib/vite/index.ts +71 -17
|
@@ -5,7 +5,7 @@ import { HighlightStyle } from '@codemirror/language';
|
|
|
5
5
|
import { tags } from '@lezer/highlight';
|
|
6
6
|
import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from '@codemirror/view';
|
|
7
7
|
import { RangeSetBuilder } from '@codemirror/state';
|
|
8
|
-
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
8
|
+
import { directiveLineKind, fenceDepths, findInlineDirectives } from './markdown-directives.js';
|
|
9
9
|
|
|
10
10
|
/** Markdown token colors over the admin theme variables. */
|
|
11
11
|
export function cairnHighlightStyle(): HighlightStyle {
|
|
@@ -27,19 +27,40 @@ export function cairnHighlightStyle(): HighlightStyle {
|
|
|
27
27
|
// learns what the line is without leaving the page.
|
|
28
28
|
const MACHINERY_HINT = 'Layout marker. Edit the text between these lines and leave this line as it is.';
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Nesting deeper than three steps shares the third visual step; the depth model itself is unbounded.
|
|
31
|
+
const DEPTH_STEPS = [1, 2, 3];
|
|
32
|
+
|
|
33
|
+
const fenceLines = DEPTH_STEPS.map((d) =>
|
|
34
|
+
Decoration.line({ class: `cm-cairn-directive-fence cm-cairn-depth-${d}`, attributes: { title: MACHINERY_HINT } }),
|
|
35
|
+
);
|
|
36
|
+
const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
|
|
31
37
|
const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
|
|
32
38
|
const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
// Depth needs the whole document, since a visible line's containers can open above the viewport.
|
|
41
|
+
// One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
|
|
42
|
+
// is well under a millisecond. The plugin caches the result, so the scan reruns only when the
|
|
43
|
+
// document changes and a scroll rebuilds the viewport decorations from the cached array.
|
|
44
|
+
function docDepths(view: EditorView): (number | null)[] {
|
|
45
|
+
const doc = view.state.doc;
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
|
|
48
|
+
return fenceDepths(lines);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildDirectiveDecorations(view: EditorView, depths: (number | null)[]): DecorationSet {
|
|
35
52
|
const builder = new RangeSetBuilder<Decoration>();
|
|
36
53
|
for (const { from, to } of view.visibleRanges) {
|
|
37
54
|
for (let pos = from; pos <= to; ) {
|
|
38
55
|
const line = view.state.doc.lineAt(pos);
|
|
39
56
|
const kind = directiveLineKind(line.text);
|
|
40
|
-
|
|
57
|
+
const depth = Math.min(depths[line.number - 1] ?? 0, DEPTH_STEPS.length);
|
|
58
|
+
// A fence-shaped line at depth 0 is one the depth scan disowned (a documented example
|
|
59
|
+
// inside a code block, outside any container); it gets no machinery treatment.
|
|
60
|
+
if (kind === 'fence' && depth > 0) builder.add(line.from, line.from, fenceLines[depth - 1]);
|
|
41
61
|
else if (kind === 'leaf') builder.add(line.from, line.from, leafLine);
|
|
42
|
-
else {
|
|
62
|
+
else if (kind === null) {
|
|
63
|
+
if (depth > 0) builder.add(line.from, line.from, contentLines[depth - 1]);
|
|
43
64
|
for (const r of findInlineDirectives(line.text)) {
|
|
44
65
|
builder.add(line.from + r.from, line.from + r.to, inlineMark);
|
|
45
66
|
}
|
|
@@ -55,11 +76,15 @@ export function cairnDirectivePlugin() {
|
|
|
55
76
|
return ViewPlugin.fromClass(
|
|
56
77
|
class {
|
|
57
78
|
decorations: DecorationSet;
|
|
79
|
+
depths: (number | null)[];
|
|
58
80
|
constructor(view: EditorView) {
|
|
59
|
-
this.
|
|
81
|
+
this.depths = docDepths(view);
|
|
82
|
+
this.decorations = buildDirectiveDecorations(view, this.depths);
|
|
60
83
|
}
|
|
61
84
|
update(update: ViewUpdate) {
|
|
62
|
-
if (update.docChanged
|
|
85
|
+
if (update.docChanged) this.depths = docDepths(update.view);
|
|
86
|
+
if (update.docChanged || update.viewportChanged)
|
|
87
|
+
this.decorations = buildDirectiveDecorations(update.view, this.depths);
|
|
63
88
|
}
|
|
64
89
|
},
|
|
65
90
|
{ decorations: (v) => v.decorations },
|
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
// styled distinctly so an editor can tell component scaffolding from prose). Pure functions; the
|
|
3
3
|
// CodeMirror decoration plugin wraps them.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// A container fence: three or more colons, then an optional name, an optional [label], and
|
|
6
|
+
// optional {attrs}, in remark-directive order. The name is captured so the depth scan below can
|
|
7
|
+
// tell an opener (named) from a closer (bare colons). Matching is tolerant of stray whitespace,
|
|
8
|
+
// the same posture as the leaf form: a slightly off fence should still read as machinery.
|
|
9
|
+
const FENCE = /^\s{0,3}:{3,}\s*([\w-]*)\s*(\[[^\]]*\])?\s*(\{[^}]*\})?\s*$/;
|
|
6
10
|
const LEAF = /^\s{0,3}::[\w-]+(\[[^\]]*\])?(\{[^}]*\})?\s*$/;
|
|
7
11
|
const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
|
|
8
12
|
|
|
13
|
+
// A fenced code block's delimiter: three or more backticks or tildes, indent-tolerant like the
|
|
14
|
+
// directive forms. The depth scan tracks these so a documented ::: example inside a code block
|
|
15
|
+
// never opens a real container.
|
|
16
|
+
const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
17
|
+
|
|
9
18
|
/** Classify a whole line as a container fence, a leaf directive, or neither. */
|
|
10
19
|
export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
|
|
11
20
|
if (FENCE.test(line)) return 'fence';
|
|
@@ -13,6 +22,47 @@ export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
|
|
|
13
22
|
return null;
|
|
14
23
|
}
|
|
15
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The 1-based container depth each line sits at, or null outside any container. A named fence
|
|
27
|
+
* opens a container; a bare fence closes the most recent one (colon counts are not trusted for
|
|
28
|
+
* pairing, since authors vary them). An opener and its closer share the opener's depth, and a
|
|
29
|
+
* line between them carries the depth of its innermost container. Lines inside a fenced code
|
|
30
|
+
* block are plain content, so a documented ::: example cannot open a phantom container running
|
|
31
|
+
* to end of document. Author errors are tolerated: an unmatched closer reads as depth 1 and the
|
|
32
|
+
* count never goes below zero.
|
|
33
|
+
*/
|
|
34
|
+
export function fenceDepths(lines: string[]): (number | null)[] {
|
|
35
|
+
const depths: (number | null)[] = [];
|
|
36
|
+
let open = 0;
|
|
37
|
+
// The marker character that opened the current code block, or null outside one. Only a line
|
|
38
|
+
// opening with the same character closes it, so tildes inside a backtick block stay literal.
|
|
39
|
+
let codeMarker: string | null = null;
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const code = CODE_FENCE.exec(line);
|
|
42
|
+
if (code) {
|
|
43
|
+
if (codeMarker === null) codeMarker = code[1][0];
|
|
44
|
+
else if (code[1][0] === codeMarker) codeMarker = null;
|
|
45
|
+
depths.push(open > 0 ? open : null);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (codeMarker !== null) {
|
|
49
|
+
depths.push(open > 0 ? open : null);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const fence = FENCE.exec(line);
|
|
53
|
+
if (!fence) {
|
|
54
|
+
depths.push(open > 0 ? open : null);
|
|
55
|
+
} else if (fence[1]) {
|
|
56
|
+
open += 1;
|
|
57
|
+
depths.push(open);
|
|
58
|
+
} else {
|
|
59
|
+
depths.push(Math.max(open, 1));
|
|
60
|
+
if (open > 0) open -= 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return depths;
|
|
64
|
+
}
|
|
65
|
+
|
|
16
66
|
/** Inline directive ranges (`:name[...]{...}`) within a line of text. */
|
|
17
67
|
export function findInlineDirectives(text: string): { from: number; to: number }[] {
|
|
18
68
|
const out: { from: number; to: number }[] = [];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// cairn-cms: the edit page's preview-frame document. The admin's chrome isolation keeps the
|
|
2
|
+
// site's CSS out of the admin document, so EditPage renders the preview inside a sandboxed
|
|
3
|
+
// iframe whose document links the site's own stylesheets from the adapter's preview knob. This
|
|
4
|
+
// module builds that iframe's srcdoc as one pure string, so its shape is unit-testable, and it
|
|
5
|
+
// carries the device table the frame's width control offers.
|
|
6
|
+
|
|
7
|
+
import { escapeHtml } from '../escape.js';
|
|
8
|
+
import type { ResolvedPreview } from '../content/types.js';
|
|
9
|
+
|
|
10
|
+
/** One width the preview frame can take. */
|
|
11
|
+
export interface PreviewDevice {
|
|
12
|
+
id: 'desktop' | 'tablet' | 'phone' | 'small';
|
|
13
|
+
/** The device menu label, also the frame caption's first half. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Frame width in CSS pixels; null fills the pane (Desktop). */
|
|
16
|
+
width: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A preview device's id, the value the page persists. */
|
|
20
|
+
export type PreviewDeviceId = PreviewDevice['id'];
|
|
21
|
+
|
|
22
|
+
/** The four widths the device menu offers, in menu order. Desktop leads as the default. */
|
|
23
|
+
export const previewDevices: PreviewDevice[] = [
|
|
24
|
+
{ id: 'desktop', label: 'Desktop', width: null },
|
|
25
|
+
{ id: 'tablet', label: 'Tablet', width: 768 },
|
|
26
|
+
{ id: 'phone', label: 'Phone', width: 390 },
|
|
27
|
+
{ id: 'small', label: 'Small phone', width: 320 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** The table row for a device id. The id type makes a miss impossible; the fallback satisfies find. */
|
|
31
|
+
export function previewDevice(id: PreviewDeviceId): PreviewDevice {
|
|
32
|
+
return previewDevices.find((d) => d.id === id) ?? previewDevices[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A device's user-facing text, shared by the toolbar's menu items and the frame caption: the
|
|
36
|
+
* label with its width when one is fixed, so the value reaches assistive tech at pick time. */
|
|
37
|
+
export function deviceLabel(d: PreviewDevice): string {
|
|
38
|
+
return d.width === null ? d.label : `${d.label} · ${d.width} px`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the preview iframe's srcdoc: a complete document linking the site's stylesheets around
|
|
43
|
+
* the rendered entry html. The html comes from the site's floored render pipeline, which already
|
|
44
|
+
* stripped scripts and event handlers, so it embeds unescaped; the frame's empty `sandbox` is
|
|
45
|
+
* belt and braces over that floor. The parameter is the flat `ResolvedPreview` shape `editLoad`
|
|
46
|
+
* ships, so the per-concept map can never reach the frame document by construction.
|
|
47
|
+
* `preview` null (a site without the adapter knob) yields a styleless but complete document.
|
|
48
|
+
*/
|
|
49
|
+
export function buildPreviewDoc(html: string, preview: ResolvedPreview | null): string {
|
|
50
|
+
const links = (preview?.stylesheets ?? [])
|
|
51
|
+
.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`)
|
|
52
|
+
.join('\n');
|
|
53
|
+
const bodyAttrs = preview?.bodyClass ? ` class="${escapeHtml(preview.bodyClass)}"` : '';
|
|
54
|
+
const content = preview?.containerClass
|
|
55
|
+
? `<div class="${escapeHtml(preview.containerClass)}">${html}</div>`
|
|
56
|
+
: html;
|
|
57
|
+
// The reset sits BEFORE the site links so the site's CSS wins every collision: it only clears
|
|
58
|
+
// the default body margin and pins a white ground for sheets that assume one.
|
|
59
|
+
//
|
|
60
|
+
// The base tag is what makes links inert. The empty sandbox alone does not: a sandboxed
|
|
61
|
+
// context may still navigate itself, and a srcdoc document resolves relative hrefs against the
|
|
62
|
+
// parent's base URL, so a clicked fragment or root link could render the admin login inside
|
|
63
|
+
// the frame. Targeting every link at a new tab turns each click into a popup, and the sandbox
|
|
64
|
+
// (which grants no allow-popups) blocks it, so a proofing click goes nowhere.
|
|
65
|
+
return [
|
|
66
|
+
'<!doctype html>',
|
|
67
|
+
'<html>',
|
|
68
|
+
'<head>',
|
|
69
|
+
'<meta charset="utf-8">',
|
|
70
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
71
|
+
'<base target="_blank">',
|
|
72
|
+
'<style>body{margin:0;background:#fff}</style>',
|
|
73
|
+
links,
|
|
74
|
+
'</head>',
|
|
75
|
+
`<body${bodyAttrs}>`,
|
|
76
|
+
content,
|
|
77
|
+
'</body>',
|
|
78
|
+
'</html>',
|
|
79
|
+
]
|
|
80
|
+
.filter((line) => line !== '')
|
|
81
|
+
.join('\n');
|
|
82
|
+
}
|
package/src/lib/content/types.ts
CHANGED
|
@@ -154,6 +154,33 @@ export interface NavMenuConfig {
|
|
|
154
154
|
maxDepth?: number;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* How the edit page's preview frame reproduces the live site's content styling. The admin
|
|
159
|
+
* deliberately never loads the site's CSS (chrome isolation), so a design-accurate preview needs
|
|
160
|
+
* the site to name its stylesheets for the preview frame; without this knob the preview renders
|
|
161
|
+
* unstyled markup. The frame's srcdoc pins a white body background as a deliberately overridable
|
|
162
|
+
* default, so a site whose ground is not white should state its body background in its own
|
|
163
|
+
* stylesheet.
|
|
164
|
+
*/
|
|
165
|
+
export interface PreviewConfig {
|
|
166
|
+
/** Absolute or root-relative URLs of the site's compiled stylesheets, linked inside the
|
|
167
|
+
* preview document. A Vite `?url` import of the site's CSS resolves the hashed asset URL. */
|
|
168
|
+
stylesheets: string[];
|
|
169
|
+
/** Class list applied to the preview document's body, for theme or typography roots. */
|
|
170
|
+
bodyClass?: string;
|
|
171
|
+
/** Class list for a wrapper element around the rendered content, reproducing the site's
|
|
172
|
+
* content container (a prose or measure class). Omitted renders the content bare. */
|
|
173
|
+
containerClass?: string;
|
|
174
|
+
/** Per-concept overrides of bodyClass and containerClass, keyed by concept id. An entry's
|
|
175
|
+
* preview resolves the override for its concept over the top-level values; stylesheets are
|
|
176
|
+
* always shared. */
|
|
177
|
+
byConcept?: Record<string, { bodyClass?: string; containerClass?: string }>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The flat preview shape `editLoad` ships to the edit page: the top-level `PreviewConfig`
|
|
181
|
+
* values with the entry's concept override applied, and no `byConcept` map. */
|
|
182
|
+
export type ResolvedPreview = Omit<PreviewConfig, 'byConcept'>;
|
|
183
|
+
|
|
157
184
|
/** Reserved asset slot (seam 4). Typed and unused in the rebuild; R7/R9 read it later with no contract change. */
|
|
158
185
|
export interface AssetConfig {
|
|
159
186
|
/** Repo-relative asset roots, e.g. ["static/images"]. */
|
|
@@ -187,6 +214,9 @@ export interface CairnAdapter {
|
|
|
187
214
|
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
188
215
|
icons?: IconSet;
|
|
189
216
|
navMenu?: NavMenuConfig;
|
|
217
|
+
/** The live site's content styling for the preview frame. The admin's chrome isolation keeps
|
|
218
|
+
* the site's CSS out of the admin document, so the preview frame links these instead. */
|
|
219
|
+
preview?: PreviewConfig;
|
|
190
220
|
assets?: AssetConfig;
|
|
191
221
|
}
|
|
192
222
|
|
|
@@ -285,6 +315,8 @@ export interface CairnRuntime {
|
|
|
285
315
|
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
286
316
|
icons?: IconSet;
|
|
287
317
|
navMenu?: NavMenuConfig;
|
|
318
|
+
/** The live site's content styling for the preview frame; passed through from the adapter. */
|
|
319
|
+
preview?: PreviewConfig;
|
|
288
320
|
assets?: AssetConfig;
|
|
289
321
|
/** Admin panels contributed by extensions (Mode 2). Empty until Plan 09 wires the dispatch route. */
|
|
290
322
|
adminPanels?: AdminPanel[];
|
|
@@ -101,6 +101,14 @@ export const REGISTRY: Record<string, CairnCondition> = {
|
|
|
101
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
102
|
docsAnchor: 'cloudflare-readiness.md#hand-cairn-the-csrf-authority',
|
|
103
103
|
},
|
|
104
|
+
'config.public-origin-invalid': {
|
|
105
|
+
id: 'config.public-origin-invalid',
|
|
106
|
+
severity: 'blocker',
|
|
107
|
+
title: 'PUBLIC_ORIGIN is missing or invalid',
|
|
108
|
+
why: 'PUBLIC_ORIGIN is unset, does not parse as a URL, or uses http on a non-local host. The magic-link confirmation links and the absolute feed URLs derive from it, config-only so a forged Host header cannot redirect a link, and sign-in cannot mint a usable link without it.',
|
|
109
|
+
remediation: "Set PUBLIC_ORIGIN to the site's canonical https origin in the wrangler config vars (with .dev.vars carrying the local http override), then re-deploy; http passes only on localhost or 127.0.0.1.",
|
|
110
|
+
docsAnchor: 'cloudflare-readiness.md#set-the-public-origin',
|
|
111
|
+
},
|
|
104
112
|
'config.site-config-invalid': {
|
|
105
113
|
id: 'config.site-config-invalid',
|
|
106
114
|
severity: 'blocker',
|
|
@@ -109,6 +117,14 @@ export const REGISTRY: Record<string, CairnCondition> = {
|
|
|
109
117
|
remediation: 'Correct site.config.yaml; the parse or validation error names the failing field or URL-policy rule.',
|
|
110
118
|
docsAnchor: 'cloudflare-readiness.md#validate-the-site-config',
|
|
111
119
|
},
|
|
120
|
+
'config.dependency-floors-unmet': {
|
|
121
|
+
id: 'config.dependency-floors-unmet',
|
|
122
|
+
severity: 'blocker',
|
|
123
|
+
title: 'A framework dependency sits below the engine floor',
|
|
124
|
+
why: 'The lockfile resolves svelte or @sveltejs/kit below the range the engine declares as a peer. Consumer sites compile the shipped .svelte sources, so a below-floor compiler bites silently at build time; svelte 5.56.1 miscompiles parenthesized boolean groupings, which is why the svelte floor is ^5.56.3.',
|
|
125
|
+
remediation: "Raise the devDependency range in the site's package.json to the engine peer range and reinstall so the lockfile re-resolves, for example `npm install --save-dev svelte@^5.56.3`.",
|
|
126
|
+
docsAnchor: 'cloudflare-readiness.md#meet-the-dependency-floors',
|
|
127
|
+
},
|
|
112
128
|
'edge.hsts-off': {
|
|
113
129
|
id: 'edge.hsts-off',
|
|
114
130
|
severity: 'warning',
|
|
@@ -134,6 +150,14 @@ export const REGISTRY: Record<string, CairnCondition> = {
|
|
|
134
150
|
docsAnchor: 'cloudflare-readiness.md#install-the-github-app',
|
|
135
151
|
logEvent: 'github.unreachable',
|
|
136
152
|
},
|
|
153
|
+
'admin.login-probe-failed': {
|
|
154
|
+
id: 'admin.login-probe-failed',
|
|
155
|
+
severity: 'blocker',
|
|
156
|
+
title: 'Live admin login probe failed',
|
|
157
|
+
why: 'A live request to the deployed admin did not answer with the working sign-in envelope (the login page, its CSRF cookie and hidden field, and the request action), so a real editor cannot sign in either. A probe failure has many possible causes; the detail line names the assertion that failed.',
|
|
158
|
+
remediation: 'Read the failed assertion in the detail line, run the full doctor against the same site, and work through the deploy guide; the other checks narrow the cause.',
|
|
159
|
+
docsAnchor: 'cloudflare-readiness.md#probe-the-deployed-admin',
|
|
160
|
+
},
|
|
137
161
|
};
|
|
138
162
|
|
|
139
163
|
// The registry is shared identity, never working state; freeze every entry and the map itself.
|
package/src/lib/doctor/bin.ts
CHANGED
|
@@ -7,8 +7,17 @@
|
|
|
7
7
|
// before the process ends.
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
9
|
import { resolve } from 'node:path';
|
|
10
|
+
import { liveProbeCheck } from './check-probe.js';
|
|
10
11
|
import { liveSendCheck } from './check-send.js';
|
|
11
|
-
import {
|
|
12
|
+
import { readWranglerConfig } from './wrangler-config.js';
|
|
13
|
+
import {
|
|
14
|
+
contextFromEnv,
|
|
15
|
+
defaultChecks,
|
|
16
|
+
deriveMissingInputs,
|
|
17
|
+
formatReport,
|
|
18
|
+
parseArgs,
|
|
19
|
+
runDoctor,
|
|
20
|
+
} from './index.js';
|
|
12
21
|
|
|
13
22
|
async function main(): Promise<void> {
|
|
14
23
|
let args: ReturnType<typeof parseArgs>;
|
|
@@ -21,21 +30,37 @@ async function main(): Promise<void> {
|
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
const cwd = process.cwd();
|
|
33
|
+
const readFileUnderCwd = async (relPath: string): Promise<string | null> => {
|
|
34
|
+
try {
|
|
35
|
+
return await readFile(resolve(cwd, relPath), 'utf8');
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
// Fill inputs the flags and env left missing from the repo itself: from and repo off the
|
|
42
|
+
// adapter (through the vite arm, which exists only on this bin path, never in a Worker)
|
|
43
|
+
// and the account id off the wrangler config. The API token stays env-only.
|
|
44
|
+
const derived = await deriveMissingInputs(contextFromEnv(process.env, args, cwd), {
|
|
45
|
+
adapterFacts: async () => {
|
|
46
|
+
const { readAdapterFacts } = await import('../vite/index.js');
|
|
47
|
+
return readAdapterFacts(cwd);
|
|
48
|
+
},
|
|
49
|
+
wranglerAccountId: async () => (await readWranglerConfig(readFileUnderCwd))?.accountId,
|
|
50
|
+
});
|
|
24
51
|
const ctx = {
|
|
25
|
-
...
|
|
52
|
+
...derived,
|
|
26
53
|
fetch: globalThis.fetch,
|
|
27
|
-
readFile:
|
|
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
|
-
},
|
|
54
|
+
readFile: readFileUnderCwd,
|
|
35
55
|
};
|
|
36
56
|
|
|
37
57
|
const checks = defaultChecks();
|
|
38
58
|
if (args.sendTest) checks.push(liveSendCheck(args.sendTest));
|
|
59
|
+
// The probe is an opt-in network POST against a live site, so it joins only on --probe;
|
|
60
|
+
// the bare flag hands the URL resolution (the PUBLIC_ORIGIN input) to the check itself.
|
|
61
|
+
if (args.probe !== undefined) {
|
|
62
|
+
checks.push(liveProbeCheck(args.probe === true ? undefined : args.probe));
|
|
63
|
+
}
|
|
39
64
|
|
|
40
65
|
const { results, failed } = await runDoctor(checks, ctx);
|
|
41
66
|
console.log(formatReport(results));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The dependency-floors check. The engine's peer ranges have teeth only when something reads
|
|
2
|
+
// the consumer's lockfile, where a transitively pinned svelte can sit below the floor while
|
|
3
|
+
// package.json looks fine (the ecxc retrofit shipped svelte 5.56.0 that way). The check compares
|
|
4
|
+
// the resolved svelte and @sveltejs/kit versions in package-lock.json against the peer ranges
|
|
5
|
+
// the installed @glw907/cairn-cms declares, read at runtime so the floors live in one place.
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { fail, pass, skip } from './types.js';
|
|
8
|
+
import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
|
|
9
|
+
|
|
10
|
+
interface Version {
|
|
11
|
+
major: number;
|
|
12
|
+
minor: number;
|
|
13
|
+
patch: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Plain x.y.z only. A prerelease or build tag returns null, so the check skips rather than
|
|
17
|
+
// guessing how a tagged build orders against the floor.
|
|
18
|
+
function parseVersion(text: string): Version | null {
|
|
19
|
+
const m = text.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The engine's peers are simple caret ranges (^x.y.z, or ^x.y like the kit floor ^2.12), so
|
|
25
|
+
// this handles the caret form only; anything else returns null and the check skips for that
|
|
26
|
+
// dependency instead of approximating a full semver implementation.
|
|
27
|
+
function caretFloor(range: string): Version | null {
|
|
28
|
+
const m = range.match(/^\^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
|
|
29
|
+
if (!m) return null;
|
|
30
|
+
return { major: Number(m[1]), minor: Number(m[2] ?? 0), patch: Number(m[3] ?? 0) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function compareVersions(a: Version, b: Version): number {
|
|
34
|
+
return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A v2/v3 lockfile's packages map; v1 has none and the check skips.
|
|
38
|
+
interface LockPackages {
|
|
39
|
+
packages?: Record<string, { version?: unknown } | undefined>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function lockedVersion(lock: LockPackages, dep: string): string | undefined {
|
|
43
|
+
const version = lock.packages?.[`node_modules/${dep}`]?.version;
|
|
44
|
+
return typeof version === 'string' ? version : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Judge a lockfile's resolved framework versions against the engine's peer ranges. Pure, so the
|
|
49
|
+
* tests drive it table-style; the check object wires in the real lockfile and the real peers.
|
|
50
|
+
* A below-range version fails; a lockfile or entry the check cannot read skips, since a pnpm or
|
|
51
|
+
* yarn consumer carries no package-lock.json at all.
|
|
52
|
+
*/
|
|
53
|
+
export function dependencyFloorsResult(
|
|
54
|
+
lockText: string | null,
|
|
55
|
+
peers: Record<string, string>
|
|
56
|
+
): CheckResult {
|
|
57
|
+
if (lockText === null) {
|
|
58
|
+
return skip('no package-lock.json found (a pnpm or yarn lockfile is not read)');
|
|
59
|
+
}
|
|
60
|
+
let lock: LockPackages;
|
|
61
|
+
try {
|
|
62
|
+
lock = JSON.parse(lockText) as LockPackages;
|
|
63
|
+
} catch {
|
|
64
|
+
// Like the wrangler reader: never echo file content into the report.
|
|
65
|
+
return fail('package-lock.json did not parse');
|
|
66
|
+
}
|
|
67
|
+
if (lock.packages === undefined) {
|
|
68
|
+
return skip('package-lock.json carries no packages map (lockfile v1; reinstall with a current npm)');
|
|
69
|
+
}
|
|
70
|
+
const failures: string[] = [];
|
|
71
|
+
const skips: string[] = [];
|
|
72
|
+
const passes: string[] = [];
|
|
73
|
+
for (const [dep, range] of Object.entries(peers)) {
|
|
74
|
+
const floor = caretFloor(range);
|
|
75
|
+
if (floor === null) {
|
|
76
|
+
skips.push(`${dep}: the engine range ${range} is not a simple caret range`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const resolved = lockedVersion(lock, dep);
|
|
80
|
+
if (resolved === undefined) {
|
|
81
|
+
skips.push(`${dep}: no node_modules/${dep} entry in package-lock.json`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const version = parseVersion(resolved);
|
|
85
|
+
if (version === null) {
|
|
86
|
+
skips.push(`${dep}: resolved ${resolved} is not a plain x.y.z version`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// The caret bounds both ends: at or above the floor, same major. The engine's peers
|
|
90
|
+
// start at major 1 or higher, so the 0.x caret nuance never applies here.
|
|
91
|
+
if (compareVersions(version, floor) < 0) {
|
|
92
|
+
failures.push(`${dep} resolves to ${resolved}, below the engine floor ${range}`);
|
|
93
|
+
} else if (version.major !== floor.major) {
|
|
94
|
+
failures.push(`${dep} resolves to ${resolved}, outside the engine peer range ${range}`);
|
|
95
|
+
} else {
|
|
96
|
+
passes.push(`${dep} ${resolved}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (failures.length > 0) return fail(failures.join('; '));
|
|
100
|
+
if (skips.length > 0) return skip(skips.join('; '));
|
|
101
|
+
return pass(`${passes.join(' and ')} satisfy the engine peer ranges`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The engine's own declared peer ranges, read from the installed package.json at runtime so the
|
|
106
|
+
* floors are declared exactly once. The self-reference resolves through the consumer's
|
|
107
|
+
* node_modules in a real install and through the repo root during development.
|
|
108
|
+
*/
|
|
109
|
+
export function readEnginePeers(): Record<string, string> {
|
|
110
|
+
const require = createRequire(import.meta.url);
|
|
111
|
+
const pkg = require('@glw907/cairn-cms/package.json') as {
|
|
112
|
+
peerDependencies?: Record<string, string>;
|
|
113
|
+
};
|
|
114
|
+
return pkg.peerDependencies ?? {};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const configDependencyFloors: DoctorCheck = {
|
|
118
|
+
id: 'config.dependency-floors',
|
|
119
|
+
conditionId: 'config.dependency-floors-unmet',
|
|
120
|
+
title: 'Dependency floors',
|
|
121
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
122
|
+
return dependencyFloorsResult(await ctx.readFile('package-lock.json'), readEnginePeers());
|
|
123
|
+
},
|
|
124
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The doctor's opt-in live probe (--probe): one GET and one POST against a deployed admin,
|
|
2
|
+
// asserting the envelope a working sign-in presents. Zero side effects by construction: the
|
|
3
|
+
// POST submits a random non-editor address, and the engine's non-leak design answers a
|
|
4
|
+
// non-editor with the identical sent body while sending no email and minting no token, so the
|
|
5
|
+
// probe leaves nothing behind on the site. A factory rather than a check constant, the same
|
|
6
|
+
// shape as the live send: the check exists only when the bin receives --probe.
|
|
7
|
+
import { fail, pass, skip } from './types.js';
|
|
8
|
+
import type { CheckResult, DoctorCheck, DoctorContext } from './types.js';
|
|
9
|
+
import { csrfCookieName } from '../auth/crypto.js';
|
|
10
|
+
import { readWranglerConfig } from './wrangler-config.js';
|
|
11
|
+
|
|
12
|
+
const NO_URL: CheckResult = skip(
|
|
13
|
+
'pass --probe <url>, set PUBLIC_ORIGIN in the wrangler vars, or set PUBLIC_ORIGIN in the environment'
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/** Build the live-probe check. A missing url falls back to the PUBLIC_ORIGIN input at run time. */
|
|
17
|
+
export function liveProbeCheck(url?: string): DoctorCheck {
|
|
18
|
+
return {
|
|
19
|
+
id: 'admin.login-probe',
|
|
20
|
+
conditionId: 'admin.login-probe-failed',
|
|
21
|
+
title: 'Live admin login probe',
|
|
22
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
23
|
+
// The wrangler vars hold the value the deployed Worker reads, so they beat the local
|
|
24
|
+
// environment, the same precedence the public-origin check applies.
|
|
25
|
+
const base =
|
|
26
|
+
url ?? (await readWranglerConfig(ctx.readFile))?.publicOrigin ?? ctx.publicOrigin;
|
|
27
|
+
if (base === undefined) return NO_URL;
|
|
28
|
+
let origin: URL;
|
|
29
|
+
try {
|
|
30
|
+
origin = new URL(base);
|
|
31
|
+
} catch {
|
|
32
|
+
return fail(`probe URL does not parse: ${base}`);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return await probe(ctx, origin);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return fail(String(err));
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** GET /admin/login and assert the sign-in envelope, then hand the harvested token pair on. */
|
|
44
|
+
async function probe(ctx: DoctorContext, origin: URL): Promise<CheckResult> {
|
|
45
|
+
const res = await ctx.fetch(String(new URL('/admin/login', origin)));
|
|
46
|
+
if (res.status !== 200) {
|
|
47
|
+
return fail(`GET /admin/login returned ${res.status}, expected 200`);
|
|
48
|
+
}
|
|
49
|
+
const cookieName = csrfCookieName(origin.protocol === 'https:');
|
|
50
|
+
const cookieValue = setCookieValue(res.headers.getSetCookie(), cookieName);
|
|
51
|
+
if (cookieValue === undefined) {
|
|
52
|
+
return fail(`GET /admin/login set no ${cookieName} cookie`);
|
|
53
|
+
}
|
|
54
|
+
const html = await res.text();
|
|
55
|
+
const field = csrfFieldValue(html);
|
|
56
|
+
if (field === undefined) {
|
|
57
|
+
return fail('the login page carries no name="csrf" hidden field with a value');
|
|
58
|
+
}
|
|
59
|
+
if (!/<form[^>]*action="[^"]*\?\/request"/.test(html)) {
|
|
60
|
+
return fail('the login page carries no form posting the ?/request action');
|
|
61
|
+
}
|
|
62
|
+
return postRequestAction(ctx, origin, `${cookieName}=${cookieValue}`, field);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The named cookie's value from the Set-Cookie lines, or undefined when no line names it. */
|
|
66
|
+
function setCookieValue(lines: string[], name: string): string | undefined {
|
|
67
|
+
for (const line of lines) {
|
|
68
|
+
const eq = line.indexOf('=');
|
|
69
|
+
if (eq === -1 || line.slice(0, eq).trim() !== name) continue;
|
|
70
|
+
const rest = line.slice(eq + 1);
|
|
71
|
+
const semi = rest.indexOf(';');
|
|
72
|
+
return semi === -1 ? rest : rest.slice(0, semi);
|
|
73
|
+
}
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The csrf hidden field's value, tolerant of attribute order, or undefined when absent or empty. */
|
|
78
|
+
function csrfFieldValue(html: string): string | undefined {
|
|
79
|
+
const input = (html.match(/<input[^>]*>/g) ?? []).find((tag) => /name="csrf"/.test(tag));
|
|
80
|
+
if (input === undefined) return undefined;
|
|
81
|
+
return /value="([^"]+)"/.exec(input)?.[1];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* POST the request action and read its serialized result. The address is random and non-editor
|
|
86
|
+
* at the reserved example.invalid domain, so even a delivery bug could send nothing anywhere,
|
|
87
|
+
* and the engine's non-leak design makes the response indistinguishable from a real send.
|
|
88
|
+
*/
|
|
89
|
+
async function postRequestAction(
|
|
90
|
+
ctx: DoctorContext,
|
|
91
|
+
origin: URL,
|
|
92
|
+
cookie: string,
|
|
93
|
+
csrf: string
|
|
94
|
+
): Promise<CheckResult> {
|
|
95
|
+
const email = `cairn-doctor-probe-${Math.random().toString(36).slice(2, 10)}@example.invalid`;
|
|
96
|
+
const res = await ctx.fetch(String(new URL('/admin/login?/request', origin)), {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
100
|
+
cookie,
|
|
101
|
+
},
|
|
102
|
+
body: new URLSearchParams({ email, csrf }).toString(),
|
|
103
|
+
});
|
|
104
|
+
if (res.status !== 200) {
|
|
105
|
+
return fail(`POST ?/request returned ${res.status}, expected 200`);
|
|
106
|
+
}
|
|
107
|
+
// A no-Accept action POST answers with SvelteKit's serialized form-action JSON, shaped
|
|
108
|
+
// {"type":"success","status":200,"data":"<devalue array string>"}. The data field is a
|
|
109
|
+
// devalue encoding the probe reads by containment for the status literals, tolerant of
|
|
110
|
+
// encoding details it does not own, instead of pulling in a devalue parser.
|
|
111
|
+
let envelope: { type?: unknown; data?: unknown };
|
|
112
|
+
try {
|
|
113
|
+
envelope = (await res.json()) as { type?: unknown; data?: unknown };
|
|
114
|
+
} catch {
|
|
115
|
+
return fail('POST ?/request did not answer with the serialized action JSON');
|
|
116
|
+
}
|
|
117
|
+
if (envelope.type !== 'success') {
|
|
118
|
+
return fail(`POST ?/request answered type ${String(envelope.type)}, expected success`);
|
|
119
|
+
}
|
|
120
|
+
const data = typeof envelope.data === 'string' ? envelope.data : '';
|
|
121
|
+
if (data.includes('"send_error"')) {
|
|
122
|
+
return fail(
|
|
123
|
+
'the request action answered send_error; the magic-link send path is failing (see the email checks and the auth.link.send_failed log records)'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
// Every payload carries the "sent" field name, so the distinct status spellings go first.
|
|
127
|
+
if (data.includes('"throttled"')) {
|
|
128
|
+
return pass(
|
|
129
|
+
`sign-in envelope verified at ${origin.origin}; the request action answered throttled (a real cooldown window is active), which still proves the path`
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
if (data.includes('"sent"')) {
|
|
133
|
+
return pass(
|
|
134
|
+
`sign-in envelope verified at ${origin.origin}; the request action answered sent for a non-editor probe address`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return fail('POST ?/request answered success with an unrecognized payload');
|
|
138
|
+
}
|