@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.
- package/CHANGELOG.md +82 -0
- package/README.md +2 -2
- package/dist/ambient.d.ts +9 -0
- package/dist/ambient.js +1 -0
- package/dist/components/AdminLayout.svelte +6 -8
- package/dist/components/CairnAdmin.svelte +67 -0
- package/dist/components/CairnAdmin.svelte.d.ts +35 -0
- package/dist/components/ConceptList.svelte +4 -5
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +107 -25
- package/dist/components/EditPage.svelte.d.ts +8 -10
- package/dist/components/EditorToolbar.svelte +79 -8
- package/dist/components/EditorToolbar.svelte.d.ts +10 -2
- package/dist/components/LoginPage.svelte +2 -2
- package/dist/components/LoginPage.svelte.d.ts +1 -1
- package/dist/components/ManageEditors.svelte +4 -3
- package/dist/components/ManageEditors.svelte.d.ts +2 -1
- 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/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +10 -0
- package/dist/components/markdown-directives.js +54 -1
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- 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/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +35 -2
- package/dist/delivery/data.d.ts +3 -5
- package/dist/delivery/data.js +2 -3
- package/dist/delivery/feeds.js +1 -7
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +1 -1
- package/dist/delivery/manifest.d.ts +0 -5
- package/dist/delivery/manifest.js +5 -16
- package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
- package/dist/{sveltekit → delivery}/public-routes.js +7 -7
- package/dist/delivery/site-indexes.d.ts +3 -3
- package/dist/delivery/site-indexes.js +3 -3
- package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
- package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
- package/dist/delivery/sitemap.js +1 -3
- package/dist/delivery/xml.d.ts +2 -0
- package/dist/delivery/xml.js +11 -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/email.js +4 -11
- package/dist/env.d.ts +3 -2
- package/dist/env.js +12 -6
- package/dist/escape.d.ts +2 -0
- package/dist/escape.js +11 -0
- package/dist/github/credentials.d.ts +2 -1
- package/dist/github/credentials.js +10 -2
- package/dist/github/types.d.ts +2 -0
- package/dist/github/types.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +2 -0
- package/dist/nav/site-config.js +2 -0
- package/dist/sveltekit/admin-dispatch.d.ts +28 -0
- package/dist/sveltekit/admin-dispatch.js +62 -0
- package/dist/sveltekit/cairn-admin.d.ts +94 -0
- package/dist/sveltekit/cairn-admin.js +126 -0
- package/dist/sveltekit/condition-response.d.ts +1 -0
- package/dist/sveltekit/condition-response.js +25 -0
- package/dist/sveltekit/content-routes.d.ts +39 -15
- package/dist/sveltekit/content-routes.js +84 -50
- package/dist/sveltekit/guard.d.ts +8 -2
- package/dist/sveltekit/guard.js +18 -4
- package/dist/sveltekit/https-required-page.js +2 -1
- package/dist/sveltekit/index.d.ts +3 -1
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/nav-routes.d.ts +3 -1
- package/dist/sveltekit/nav-routes.js +22 -19
- package/dist/sveltekit/static-admin-page.d.ts +0 -2
- package/dist/sveltekit/static-admin-page.js +1 -8
- package/dist/sveltekit/types.d.ts +18 -11
- package/dist/vite/index.d.ts +16 -0
- package/dist/vite/index.js +57 -13
- package/package.json +6 -2
- package/src/lib/ambient.ts +19 -0
- package/src/lib/components/AdminLayout.svelte +6 -8
- package/src/lib/components/CairnAdmin.svelte +67 -0
- package/src/lib/components/ConceptList.svelte +4 -5
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +107 -25
- package/src/lib/components/EditorToolbar.svelte +79 -8
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- 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/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +51 -1
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/components/preview-doc.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +34 -2
- package/src/lib/delivery/data.ts +3 -5
- package/src/lib/delivery/feeds.ts +1 -8
- package/src/lib/delivery/index.ts +2 -2
- package/src/lib/delivery/manifest.ts +5 -18
- package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
- package/src/lib/delivery/site-indexes.ts +6 -6
- package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
- package/src/lib/delivery/sitemap.ts +1 -4
- package/src/lib/delivery/xml.ts +12 -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/email.ts +4 -11
- package/src/lib/env.ts +12 -6
- package/src/lib/escape.ts +12 -0
- package/src/lib/github/credentials.ts +6 -2
- package/src/lib/github/types.ts +5 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/nav/site-config.ts +3 -0
- package/src/lib/sveltekit/admin-dispatch.ts +75 -0
- package/src/lib/sveltekit/cairn-admin.ts +177 -0
- package/src/lib/sveltekit/condition-response.ts +27 -1
- package/src/lib/sveltekit/content-routes.ts +131 -62
- package/src/lib/sveltekit/guard.ts +20 -5
- package/src/lib/sveltekit/https-required-page.ts +2 -1
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/nav-routes.ts +24 -21
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- package/src/lib/vite/index.ts +71 -17
- package/dist/delivery/paginate.d.ts +0 -12
- package/dist/delivery/paginate.js +0 -20
- package/dist/render/index.d.ts +0 -5
- package/dist/render/index.js +0 -8
- package/src/lib/delivery/paginate.ts +0 -32
- package/src/lib/render/index.ts +0 -8
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"]. */
|
|
@@ -176,8 +203,8 @@ export interface CairnAdapter {
|
|
|
176
203
|
backend: BackendConfig;
|
|
177
204
|
sender: SenderConfig;
|
|
178
205
|
/** The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
179
|
-
* `resolve` rewrites cairn: links to live permalinks; the build passes a site-
|
|
180
|
-
* preview a manifest one. */
|
|
206
|
+
* `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
|
|
207
|
+
* one, the preview a manifest one. */
|
|
181
208
|
render(md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }): string | Promise<string>;
|
|
182
209
|
/** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
|
|
183
210
|
* in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
|
|
@@ -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[];
|
package/src/lib/delivery/data.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
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
5
|
export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
|
|
6
|
-
export {
|
|
7
|
-
export type {
|
|
6
|
+
export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
|
|
7
|
+
export type { SiteResolver, ConceptIndex } from './site-resolver.js';
|
|
8
8
|
export { createSiteIndexes } from './site-indexes.js';
|
|
9
9
|
export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
|
|
10
10
|
export { siteDescriptors } from './site-descriptors.js';
|
|
@@ -18,9 +18,7 @@ export { buildSeoMeta } from './seo.js';
|
|
|
18
18
|
export type { SeoInput, SeoMeta } from './seo.js';
|
|
19
19
|
export { readSeoFields, resolveImageUrl } from './seo-fields.js';
|
|
20
20
|
export type { SeoFields } from './seo-fields.js';
|
|
21
|
-
export { paginate } from './paginate.js';
|
|
22
|
-
export type { Page } from './paginate.js';
|
|
23
21
|
export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
|
|
24
22
|
export { jsonLdScript } from './json-ld.js';
|
|
25
23
|
export { permalink } from '../content/permalink.js';
|
|
26
|
-
export { buildSiteManifest
|
|
24
|
+
export { buildSiteManifest } from './manifest.js';
|
|
@@ -2,6 +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
|
+
import { escapeXml } from './xml.js';
|
|
5
6
|
|
|
6
7
|
/** Feed channel metadata. URLs are absolute. */
|
|
7
8
|
export interface FeedChannel {
|
|
@@ -24,14 +25,6 @@ export interface FeedItem {
|
|
|
24
25
|
tags?: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function escapeXml(value: string): string {
|
|
28
|
-
return value
|
|
29
|
-
.replace(/&/g, '&')
|
|
30
|
-
.replace(/</g, '<')
|
|
31
|
-
.replace(/>/g, '>')
|
|
32
|
-
.replace(/"/g, '"');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
28
|
/** Make a string safe inside a CDATA section by splitting any `]]>` across two sections. */
|
|
36
29
|
function cdataSafe(value: string): string {
|
|
37
30
|
return value.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
@@ -3,11 +3,11 @@
|
|
|
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 '
|
|
6
|
+
export { createPublicRoutes } from './public-routes.js';
|
|
7
7
|
export type {
|
|
8
8
|
PublicRoutesDeps,
|
|
9
9
|
ListData,
|
|
10
10
|
TagData,
|
|
11
11
|
TagIndexData,
|
|
12
12
|
EntryData,
|
|
13
|
-
} from '
|
|
13
|
+
} from './public-routes.js';
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
// cairn-cms: the build-side manifest builder
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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';
|
|
9
8
|
import { emptyManifest, manifestEntryFromFile } from '../content/manifest.js';
|
|
10
9
|
import type { Manifest } from '../content/manifest.js';
|
|
11
|
-
import type { LinkResolve } from '../content/links.js';
|
|
12
|
-
import type { SiteIndex } from './site-index.js';
|
|
13
10
|
import type { SiteConfig } from '../nav/site-config.js';
|
|
14
11
|
import type { CairnAdapter } from '../content/types.js';
|
|
15
12
|
import type { SiteGlobs } from './site-indexes.js';
|
|
@@ -22,7 +19,7 @@ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: Si
|
|
|
22
19
|
for (const descriptor of siteDescriptors(adapter, config)) {
|
|
23
20
|
const record = globRecord[descriptor.id] ?? {};
|
|
24
21
|
for (const file of fromGlob(record)) {
|
|
25
|
-
// Validate the same way createContentIndex does, so the manifest and the site
|
|
22
|
+
// Validate the same way createContentIndex does, so the manifest and the site resolver agree on
|
|
26
23
|
// which entries exist. A validation failure is excluded from both; otherwise the preview would
|
|
27
24
|
// resolve a link the build then rejects as a missing target.
|
|
28
25
|
const { frontmatter, body } = parseMarkdown(file.raw);
|
|
@@ -32,13 +29,3 @@ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: Si
|
|
|
32
29
|
}
|
|
33
30
|
return manifest;
|
|
34
31
|
}
|
|
35
|
-
|
|
36
|
-
/** A resolver backed by the site index, for the build. A miss throws, so a dangling cairn: token
|
|
37
|
-
* fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
|
|
38
|
-
export function buildLinkResolver(site: SiteIndex): LinkResolve {
|
|
39
|
-
return (ref) => {
|
|
40
|
-
const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
|
|
41
|
-
if (!url) throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
|
|
42
|
-
return url;
|
|
43
|
-
};
|
|
44
|
-
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
// cairn-cms: public route loaders (dated-slug design). The factory closes over the site
|
|
2
|
-
//
|
|
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
|
|
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 type { ContentSummary, ContentEntry } from '
|
|
8
|
-
import type {
|
|
9
|
-
import { buildSeoMeta } from '
|
|
10
|
-
import type { SeoMeta } from '
|
|
11
|
-
import { readSeoFields, resolveImageUrl } from '
|
|
12
|
-
import { buildLinkResolver } from '
|
|
7
|
+
import type { ContentSummary, ContentEntry } from './content-index.js';
|
|
8
|
+
import type { SiteResolver } from './site-resolver.js';
|
|
9
|
+
import { buildSeoMeta } from './seo.js';
|
|
10
|
+
import type { SeoMeta } from './seo.js';
|
|
11
|
+
import { readSeoFields, resolveImageUrl } from './seo-fields.js';
|
|
12
|
+
import { buildLinkResolver } from './site-resolver.js';
|
|
13
13
|
import type { LinkResolve } from '../content/links.js';
|
|
14
14
|
|
|
15
15
|
/** Injected dependencies for the public loaders. */
|
|
16
16
|
export interface PublicRoutesDeps {
|
|
17
|
-
site:
|
|
17
|
+
site: SiteResolver;
|
|
18
18
|
render: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
|
|
19
19
|
origin: string;
|
|
20
20
|
/** Site name for og:site_name and the SEO head. */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// cairn-cms: the full-auto typed site index (schema-source-of-truth design). It maps over a
|
|
2
2
|
// defineAdapter-typed adapter to give one typed per-concept index, with frontmatter typed as the
|
|
3
3
|
// concept's inferred schema type, plus a site resolver for the catch-all route. It is the typed
|
|
4
|
-
// convenience over createContentIndex and
|
|
4
|
+
// convenience over createContentIndex and createSiteResolver, not a replacement: both stay the
|
|
5
5
|
// lower-level escape hatch. It imports only pure content and delivery code, so the delivery
|
|
6
6
|
// bundle stays backend-free.
|
|
7
7
|
import type { CairnAdapter, ConceptConfig } from '../content/types.js';
|
|
@@ -9,9 +9,9 @@ import type { Infer } from '../content/schema.js';
|
|
|
9
9
|
import type { SiteConfig } from '../nav/site-config.js';
|
|
10
10
|
import { siteDescriptors } from './site-descriptors.js';
|
|
11
11
|
import { createContentIndex, fromGlob } from './content-index.js';
|
|
12
|
-
import {
|
|
12
|
+
import { createSiteResolver } from './site-resolver.js';
|
|
13
13
|
import type { ContentIndex } from './content-index.js';
|
|
14
|
-
import type { ConceptIndex,
|
|
14
|
+
import type { ConceptIndex, SiteResolver } from './site-resolver.js';
|
|
15
15
|
|
|
16
16
|
/** A per-concept raw glob record (`{ path: raw }`) keyed by concept id, from `import.meta.glob`. */
|
|
17
17
|
export type SiteGlobs<A extends CairnAdapter> = {
|
|
@@ -24,13 +24,13 @@ export type SiteIndexes<A extends CairnAdapter> = {
|
|
|
24
24
|
[K in keyof A['content']]: ContentIndex<
|
|
25
25
|
NonNullable<A['content'][K]> extends ConceptConfig<infer S> ? Infer<S> : Record<string, unknown>
|
|
26
26
|
>;
|
|
27
|
-
} & { readonly site:
|
|
27
|
+
} & { readonly site: SiteResolver };
|
|
28
28
|
|
|
29
29
|
/**
|
|
30
30
|
* Build typed per-concept indexes and a site resolver from one adapter. Pass the per-concept raw
|
|
31
31
|
* globs as `{ posts: import.meta.glob('...?raw', { eager: true }), ... }`; Vite needs the literal
|
|
32
32
|
* glob at the call site, so the engine cannot glob on the site's behalf. `validate: false` opts out
|
|
33
|
-
* of the build gate, exactly as on `
|
|
33
|
+
* of the build gate, exactly as on `createSiteResolver`.
|
|
34
34
|
*/
|
|
35
35
|
export function createSiteIndexes<const A extends CairnAdapter>(
|
|
36
36
|
adapter: A,
|
|
@@ -59,6 +59,6 @@ export function createSiteIndexes<const A extends CairnAdapter>(
|
|
|
59
59
|
byConcept[descriptor.id] = index;
|
|
60
60
|
conceptIndexes.push({ descriptor, index });
|
|
61
61
|
}
|
|
62
|
-
const site =
|
|
62
|
+
const site = createSiteResolver(conceptIndexes, opts);
|
|
63
63
|
return { ...byConcept, site } as SiteIndexes<A>;
|
|
64
64
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
// cairn-cms: the
|
|
2
|
-
// per-concept index into one
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// cairn-cms: the cross-concept site resolver (dated-slug design). It unions every concept's
|
|
2
|
+
// per-concept index into one resolver: a single byPermalink map a catch-all route matches a
|
|
3
|
+
// request path against, one entries() list the prerenderer walks, and the per-concept indexes
|
|
4
|
+
// for concept-scoped archive, tag, and feed loaders. A duplicate permalink throws at build.
|
|
5
|
+
// buildLinkResolver lives here too, since it closes over the resolver.
|
|
5
6
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
6
7
|
import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
|
|
8
|
+
import type { LinkResolve } from '../content/links.js';
|
|
7
9
|
|
|
8
10
|
/** One concept's descriptor paired with its built index. */
|
|
9
11
|
export interface ConceptIndex {
|
|
@@ -12,7 +14,7 @@ export interface ConceptIndex {
|
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
/** The cross-concept query surface a catch-all route and the sitemap read. */
|
|
15
|
-
export interface
|
|
17
|
+
export interface SiteResolver {
|
|
16
18
|
/** Resolve a request path (with or without a trailing slash) to its entry, or undefined. */
|
|
17
19
|
byPermalink(path: string): ContentEntry | undefined;
|
|
18
20
|
/** Newer/older neighbors within the entry's own concept, for prev/next links. */
|
|
@@ -49,11 +51,11 @@ function siteProblems(concepts: ConceptIndex[]): string[] {
|
|
|
49
51
|
* unless `validate` is `false`, on any non-draft entry whose frontmatter fails its concept's
|
|
50
52
|
* validator, so malformed content fails the build instead of shipping.
|
|
51
53
|
*/
|
|
52
|
-
export function
|
|
54
|
+
export function createSiteResolver(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteResolver {
|
|
53
55
|
if (opts.validate !== false) {
|
|
54
56
|
const problems = siteProblems(concepts);
|
|
55
57
|
if (problems.length > 0) {
|
|
56
|
-
throw new Error(`site
|
|
58
|
+
throw new Error(`site resolver: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
const byPath = new Map<string, { index: ContentIndex; id: string }>();
|
|
@@ -64,7 +66,7 @@ export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boo
|
|
|
64
66
|
const existing = byPath.get(summary.permalink);
|
|
65
67
|
if (existing) {
|
|
66
68
|
throw new Error(
|
|
67
|
-
`site
|
|
69
|
+
`site resolver: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`,
|
|
68
70
|
);
|
|
69
71
|
}
|
|
70
72
|
byPath.set(summary.permalink, { index, id: summary.id });
|
|
@@ -90,3 +92,13 @@ export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boo
|
|
|
90
92
|
},
|
|
91
93
|
};
|
|
92
94
|
}
|
|
95
|
+
|
|
96
|
+
/** A resolver backed by the site resolver, for the build. A miss throws, so a dangling cairn: token
|
|
97
|
+
* fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
|
|
98
|
+
export function buildLinkResolver(site: SiteResolver): LinkResolve {
|
|
99
|
+
return (ref) => {
|
|
100
|
+
const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
|
|
101
|
+
if (!url) throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
|
|
102
|
+
return url;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -1,5 +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
|
+
import { escapeXml } from './xml.js';
|
|
3
4
|
|
|
4
5
|
/** One sitemap URL. `lastmod` is a YYYY-MM-DD date. */
|
|
5
6
|
export interface SitemapUrl {
|
|
@@ -7,10 +8,6 @@ export interface SitemapUrl {
|
|
|
7
8
|
lastmod?: string;
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
function escapeXml(value: string): string {
|
|
11
|
-
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
12
|
-
}
|
|
13
|
-
|
|
14
11
|
/** Build a sitemap XML document from a list of URLs. */
|
|
15
12
|
export function buildSitemap(urls: SitemapUrl[]): string {
|
|
16
13
|
const entries = urls
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
|
|
5
|
+
/** Escape the XML-significant characters for element text and double-quoted attribute values. */
|
|
6
|
+
export function escapeXml(value: string): string {
|
|
7
|
+
return value
|
|
8
|
+
.replaceAll('&', '&')
|
|
9
|
+
.replaceAll('<', '<')
|
|
10
|
+
.replaceAll('>', '>')
|
|
11
|
+
.replaceAll('"', '"');
|
|
12
|
+
}
|
|
@@ -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
|
+
};
|