@glw907/cairn-cms 0.41.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.
Files changed (162) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/README.md +2 -2
  3. package/dist/ambient.d.ts +9 -0
  4. package/dist/ambient.js +1 -0
  5. package/dist/components/AdminLayout.svelte +6 -8
  6. package/dist/components/CairnAdmin.svelte +67 -0
  7. package/dist/components/CairnAdmin.svelte.d.ts +35 -0
  8. package/dist/components/ConceptList.svelte +4 -5
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +107 -25
  12. package/dist/components/EditPage.svelte.d.ts +8 -10
  13. package/dist/components/EditorToolbar.svelte +79 -8
  14. package/dist/components/EditorToolbar.svelte.d.ts +10 -2
  15. package/dist/components/LoginPage.svelte +2 -2
  16. package/dist/components/LoginPage.svelte.d.ts +1 -1
  17. package/dist/components/ManageEditors.svelte +4 -3
  18. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  19. package/dist/components/MarkdownEditor.svelte +20 -2
  20. package/dist/components/cairn-admin.css +57 -9
  21. package/dist/components/editor-highlight.d.ts +1 -0
  22. package/dist/components/editor-highlight.js +31 -8
  23. package/dist/components/index.d.ts +1 -0
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/markdown-directives.d.ts +10 -0
  26. package/dist/components/markdown-directives.js +54 -1
  27. package/dist/components/markdown-format.d.ts +0 -8
  28. package/dist/components/markdown-format.js +0 -28
  29. package/dist/components/preview-doc.d.ts +27 -0
  30. package/dist/components/preview-doc.js +64 -0
  31. package/dist/content/compose.js +1 -0
  32. package/dist/content/links.d.ts +8 -0
  33. package/dist/content/links.js +28 -0
  34. package/dist/content/types.d.ts +35 -2
  35. package/dist/delivery/data.d.ts +3 -5
  36. package/dist/delivery/data.js +2 -3
  37. package/dist/delivery/feeds.js +1 -7
  38. package/dist/delivery/index.d.ts +2 -2
  39. package/dist/delivery/index.js +1 -1
  40. package/dist/delivery/manifest.d.ts +0 -5
  41. package/dist/delivery/manifest.js +5 -16
  42. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  43. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  44. package/dist/delivery/site-indexes.d.ts +3 -3
  45. package/dist/delivery/site-indexes.js +3 -3
  46. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  47. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  48. package/dist/delivery/sitemap.js +1 -3
  49. package/dist/delivery/xml.d.ts +2 -0
  50. package/dist/delivery/xml.js +11 -0
  51. package/dist/diagnostics/conditions.js +24 -0
  52. package/dist/doctor/bin.js +30 -12
  53. package/dist/doctor/check-floors.d.ts +15 -0
  54. package/dist/doctor/check-floors.js +107 -0
  55. package/dist/doctor/check-probe.d.ts +3 -0
  56. package/dist/doctor/check-probe.js +123 -0
  57. package/dist/doctor/checks-github.js +1 -1
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +28 -2
  60. package/dist/doctor/cloudflare-api.js +2 -2
  61. package/dist/doctor/index.d.ts +28 -3
  62. package/dist/doctor/index.js +47 -6
  63. package/dist/doctor/types.d.ts +2 -0
  64. package/dist/doctor/wrangler-config.d.ts +4 -0
  65. package/dist/doctor/wrangler-config.js +11 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +3 -2
  68. package/dist/env.js +12 -6
  69. package/dist/escape.d.ts +2 -0
  70. package/dist/escape.js +11 -0
  71. package/dist/github/credentials.d.ts +2 -1
  72. package/dist/github/credentials.js +10 -2
  73. package/dist/github/types.d.ts +2 -0
  74. package/dist/github/types.js +4 -0
  75. package/dist/index.d.ts +1 -1
  76. package/dist/log/events.d.ts +1 -1
  77. package/dist/nav/site-config.d.ts +2 -0
  78. package/dist/nav/site-config.js +2 -0
  79. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  80. package/dist/sveltekit/admin-dispatch.js +62 -0
  81. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  82. package/dist/sveltekit/cairn-admin.js +126 -0
  83. package/dist/sveltekit/condition-response.d.ts +1 -0
  84. package/dist/sveltekit/condition-response.js +25 -0
  85. package/dist/sveltekit/content-routes.d.ts +39 -15
  86. package/dist/sveltekit/content-routes.js +84 -50
  87. package/dist/sveltekit/guard.d.ts +8 -2
  88. package/dist/sveltekit/guard.js +18 -4
  89. package/dist/sveltekit/https-required-page.js +2 -1
  90. package/dist/sveltekit/index.d.ts +3 -1
  91. package/dist/sveltekit/index.js +2 -0
  92. package/dist/sveltekit/nav-routes.d.ts +3 -1
  93. package/dist/sveltekit/nav-routes.js +22 -19
  94. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  95. package/dist/sveltekit/static-admin-page.js +1 -8
  96. package/dist/sveltekit/types.d.ts +18 -11
  97. package/dist/vite/index.d.ts +16 -0
  98. package/dist/vite/index.js +57 -13
  99. package/package.json +6 -2
  100. package/src/lib/ambient.ts +19 -0
  101. package/src/lib/components/AdminLayout.svelte +6 -8
  102. package/src/lib/components/CairnAdmin.svelte +67 -0
  103. package/src/lib/components/ConceptList.svelte +4 -5
  104. package/src/lib/components/ConfirmPage.svelte +1 -1
  105. package/src/lib/components/EditPage.svelte +107 -25
  106. package/src/lib/components/EditorToolbar.svelte +79 -8
  107. package/src/lib/components/LoginPage.svelte +2 -2
  108. package/src/lib/components/ManageEditors.svelte +4 -3
  109. package/src/lib/components/MarkdownEditor.svelte +20 -2
  110. package/src/lib/components/cairn-admin.css +59 -0
  111. package/src/lib/components/editor-highlight.ts +32 -7
  112. package/src/lib/components/index.ts +1 -0
  113. package/src/lib/components/markdown-directives.ts +51 -1
  114. package/src/lib/components/markdown-format.ts +0 -27
  115. package/src/lib/components/preview-doc.ts +82 -0
  116. package/src/lib/content/compose.ts +1 -0
  117. package/src/lib/content/links.ts +28 -0
  118. package/src/lib/content/types.ts +34 -2
  119. package/src/lib/delivery/data.ts +3 -5
  120. package/src/lib/delivery/feeds.ts +1 -8
  121. package/src/lib/delivery/index.ts +2 -2
  122. package/src/lib/delivery/manifest.ts +5 -18
  123. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  124. package/src/lib/delivery/site-indexes.ts +6 -6
  125. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  126. package/src/lib/delivery/sitemap.ts +1 -4
  127. package/src/lib/delivery/xml.ts +12 -0
  128. package/src/lib/diagnostics/conditions.ts +24 -0
  129. package/src/lib/doctor/bin.ts +35 -10
  130. package/src/lib/doctor/check-floors.ts +124 -0
  131. package/src/lib/doctor/check-probe.ts +138 -0
  132. package/src/lib/doctor/checks-github.ts +3 -1
  133. package/src/lib/doctor/checks-local.ts +28 -2
  134. package/src/lib/doctor/cloudflare-api.ts +4 -2
  135. package/src/lib/doctor/index.ts +67 -6
  136. package/src/lib/doctor/types.ts +2 -0
  137. package/src/lib/doctor/wrangler-config.ts +11 -0
  138. package/src/lib/email.ts +4 -11
  139. package/src/lib/env.ts +12 -6
  140. package/src/lib/escape.ts +12 -0
  141. package/src/lib/github/credentials.ts +6 -2
  142. package/src/lib/github/types.ts +5 -0
  143. package/src/lib/index.ts +2 -0
  144. package/src/lib/log/events.ts +1 -0
  145. package/src/lib/nav/site-config.ts +3 -0
  146. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  147. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  148. package/src/lib/sveltekit/condition-response.ts +27 -1
  149. package/src/lib/sveltekit/content-routes.ts +131 -62
  150. package/src/lib/sveltekit/guard.ts +20 -5
  151. package/src/lib/sveltekit/https-required-page.ts +2 -1
  152. package/src/lib/sveltekit/index.ts +6 -0
  153. package/src/lib/sveltekit/nav-routes.ts +24 -21
  154. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  155. package/src/lib/sveltekit/types.ts +16 -7
  156. package/src/lib/vite/index.ts +71 -17
  157. package/dist/delivery/paginate.d.ts +0 -12
  158. package/dist/delivery/paginate.js +0 -20
  159. package/dist/render/index.d.ts +0 -5
  160. package/dist/render/index.js +0 -8
  161. package/src/lib/delivery/paginate.ts +0 -32
  162. package/src/lib/render/index.ts +0 -8
@@ -0,0 +1,27 @@
1
+ import type { ResolvedPreview } from '../content/types.js';
2
+ /** One width the preview frame can take. */
3
+ export interface PreviewDevice {
4
+ id: 'desktop' | 'tablet' | 'phone' | 'small';
5
+ /** The device menu label, also the frame caption's first half. */
6
+ label: string;
7
+ /** Frame width in CSS pixels; null fills the pane (Desktop). */
8
+ width: number | null;
9
+ }
10
+ /** A preview device's id, the value the page persists. */
11
+ export type PreviewDeviceId = PreviewDevice['id'];
12
+ /** The four widths the device menu offers, in menu order. Desktop leads as the default. */
13
+ export declare const previewDevices: PreviewDevice[];
14
+ /** The table row for a device id. The id type makes a miss impossible; the fallback satisfies find. */
15
+ export declare function previewDevice(id: PreviewDeviceId): PreviewDevice;
16
+ /** A device's user-facing text, shared by the toolbar's menu items and the frame caption: the
17
+ * label with its width when one is fixed, so the value reaches assistive tech at pick time. */
18
+ export declare function deviceLabel(d: PreviewDevice): string;
19
+ /**
20
+ * Build the preview iframe's srcdoc: a complete document linking the site's stylesheets around
21
+ * the rendered entry html. The html comes from the site's floored render pipeline, which already
22
+ * stripped scripts and event handlers, so it embeds unescaped; the frame's empty `sandbox` is
23
+ * belt and braces over that floor. The parameter is the flat `ResolvedPreview` shape `editLoad`
24
+ * ships, so the per-concept map can never reach the frame document by construction.
25
+ * `preview` null (a site without the adapter knob) yields a styleless but complete document.
26
+ */
27
+ export declare function buildPreviewDoc(html: string, preview: ResolvedPreview | null): string;
@@ -0,0 +1,64 @@
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
+ import { escapeHtml } from '../escape.js';
7
+ /** The four widths the device menu offers, in menu order. Desktop leads as the default. */
8
+ export const previewDevices = [
9
+ { id: 'desktop', label: 'Desktop', width: null },
10
+ { id: 'tablet', label: 'Tablet', width: 768 },
11
+ { id: 'phone', label: 'Phone', width: 390 },
12
+ { id: 'small', label: 'Small phone', width: 320 },
13
+ ];
14
+ /** The table row for a device id. The id type makes a miss impossible; the fallback satisfies find. */
15
+ export function previewDevice(id) {
16
+ return previewDevices.find((d) => d.id === id) ?? previewDevices[0];
17
+ }
18
+ /** A device's user-facing text, shared by the toolbar's menu items and the frame caption: the
19
+ * label with its width when one is fixed, so the value reaches assistive tech at pick time. */
20
+ export function deviceLabel(d) {
21
+ return d.width === null ? d.label : `${d.label} · ${d.width} px`;
22
+ }
23
+ /**
24
+ * Build the preview iframe's srcdoc: a complete document linking the site's stylesheets around
25
+ * the rendered entry html. The html comes from the site's floored render pipeline, which already
26
+ * stripped scripts and event handlers, so it embeds unescaped; the frame's empty `sandbox` is
27
+ * belt and braces over that floor. The parameter is the flat `ResolvedPreview` shape `editLoad`
28
+ * ships, so the per-concept map can never reach the frame document by construction.
29
+ * `preview` null (a site without the adapter knob) yields a styleless but complete document.
30
+ */
31
+ export function buildPreviewDoc(html, preview) {
32
+ const links = (preview?.stylesheets ?? [])
33
+ .map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`)
34
+ .join('\n');
35
+ const bodyAttrs = preview?.bodyClass ? ` class="${escapeHtml(preview.bodyClass)}"` : '';
36
+ const content = preview?.containerClass
37
+ ? `<div class="${escapeHtml(preview.containerClass)}">${html}</div>`
38
+ : html;
39
+ // The reset sits BEFORE the site links so the site's CSS wins every collision: it only clears
40
+ // the default body margin and pins a white ground for sheets that assume one.
41
+ //
42
+ // The base tag is what makes links inert. The empty sandbox alone does not: a sandboxed
43
+ // context may still navigate itself, and a srcdoc document resolves relative hrefs against the
44
+ // parent's base URL, so a clicked fragment or root link could render the admin login inside
45
+ // the frame. Targeting every link at a new tab turns each click into a popup, and the sandbox
46
+ // (which grants no allow-popups) blocks it, so a proofing click goes nowhere.
47
+ return [
48
+ '<!doctype html>',
49
+ '<html>',
50
+ '<head>',
51
+ '<meta charset="utf-8">',
52
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
53
+ '<base target="_blank">',
54
+ '<style>body{margin:0;background:#fff}</style>',
55
+ links,
56
+ '</head>',
57
+ `<body${bodyAttrs}>`,
58
+ content,
59
+ '</body>',
60
+ '</html>',
61
+ ]
62
+ .filter((line) => line !== '')
63
+ .join('\n');
64
+ }
@@ -31,6 +31,7 @@ export function composeRuntime({ adapter, siteConfig, extensions = [] }) {
31
31
  registry: adapter.registry,
32
32
  icons: adapter.icons,
33
33
  navMenu: adapter.navMenu,
34
+ preview: adapter.preview,
34
35
  assets: adapter.assets,
35
36
  adminPanels,
36
37
  fieldTypes,
@@ -18,3 +18,11 @@ export declare function escapeLinkText(text: string): string;
18
18
  /** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
19
19
  * Parses the body as mdast, so a token inside a code span or fence is never matched. */
20
20
  export declare function extractCairnLinks(body: string): CairnRef[];
21
+ /**
22
+ * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
23
+ * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
24
+ * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
25
+ * span is not a link node and is never touched. Each matching node's source span is rewritten from
26
+ * last to first, replacing only the `](oldHref` run so the label and title stay exact.
27
+ */
28
+ export declare function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string;
@@ -50,3 +50,31 @@ export function extractCairnLinks(body) {
50
50
  });
51
51
  return refs;
52
52
  }
53
+ /**
54
+ * Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
55
+ * the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
56
+ * inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
57
+ * span is not a link node and is never touched. Each matching node's source span is rewritten from
58
+ * last to first, replacing only the `](oldHref` run so the label and title stay exact.
59
+ */
60
+ export function rewriteCairnLink(doc, oldHref, newHref) {
61
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
62
+ const spans = [];
63
+ visit(tree, 'link', (node) => {
64
+ if (node.url !== oldHref)
65
+ return;
66
+ const start = node.position?.start?.offset;
67
+ const end = node.position?.end?.offset;
68
+ if (start == null || end == null)
69
+ return;
70
+ spans.push({ start, end });
71
+ });
72
+ spans.sort((a, b) => b.start - a.start);
73
+ let out = doc;
74
+ for (const span of spans) {
75
+ const src = out.slice(span.start, span.end);
76
+ const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
77
+ out = out.slice(0, span.start) + rewritten + out.slice(span.end);
78
+ }
79
+ return out;
80
+ }
@@ -133,6 +133,34 @@ export interface NavMenuConfig {
133
133
  /** Max nesting depth allowed in the editor; defaults to 2. */
134
134
  maxDepth?: number;
135
135
  }
136
+ /**
137
+ * How the edit page's preview frame reproduces the live site's content styling. The admin
138
+ * deliberately never loads the site's CSS (chrome isolation), so a design-accurate preview needs
139
+ * the site to name its stylesheets for the preview frame; without this knob the preview renders
140
+ * unstyled markup. The frame's srcdoc pins a white body background as a deliberately overridable
141
+ * default, so a site whose ground is not white should state its body background in its own
142
+ * stylesheet.
143
+ */
144
+ export interface PreviewConfig {
145
+ /** Absolute or root-relative URLs of the site's compiled stylesheets, linked inside the
146
+ * preview document. A Vite `?url` import of the site's CSS resolves the hashed asset URL. */
147
+ stylesheets: string[];
148
+ /** Class list applied to the preview document's body, for theme or typography roots. */
149
+ bodyClass?: string;
150
+ /** Class list for a wrapper element around the rendered content, reproducing the site's
151
+ * content container (a prose or measure class). Omitted renders the content bare. */
152
+ containerClass?: string;
153
+ /** Per-concept overrides of bodyClass and containerClass, keyed by concept id. An entry's
154
+ * preview resolves the override for its concept over the top-level values; stylesheets are
155
+ * always shared. */
156
+ byConcept?: Record<string, {
157
+ bodyClass?: string;
158
+ containerClass?: string;
159
+ }>;
160
+ }
161
+ /** The flat preview shape `editLoad` ships to the edit page: the top-level `PreviewConfig`
162
+ * values with the entry's concept override applied, and no `byConcept` map. */
163
+ export type ResolvedPreview = Omit<PreviewConfig, 'byConcept'>;
136
164
  /** Reserved asset slot (seam 4). Typed and unused in the rebuild; R7/R9 read it later with no contract change. */
137
165
  export interface AssetConfig {
138
166
  /** Repo-relative asset roots, e.g. ["static/images"]. */
@@ -154,8 +182,8 @@ export interface CairnAdapter {
154
182
  backend: BackendConfig;
155
183
  sender: SenderConfig;
156
184
  /** The site's one renderer: the editor preview and every public page call it (design decision 4).
157
- * `resolve` rewrites cairn: links to live permalinks; the build passes a site-index resolver, the
158
- * preview a manifest one. */
185
+ * `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
186
+ * one, the preview a manifest one. */
159
187
  render(md: string, opts?: {
160
188
  stagger?: boolean;
161
189
  resolve?: LinkResolve;
@@ -168,6 +196,9 @@ export interface CairnAdapter {
168
196
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
169
197
  icons?: IconSet;
170
198
  navMenu?: NavMenuConfig;
199
+ /** The live site's content styling for the preview frame. The admin's chrome isolation keeps
200
+ * the site's CSS out of the admin document, so the preview frame links these instead. */
201
+ preview?: PreviewConfig;
171
202
  assets?: AssetConfig;
172
203
  }
173
204
  /**
@@ -263,6 +294,8 @@ export interface CairnRuntime {
263
294
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
264
295
  icons?: IconSet;
265
296
  navMenu?: NavMenuConfig;
297
+ /** The live site's content styling for the preview frame; passed through from the adapter. */
298
+ preview?: PreviewConfig;
266
299
  assets?: AssetConfig;
267
300
  /** Admin panels contributed by extensions (Mode 2). Empty until Plan 09 wires the dispatch route. */
268
301
  adminPanels?: AdminPanel[];
@@ -1,7 +1,7 @@
1
1
  export { createContentIndex, fromGlob } from './content-index.js';
2
2
  export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
3
- export { createSiteIndex } from './site-index.js';
4
- export type { SiteIndex, ConceptIndex } from './site-index.js';
3
+ export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
4
+ export type { SiteResolver, ConceptIndex } from './site-resolver.js';
5
5
  export { createSiteIndexes } from './site-indexes.js';
6
6
  export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
@@ -15,9 +15,7 @@ export { buildSeoMeta } from './seo.js';
15
15
  export type { SeoInput, SeoMeta } from './seo.js';
16
16
  export { readSeoFields, resolveImageUrl } from './seo-fields.js';
17
17
  export type { SeoFields } from './seo-fields.js';
18
- export { paginate } from './paginate.js';
19
- export type { Page } from './paginate.js';
20
18
  export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
21
19
  export { jsonLdScript } from './json-ld.js';
22
20
  export { permalink } from '../content/permalink.js';
23
- export { buildSiteManifest, buildLinkResolver } from './manifest.js';
21
+ export { buildSiteManifest } from './manifest.js';
@@ -2,7 +2,7 @@
2
2
  // projections a SvelteKit site or a plain-Node tool reads, with no @sveltejs/kit and no .svelte in
3
3
  // the graph. The full ./delivery barrel re-exports this and adds the route loaders.
4
4
  export { createContentIndex, fromGlob } from './content-index.js';
5
- export { createSiteIndex } from './site-index.js';
5
+ export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
6
6
  export { createSiteIndexes } from './site-indexes.js';
7
7
  export { siteDescriptors } from './site-descriptors.js';
8
8
  export { deriveExcerpt, wordCount } from './excerpt.js';
@@ -11,8 +11,7 @@ export { buildSitemap } from './sitemap.js';
11
11
  export { buildRobots } from './robots.js';
12
12
  export { buildSeoMeta } from './seo.js';
13
13
  export { readSeoFields, resolveImageUrl } from './seo-fields.js';
14
- export { paginate } from './paginate.js';
15
14
  export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
16
15
  export { jsonLdScript } from './json-ld.js';
17
16
  export { permalink } from '../content/permalink.js';
18
- export { buildSiteManifest, buildLinkResolver } from './manifest.js';
17
+ export { buildSiteManifest } from './manifest.js';
@@ -2,13 +2,7 @@
2
2
  // channel and a list of items, so they unit-test without a render or a network. The caller
3
3
  // (a template +server.ts shim) assembles items from the content index and passes absolute
4
4
  // URLs built from PUBLIC_ORIGIN.
5
- function escapeXml(value) {
6
- return value
7
- .replace(/&/g, '&amp;')
8
- .replace(/</g, '&lt;')
9
- .replace(/>/g, '&gt;')
10
- .replace(/"/g, '&quot;');
11
- }
5
+ import { escapeXml } from './xml.js';
12
6
  /** Make a string safe inside a CDATA section by splitting any `]]>` across two sections. */
13
7
  function cdataSafe(value) {
14
8
  return value.replace(/]]>/g, ']]]]><![CDATA[>');
@@ -1,3 +1,3 @@
1
1
  export * from './data.js';
2
- export { createPublicRoutes } from '../sveltekit/public-routes.js';
3
- export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData, } from '../sveltekit/public-routes.js';
2
+ export { createPublicRoutes } from './public-routes.js';
3
+ export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData, } from './public-routes.js';
@@ -3,4 +3,4 @@
3
3
  // lives at ./delivery/head. Importing this pulls @sveltejs/kit through the route loaders, so a
4
4
  // plain-Node tool imports from ./delivery/data instead.
5
5
  export * from './data.js';
6
- export { createPublicRoutes } from '../sveltekit/public-routes.js';
6
+ export { createPublicRoutes } from './public-routes.js';
@@ -1,12 +1,7 @@
1
1
  import type { Manifest } from '../content/manifest.js';
2
- import type { LinkResolve } from '../content/links.js';
3
- import type { SiteIndex } from './site-index.js';
4
2
  import type { SiteConfig } from '../nav/site-config.js';
5
3
  import type { CairnAdapter } from '../content/types.js';
6
4
  import type { SiteGlobs } from './site-indexes.js';
7
5
  /** Build the whole-corpus manifest from a site's adapter, config, and per-concept globs. Drafts are
8
6
  * included and flagged, so the admin picker and the guards see the full graph. */
9
7
  export declare function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: SiteConfig, globs: SiteGlobs<A>): Manifest;
10
- /** A resolver backed by the site index, for the build. A miss throws, so a dangling cairn: token
11
- * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
12
- export declare function buildLinkResolver(site: SiteIndex): LinkResolve;
@@ -1,8 +1,7 @@
1
- // cairn-cms: the build-side manifest builder and the build link resolver (content-graph design).
2
- // buildSiteManifest mirrors createSiteIndexes: it maps the site descriptors over the per-concept
3
- // globs and projects each file to a manifest row. buildLinkResolver reads the site index, which is
4
- // fresh from the files at build, and throws on a missing target so a dangling cairn: token fails
5
- // the build (the backstop). The admin preview uses manifestLinkResolver instead.
1
+ // cairn-cms: the build-side manifest builder (content-graph design). buildSiteManifest mirrors
2
+ // createSiteIndexes: it maps the site descriptors over the per-concept globs and projects each
3
+ // file to a manifest row. The build-time cairn: link resolver lives beside the site resolver in
4
+ // site-resolver.ts; the admin preview uses manifestLinkResolver instead.
6
5
  import { siteDescriptors } from './site-descriptors.js';
7
6
  import { fromGlob } from './content-index.js';
8
7
  import { parseMarkdown } from '../content/frontmatter.js';
@@ -15,7 +14,7 @@ export function buildSiteManifest(adapter, config, globs) {
15
14
  for (const descriptor of siteDescriptors(adapter, config)) {
16
15
  const record = globRecord[descriptor.id] ?? {};
17
16
  for (const file of fromGlob(record)) {
18
- // Validate the same way createContentIndex does, so the manifest and the site index agree on
17
+ // Validate the same way createContentIndex does, so the manifest and the site resolver agree on
19
18
  // which entries exist. A validation failure is excluded from both; otherwise the preview would
20
19
  // resolve a link the build then rejects as a missing target.
21
20
  const { frontmatter, body } = parseMarkdown(file.raw);
@@ -26,13 +25,3 @@ export function buildSiteManifest(adapter, config, globs) {
26
25
  }
27
26
  return manifest;
28
27
  }
29
- /** A resolver backed by the site index, for the build. A miss throws, so a dangling cairn: token
30
- * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
31
- export function buildLinkResolver(site) {
32
- return (ref) => {
33
- const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
34
- if (!url)
35
- throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
36
- return url;
37
- };
38
- }
@@ -1,10 +1,10 @@
1
- import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
2
- import type { SiteIndex } from '../delivery/site-index.js';
3
- import type { SeoMeta } from '../delivery/seo.js';
1
+ import type { ContentSummary, ContentEntry } from './content-index.js';
2
+ import type { SiteResolver } from './site-resolver.js';
3
+ import type { SeoMeta } from './seo.js';
4
4
  import type { LinkResolve } from '../content/links.js';
5
5
  /** Injected dependencies for the public loaders. */
6
6
  export interface PublicRoutesDeps {
7
- site: SiteIndex;
7
+ site: SiteResolver;
8
8
  render: (md: string, opts?: {
9
9
  stagger?: boolean;
10
10
  resolve?: LinkResolve;
@@ -1,12 +1,12 @@
1
- // cairn-cms: public route loaders (dated-slug design). The factory closes over the site-level
2
- // index, the runtime render, and the origin. entryLoad and entries are site-wide: one catch-all
1
+ // cairn-cms: public route loaders (dated-slug design). The factory closes over the site
2
+ // resolver, the runtime render, and the origin. entryLoad and entries are site-wide: one catch-all
3
3
  // `[...path]` route resolves any concept by request path through `byPermalink`. The archive, tag,
4
- // and tag-index loaders stay concept-scoped, keyed by concept id. The index is built in site code
5
- // from globs, so it stays in the prerender graph and out of the runtime Worker.
4
+ // and tag-index loaders stay concept-scoped, keyed by concept id. The resolver is built in site
5
+ // code from globs, so it stays in the prerender graph and out of the runtime Worker.
6
6
  import { error } from '@sveltejs/kit';
7
- import { buildSeoMeta } from '../delivery/seo.js';
8
- import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
9
- import { buildLinkResolver } from '../delivery/manifest.js';
7
+ import { buildSeoMeta } from './seo.js';
8
+ import { readSeoFields, resolveImageUrl } from './seo-fields.js';
9
+ import { buildLinkResolver } from './site-resolver.js';
10
10
  /** Build the public loaders for a site's unified index. */
11
11
  export function createPublicRoutes(deps) {
12
12
  const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
@@ -2,7 +2,7 @@ import type { CairnAdapter, ConceptConfig } from '../content/types.js';
2
2
  import type { Infer } from '../content/schema.js';
3
3
  import type { SiteConfig } from '../nav/site-config.js';
4
4
  import type { ContentIndex } from './content-index.js';
5
- import type { SiteIndex } from './site-index.js';
5
+ import type { SiteResolver } from './site-resolver.js';
6
6
  /** A per-concept raw glob record (`{ path: raw }`) keyed by concept id, from `import.meta.glob`. */
7
7
  export type SiteGlobs<A extends CairnAdapter> = {
8
8
  [K in keyof A['content']]?: Record<string, string>;
@@ -12,13 +12,13 @@ export type SiteGlobs<A extends CairnAdapter> = {
12
12
  export type SiteIndexes<A extends CairnAdapter> = {
13
13
  [K in keyof A['content']]: ContentIndex<NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>>;
14
14
  } & {
15
- readonly site: SiteIndex;
15
+ readonly site: SiteResolver;
16
16
  };
17
17
  /**
18
18
  * Build typed per-concept indexes and a site resolver from one adapter. Pass the per-concept raw
19
19
  * globs as `{ posts: import.meta.glob('...?raw', { eager: true }), ... }`; Vite needs the literal
20
20
  * glob at the call site, so the engine cannot glob on the site's behalf. `validate: false` opts out
21
- * of the build gate, exactly as on `createSiteIndex`.
21
+ * of the build gate, exactly as on `createSiteResolver`.
22
22
  */
23
23
  export declare function createSiteIndexes<const A extends CairnAdapter>(adapter: A, config: SiteConfig, globs: SiteGlobs<A>, opts?: {
24
24
  validate?: boolean;
@@ -1,11 +1,11 @@
1
1
  import { siteDescriptors } from './site-descriptors.js';
2
2
  import { createContentIndex, fromGlob } from './content-index.js';
3
- import { createSiteIndex } from './site-index.js';
3
+ import { createSiteResolver } from './site-resolver.js';
4
4
  /**
5
5
  * Build typed per-concept indexes and a site resolver from one adapter. Pass the per-concept raw
6
6
  * globs as `{ posts: import.meta.glob('...?raw', { eager: true }), ... }`; Vite needs the literal
7
7
  * glob at the call site, so the engine cannot glob on the site's behalf. `validate: false` opts out
8
- * of the build gate, exactly as on `createSiteIndex`.
8
+ * of the build gate, exactly as on `createSiteResolver`.
9
9
  */
10
10
  export function createSiteIndexes(adapter, config, globs, opts = {}) {
11
11
  const descriptors = siteDescriptors(adapter, config);
@@ -25,6 +25,6 @@ export function createSiteIndexes(adapter, config, globs, opts = {}) {
25
25
  byConcept[descriptor.id] = index;
26
26
  conceptIndexes.push({ descriptor, index });
27
27
  }
28
- const site = createSiteIndex(conceptIndexes, opts);
28
+ const site = createSiteResolver(conceptIndexes, opts);
29
29
  return { ...byConcept, site };
30
30
  }
@@ -1,12 +1,13 @@
1
1
  import type { ConceptDescriptor } from '../content/types.js';
2
2
  import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
3
+ import type { LinkResolve } from '../content/links.js';
3
4
  /** One concept's descriptor paired with its built index. */
4
5
  export interface ConceptIndex {
5
6
  descriptor: ConceptDescriptor;
6
7
  index: ContentIndex;
7
8
  }
8
9
  /** The cross-concept query surface a catch-all route and the sitemap read. */
9
- export interface SiteIndex {
10
+ export interface SiteResolver {
10
11
  /** Resolve a request path (with or without a trailing slash) to its entry, or undefined. */
11
12
  byPermalink(path: string): ContentEntry | undefined;
12
13
  /** Newer/older neighbors within the entry's own concept, for prev/next links. */
@@ -28,6 +29,9 @@ export interface SiteIndex {
28
29
  * unless `validate` is `false`, on any non-draft entry whose frontmatter fails its concept's
29
30
  * validator, so malformed content fails the build instead of shipping.
30
31
  */
31
- export declare function createSiteIndex(concepts: ConceptIndex[], opts?: {
32
+ export declare function createSiteResolver(concepts: ConceptIndex[], opts?: {
32
33
  validate?: boolean;
33
- }): SiteIndex;
34
+ }): SiteResolver;
35
+ /** A resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token
36
+ * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
37
+ export declare function buildLinkResolver(site: SiteResolver): LinkResolve;
@@ -21,11 +21,11 @@ function siteProblems(concepts) {
21
21
  * unless `validate` is `false`, on any non-draft entry whose frontmatter fails its concept's
22
22
  * validator, so malformed content fails the build instead of shipping.
23
23
  */
24
- export function createSiteIndex(concepts, opts = {}) {
24
+ export function createSiteResolver(concepts, opts = {}) {
25
25
  if (opts.validate !== false) {
26
26
  const problems = siteProblems(concepts);
27
27
  if (problems.length > 0) {
28
- throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
28
+ throw new Error(`site resolver: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
29
29
  }
30
30
  }
31
31
  const byPath = new Map();
@@ -35,7 +35,7 @@ export function createSiteIndex(concepts, opts = {}) {
35
35
  for (const summary of index.all()) {
36
36
  const existing = byPath.get(summary.permalink);
37
37
  if (existing) {
38
- throw new Error(`site index: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`);
38
+ throw new Error(`site resolver: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`);
39
39
  }
40
40
  byPath.set(summary.permalink, { index, id: summary.id });
41
41
  }
@@ -60,3 +60,13 @@ export function createSiteIndex(concepts, opts = {}) {
60
60
  },
61
61
  };
62
62
  }
63
+ /** A resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token
64
+ * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
65
+ export function buildLinkResolver(site) {
66
+ return (ref) => {
67
+ const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
68
+ if (!url)
69
+ throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
70
+ return url;
71
+ };
72
+ }
@@ -1,8 +1,6 @@
1
1
  // cairn-cms: sitemap builder (public-delivery design). Pure over a URL list; the caller
2
2
  // derives the list from the content index and the routable concepts.
3
- function escapeXml(value) {
4
- return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
5
- }
3
+ import { escapeXml } from './xml.js';
6
4
  /** Build a sitemap XML document from a list of URLs. */
7
5
  export function buildSitemap(urls) {
8
6
  const entries = urls
@@ -0,0 +1,2 @@
1
+ /** Escape the XML-significant characters for element text and double-quoted attribute values. */
2
+ export declare function escapeXml(value: string): string;
@@ -0,0 +1,11 @@
1
+ // cairn-cms: the one XML text escape the feed and sitemap builders share. The strongest of the
2
+ // two copies it replaced (the old sitemap copy skipped quotes), so both documents stay safe in
3
+ // element text and double-quoted attributes.
4
+ /** Escape the XML-significant characters for element text and double-quoted attribute values. */
5
+ export function escapeXml(value) {
6
+ return value
7
+ .replaceAll('&', '&amp;')
8
+ .replaceAll('<', '&lt;')
9
+ .replaceAll('>', '&gt;')
10
+ .replaceAll('"', '&quot;');
11
+ }
@@ -69,6 +69,14 @@ export const REGISTRY = {
69
69
  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.",
70
70
  docsAnchor: 'cloudflare-readiness.md#hand-cairn-the-csrf-authority',
71
71
  },
72
+ 'config.public-origin-invalid': {
73
+ id: 'config.public-origin-invalid',
74
+ severity: 'blocker',
75
+ title: 'PUBLIC_ORIGIN is missing or invalid',
76
+ 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.',
77
+ 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.",
78
+ docsAnchor: 'cloudflare-readiness.md#set-the-public-origin',
79
+ },
72
80
  'config.site-config-invalid': {
73
81
  id: 'config.site-config-invalid',
74
82
  severity: 'blocker',
@@ -77,6 +85,14 @@ export const REGISTRY = {
77
85
  remediation: 'Correct site.config.yaml; the parse or validation error names the failing field or URL-policy rule.',
78
86
  docsAnchor: 'cloudflare-readiness.md#validate-the-site-config',
79
87
  },
88
+ 'config.dependency-floors-unmet': {
89
+ id: 'config.dependency-floors-unmet',
90
+ severity: 'blocker',
91
+ title: 'A framework dependency sits below the engine floor',
92
+ 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.',
93
+ 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`.",
94
+ docsAnchor: 'cloudflare-readiness.md#meet-the-dependency-floors',
95
+ },
80
96
  'edge.hsts-off': {
81
97
  id: 'edge.hsts-off',
82
98
  severity: 'warning',
@@ -102,6 +118,14 @@ export const REGISTRY = {
102
118
  docsAnchor: 'cloudflare-readiness.md#install-the-github-app',
103
119
  logEvent: 'github.unreachable',
104
120
  },
121
+ 'admin.login-probe-failed': {
122
+ id: 'admin.login-probe-failed',
123
+ severity: 'blocker',
124
+ title: 'Live admin login probe failed',
125
+ 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.',
126
+ 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.',
127
+ docsAnchor: 'cloudflare-readiness.md#probe-the-deployed-admin',
128
+ },
105
129
  };
106
130
  // The registry is shared identity, never working state; freeze every entry and the map itself.
107
131
  for (const entry of Object.values(REGISTRY))
@@ -7,8 +7,10 @@
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 { contextFromEnv, defaultChecks, formatReport, parseArgs, runDoctor } from './index.js';
12
+ import { readWranglerConfig } from './wrangler-config.js';
13
+ import { contextFromEnv, defaultChecks, deriveMissingInputs, formatReport, parseArgs, runDoctor, } from './index.js';
12
14
  async function main() {
13
15
  let args;
14
16
  try {
@@ -20,23 +22,39 @@ async function main() {
20
22
  return;
21
23
  }
22
24
  const cwd = process.cwd();
25
+ const readFileUnderCwd = async (relPath) => {
26
+ try {
27
+ return await readFile(resolve(cwd, relPath), 'utf8');
28
+ }
29
+ catch (err) {
30
+ if (err.code === 'ENOENT')
31
+ return null;
32
+ throw err;
33
+ }
34
+ };
35
+ // Fill inputs the flags and env left missing from the repo itself: from and repo off the
36
+ // adapter (through the vite arm, which exists only on this bin path, never in a Worker)
37
+ // and the account id off the wrangler config. The API token stays env-only.
38
+ const derived = await deriveMissingInputs(contextFromEnv(process.env, args, cwd), {
39
+ adapterFacts: async () => {
40
+ const { readAdapterFacts } = await import('../vite/index.js');
41
+ return readAdapterFacts(cwd);
42
+ },
43
+ wranglerAccountId: async () => (await readWranglerConfig(readFileUnderCwd))?.accountId,
44
+ });
23
45
  const ctx = {
24
- ...contextFromEnv(process.env, args, cwd),
46
+ ...derived,
25
47
  fetch: globalThis.fetch,
26
- readFile: async (relPath) => {
27
- try {
28
- return await readFile(resolve(cwd, relPath), 'utf8');
29
- }
30
- catch (err) {
31
- if (err.code === 'ENOENT')
32
- return null;
33
- throw err;
34
- }
35
- },
48
+ readFile: readFileUnderCwd,
36
49
  };
37
50
  const checks = defaultChecks();
38
51
  if (args.sendTest)
39
52
  checks.push(liveSendCheck(args.sendTest));
53
+ // The probe is an opt-in network POST against a live site, so it joins only on --probe;
54
+ // the bare flag hands the URL resolution (the PUBLIC_ORIGIN input) to the check itself.
55
+ if (args.probe !== undefined) {
56
+ checks.push(liveProbeCheck(args.probe === true ? undefined : args.probe));
57
+ }
40
58
  const { results, failed } = await runDoctor(checks, ctx);
41
59
  console.log(formatReport(results));
42
60
  process.exitCode = failed > 0 ? 1 : 0;