@glw907/cairn-cms 0.21.0 → 0.26.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 +87 -0
- package/README.md +50 -37
- package/dist/content/compose.d.ts +15 -4
- package/dist/content/compose.d.ts.map +1 -1
- package/dist/content/compose.js +9 -4
- package/dist/content/concepts.d.ts.map +1 -1
- package/dist/content/concepts.js +7 -0
- package/dist/content/frontmatter.d.ts +8 -0
- package/dist/content/frontmatter.d.ts.map +1 -1
- package/dist/content/frontmatter.js +19 -0
- package/dist/content/manifest.d.ts +20 -3
- package/dist/content/manifest.d.ts.map +1 -1
- package/dist/content/manifest.js +49 -6
- package/dist/content/types.d.ts +6 -0
- package/dist/content/types.d.ts.map +1 -1
- package/dist/content/validate.d.ts +4 -1
- package/dist/content/validate.d.ts.map +1 -1
- package/dist/content/validate.js +12 -2
- package/dist/delivery/content-index.d.ts +11 -0
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +7 -0
- package/dist/delivery/data.d.ts +24 -0
- package/dist/delivery/data.d.ts.map +1 -0
- package/dist/delivery/data.js +18 -0
- package/dist/delivery/head.d.ts +2 -0
- package/dist/delivery/head.d.ts.map +1 -0
- package/dist/delivery/head.js +4 -0
- package/dist/delivery/index.d.ts +1 -24
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +5 -21
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/render/pipeline.d.ts +6 -2
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +5 -2
- package/dist/render/registry.d.ts +1 -1
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.d.ts +5 -0
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.js +12 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/remark-directives.js +15 -6
- package/dist/render/sanitize-schema.d.ts +4 -3
- package/dist/render/sanitize-schema.d.ts.map +1 -1
- package/dist/render/sanitize-schema.js +6 -5
- package/dist/sveltekit/public-routes.d.ts +1 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +1 -1
- package/dist/vite/bin.d.ts +3 -0
- package/dist/vite/bin.d.ts.map +1 -0
- package/dist/vite/bin.js +9 -0
- package/dist/vite/index.d.ts +33 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +178 -0
- package/package.json +26 -4
- package/src/lib/content/compose.ts +18 -9
- package/src/lib/content/concepts.ts +9 -0
- package/src/lib/content/frontmatter.ts +21 -0
- package/src/lib/content/manifest.ts +63 -7
- package/src/lib/content/types.ts +6 -0
- package/src/lib/content/validate.ts +10 -2
- package/src/lib/delivery/content-index.ts +17 -0
- package/src/lib/delivery/data.ts +26 -0
- package/src/lib/delivery/head.ts +4 -0
- package/src/lib/delivery/index.ts +5 -29
- package/src/lib/index.ts +10 -1
- package/src/lib/render/pipeline.ts +11 -3
- package/src/lib/render/registry.ts +1 -1
- package/src/lib/render/rehype-dispatch.ts +12 -1
- package/src/lib/render/remark-directives.ts +16 -5
- package/src/lib/render/sanitize-schema.ts +6 -5
- package/src/lib/sveltekit/public-routes.ts +2 -1
- package/src/lib/vite/bin.ts +10 -0
- package/src/lib/vite/index.ts +213 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/** The key the cairnManifest plugin stashes its options under, so the write path can read them off the
|
|
4
|
+
* plugin instance in the consumer's loaded config without re-parsing the config file. */
|
|
5
|
+
const CAIRN_OPTIONS = Symbol.for('cairn-cms.manifest-options');
|
|
6
|
+
const VIRTUAL_ID = 'virtual:cairn-manifest';
|
|
7
|
+
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
|
8
|
+
/** The default committed manifest path, app-root-absolute. */
|
|
9
|
+
const DEFAULT_MANIFEST_PATH = '/src/content/.cairn/index.json';
|
|
10
|
+
/** Build the virtual module source. In verify mode it throws on drift; in write mode it exports the
|
|
11
|
+
* serialized manifest as `result`. The module runs in the app graph, so its `import.meta.glob`,
|
|
12
|
+
* package, and `?raw` resolution is the build's own. */
|
|
13
|
+
function virtualSource(opts, mode) {
|
|
14
|
+
const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH;
|
|
15
|
+
const globEntries = Object.entries(opts.content)
|
|
16
|
+
.map(([id, pattern]) => ` ${JSON.stringify(id)}: import.meta.glob(${JSON.stringify(pattern)}, { query: '?raw', import: 'default', eager: true }),`)
|
|
17
|
+
.join('\n');
|
|
18
|
+
// In write mode the committed file may not exist yet, so do not import it.
|
|
19
|
+
const committedImport = mode === 'verify' ? `import committed from ${JSON.stringify(manifestPath + '?raw')};` : '';
|
|
20
|
+
const resultExpr = mode === 'write' ? 'serializeManifest(built)' : '(verifyManifest(built, committed), "ok")';
|
|
21
|
+
return `
|
|
22
|
+
import { buildSiteManifest } from '@glw907/cairn-cms/delivery/data';
|
|
23
|
+
import { serializeManifest, verifyManifest } from '@glw907/cairn-cms';
|
|
24
|
+
import { cairn, siteConfig } from ${JSON.stringify(opts.configModule)};
|
|
25
|
+
${committedImport}
|
|
26
|
+
const globs = {
|
|
27
|
+
${globEntries}
|
|
28
|
+
};
|
|
29
|
+
const built = buildSiteManifest(cairn, siteConfig, globs);
|
|
30
|
+
export const result = ${resultExpr};
|
|
31
|
+
`;
|
|
32
|
+
}
|
|
33
|
+
/** Evaluate the virtual module in the given mode inside the consumer's own Vite resolution, then
|
|
34
|
+
* return the module's `result`. It reuses the consumer's loaded config (so `$lib`, the config
|
|
35
|
+
* module, `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the
|
|
36
|
+
* cairnManifest plugin from the nested server's plugin list, so its buildStart never recurses.
|
|
37
|
+
* This runs at build time and in the bin, never in the request lifecycle. */
|
|
38
|
+
async function evalVirtual(opts, mode, root) {
|
|
39
|
+
const { createServer, loadConfigFromFile } = await import('vite');
|
|
40
|
+
// Load the consumer's real Vite config so the nested server inherits SvelteKit's resolution
|
|
41
|
+
// (the $lib alias, the app root, the ?raw and import.meta.glob handling). Drop cairnManifest from
|
|
42
|
+
// it so the nested server's buildStart does not recurse, and add a plugin that serves only the
|
|
43
|
+
// virtual module in the requested mode.
|
|
44
|
+
const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, root);
|
|
45
|
+
const inlineConfig = loaded?.config ?? {};
|
|
46
|
+
const server = await createServer({
|
|
47
|
+
...inlineConfig,
|
|
48
|
+
root,
|
|
49
|
+
configFile: false,
|
|
50
|
+
logLevel: 'silent',
|
|
51
|
+
server: { middlewareMode: true, hmr: false, watch: null },
|
|
52
|
+
plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(opts, mode)],
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
const mod = (await server.ssrLoadModule(VIRTUAL_ID));
|
|
56
|
+
return mod.result;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
await server.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** True for any plugin object whose name is the cairnManifest plugin, so the nested server drops it
|
|
63
|
+
* and cannot recurse into another buildStart. The consumer's plugin list may nest arrays and hold
|
|
64
|
+
* falsy slots, so guard the shape. */
|
|
65
|
+
function isCairnManifestPlugin(p) {
|
|
66
|
+
return !!p && typeof p === 'object' && 'name' in p && p.name === 'cairn-manifest';
|
|
67
|
+
}
|
|
68
|
+
/** Flatten the consumer's plugins option and drop the cairnManifest plugin at any nesting depth, so
|
|
69
|
+
* the nested verify server can never re-enter its buildStart. Vite supports (and flattens) nested
|
|
70
|
+
* plugin arrays, and findCairnOptions recurses into them, so a flat single-level filter would miss a
|
|
71
|
+
* cairnManifest nested inside a shared preset's sub-array and let it survive into the nested server.
|
|
72
|
+
* This mirrors findCairnOptions's recursion. Falsy slots pass through, which Vite tolerates. */
|
|
73
|
+
export function stripCairnManifest(plugins) {
|
|
74
|
+
if (Array.isArray(plugins))
|
|
75
|
+
return plugins.flatMap(stripCairnManifest);
|
|
76
|
+
if (isCairnManifestPlugin(plugins))
|
|
77
|
+
return [];
|
|
78
|
+
return [plugins];
|
|
79
|
+
}
|
|
80
|
+
/** Verify the committed manifest against the corpus from a Vite context, throwing on drift. The bin
|
|
81
|
+
* and the plugin share this; the spike proved it runs cleanly inside the consumer's config. */
|
|
82
|
+
export async function verifyManifestFromVite(opts, root) {
|
|
83
|
+
await evalVirtual(opts, 'verify', root);
|
|
84
|
+
}
|
|
85
|
+
/** Regenerate the serialized manifest from the corpus in a Vite context, sharing the build's
|
|
86
|
+
* resolution. The cairn-manifest bin (a later task) will call this and write the result. */
|
|
87
|
+
export async function buildManifestFromVite(opts, root) {
|
|
88
|
+
return evalVirtual(opts, 'write', root);
|
|
89
|
+
}
|
|
90
|
+
/** The cairnManifest plugin. It serves the verify virtual module to the app graph and, in
|
|
91
|
+
* buildStart, evaluates it through a nested Vite SSR load so a manifest drift fails the build. */
|
|
92
|
+
export function cairnManifest(opts) {
|
|
93
|
+
let root = process.cwd();
|
|
94
|
+
const plugin = {
|
|
95
|
+
name: 'cairn-manifest',
|
|
96
|
+
configResolved(config) {
|
|
97
|
+
// Capture the resolved app root so the nested server loads the same config the build did.
|
|
98
|
+
root = config.root;
|
|
99
|
+
},
|
|
100
|
+
resolveId(id) {
|
|
101
|
+
if (id === VIRTUAL_ID)
|
|
102
|
+
return RESOLVED_ID;
|
|
103
|
+
},
|
|
104
|
+
load(id) {
|
|
105
|
+
if (id === RESOLVED_ID)
|
|
106
|
+
return virtualSource(opts, 'verify');
|
|
107
|
+
},
|
|
108
|
+
async buildStart() {
|
|
109
|
+
try {
|
|
110
|
+
await verifyManifestFromVite(opts, root);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
this.error(err instanceof Error ? err.message : String(err));
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
// Stash the options on the instance so the cairn-manifest bin's writeManifest can read the content
|
|
118
|
+
// globs, config module, and manifest path off the plugin in the consumer's loaded config, sharing
|
|
119
|
+
// exactly the options the build verifies with.
|
|
120
|
+
plugin[CAIRN_OPTIONS] = opts;
|
|
121
|
+
return plugin;
|
|
122
|
+
}
|
|
123
|
+
/** Regenerate the committed manifest from the consumer's corpus and write it to the configured
|
|
124
|
+
* manifestPath. It loads the consumer's Vite config from `cwd`, reads the cairnManifest plugin's
|
|
125
|
+
* options off the instance, evaluates the write-mode virtual module through the build's own
|
|
126
|
+
* resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
|
|
127
|
+
* so the write logic is testable apart from the CLI shell. */
|
|
128
|
+
export async function writeManifest(cwd = process.cwd()) {
|
|
129
|
+
const { loadConfigFromFile } = await import('vite');
|
|
130
|
+
const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, cwd);
|
|
131
|
+
if (!loaded) {
|
|
132
|
+
throw new Error(`cairn-manifest: no Vite config found in ${cwd}`);
|
|
133
|
+
}
|
|
134
|
+
const opts = findCairnOptions(loaded.config.plugins);
|
|
135
|
+
if (!opts) {
|
|
136
|
+
throw new Error('cairn-manifest: the Vite config has no cairnManifest() plugin. Add it so the bin shares the build options.');
|
|
137
|
+
}
|
|
138
|
+
const serialized = await buildManifestFromVite(opts, cwd);
|
|
139
|
+
const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH;
|
|
140
|
+
// The manifest path is app-root-absolute (a leading slash relative to the project), so resolve it
|
|
141
|
+
// against cwd, not the filesystem root.
|
|
142
|
+
const outPath = join(cwd, manifestPath.replace(/^\//, ''));
|
|
143
|
+
await mkdir(dirname(outPath), { recursive: true });
|
|
144
|
+
await writeFile(outPath, serialized);
|
|
145
|
+
}
|
|
146
|
+
/** Walk a Vite plugins option (which may nest arrays, hold falsy slots, or be a thenable) and return
|
|
147
|
+
* the stashed cairnManifest options from the first matching plugin, or null if there is none. */
|
|
148
|
+
function findCairnOptions(plugins) {
|
|
149
|
+
if (!plugins)
|
|
150
|
+
return null;
|
|
151
|
+
if (Array.isArray(plugins)) {
|
|
152
|
+
for (const p of plugins) {
|
|
153
|
+
const found = findCairnOptions(p);
|
|
154
|
+
if (found)
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
if (typeof plugins === 'object' && CAIRN_OPTIONS in plugins) {
|
|
160
|
+
return plugins[CAIRN_OPTIONS] ?? null;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
/** A minimal plugin that serves only the virtual module in one mode, for the nested SSR load. It
|
|
165
|
+
* carries no buildStart, so the nested server never recurses into the verify. */
|
|
166
|
+
function cairnVirtualOnly(opts, mode) {
|
|
167
|
+
return {
|
|
168
|
+
name: 'cairn-manifest-virtual',
|
|
169
|
+
resolveId(id) {
|
|
170
|
+
if (id === VIRTUAL_ID)
|
|
171
|
+
return RESOLVED_ID;
|
|
172
|
+
},
|
|
173
|
+
load(id) {
|
|
174
|
+
if (id === RESOLVED_ID)
|
|
175
|
+
return virtualSource(opts, mode);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
"type": "git",
|
|
14
14
|
"url": "git+https://github.com/glw907/cairn-cms.git"
|
|
15
15
|
},
|
|
16
|
+
"homepage": "https://github.com/glw907/cairn-cms#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/glw907/cairn-cms/issues"
|
|
19
|
+
},
|
|
16
20
|
"keywords": [
|
|
17
21
|
"cms",
|
|
18
22
|
"sveltekit",
|
|
@@ -22,9 +26,9 @@
|
|
|
22
26
|
"markdown"
|
|
23
27
|
],
|
|
24
28
|
"scripts": {
|
|
25
|
-
"package": "svelte-package",
|
|
29
|
+
"package": "svelte-package && chmod +x dist/vite/bin.js",
|
|
26
30
|
"check:package": "npm run package && publint --strict && attw --pack . --ignore-rules no-resolution cjs-resolves-to-esm internal-resolution-error",
|
|
27
|
-
"prepare": "
|
|
31
|
+
"prepare": "npm run package",
|
|
28
32
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
|
29
33
|
"test": "vitest run",
|
|
30
34
|
"test:watch": "vitest",
|
|
@@ -53,11 +57,29 @@
|
|
|
53
57
|
"svelte": "./dist/delivery/index.js",
|
|
54
58
|
"default": "./dist/delivery/index.js"
|
|
55
59
|
},
|
|
60
|
+
"./delivery/head": {
|
|
61
|
+
"types": "./dist/delivery/head.d.ts",
|
|
62
|
+
"svelte": "./dist/delivery/head.js",
|
|
63
|
+
"default": "./dist/delivery/head.js"
|
|
64
|
+
},
|
|
65
|
+
"./delivery/data": {
|
|
66
|
+
"types": "./dist/delivery/data.d.ts",
|
|
67
|
+
"svelte": "./dist/delivery/data.js",
|
|
68
|
+
"default": "./dist/delivery/data.js"
|
|
69
|
+
},
|
|
70
|
+
"./vite": {
|
|
71
|
+
"types": "./dist/vite/index.d.ts",
|
|
72
|
+
"default": "./dist/vite/index.js"
|
|
73
|
+
},
|
|
56
74
|
"./package.json": "./package.json"
|
|
57
75
|
},
|
|
76
|
+
"bin": {
|
|
77
|
+
"cairn-manifest": "./dist/vite/bin.js"
|
|
78
|
+
},
|
|
58
79
|
"files": [
|
|
59
80
|
"dist",
|
|
60
|
-
"src/lib"
|
|
81
|
+
"src/lib",
|
|
82
|
+
"CHANGELOG.md"
|
|
61
83
|
],
|
|
62
84
|
"peerDependencies": {
|
|
63
85
|
"@sveltejs/kit": "^2",
|
|
@@ -3,18 +3,27 @@
|
|
|
3
3
|
// the same way and contributes the same kinds of things: nav entries, route logic,
|
|
4
4
|
// concepts, components, field types, and save hooks. Shaped now so the extension contract
|
|
5
5
|
// is additive later.
|
|
6
|
-
import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig,
|
|
6
|
+
import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, FieldTypeDef } from './types.js';
|
|
7
7
|
import { normalizeConcepts } from './concepts.js';
|
|
8
|
+
import { urlPolicyFrom, type SiteConfig } from '../nav/site-config.js';
|
|
9
|
+
|
|
10
|
+
/** The input to {@link composeRuntime}. `siteConfig` is required so the per-concept URL policy is
|
|
11
|
+
* always derived from one source and can never be silently dropped. `extensions` fold in after the
|
|
12
|
+
* adapter's concepts. */
|
|
13
|
+
export interface ComposeInput {
|
|
14
|
+
adapter: CairnAdapter;
|
|
15
|
+
siteConfig: SiteConfig;
|
|
16
|
+
extensions?: CairnExtension[];
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
/**
|
|
10
|
-
* Fold an adapter and any extensions into the composed runtime (seam 2).
|
|
11
|
-
*
|
|
20
|
+
* Fold an adapter and any extensions into the composed runtime (seam 2). The per-concept URL policy
|
|
21
|
+
* is derived from the site config, the same source the delivery path uses, so the runtime and
|
|
22
|
+
* delivery permalinks cannot diverge. Extension concepts merge after the adapter's. The asset slot
|
|
23
|
+
* (seam 4) passes through untouched.
|
|
12
24
|
*/
|
|
13
|
-
export function composeRuntime(
|
|
14
|
-
|
|
15
|
-
extensions: CairnExtension[] = [],
|
|
16
|
-
urlPolicy: Record<string, ConceptUrlPolicy | undefined> = {},
|
|
17
|
-
): CairnRuntime {
|
|
25
|
+
export function composeRuntime({ adapter, siteConfig, extensions = [] }: ComposeInput): CairnRuntime {
|
|
26
|
+
if (!siteConfig) throw new Error('composeRuntime needs a site config to derive the URL policy');
|
|
18
27
|
const content: Record<string, ConceptConfig | undefined> = { ...adapter.content };
|
|
19
28
|
const adminPanels: AdminPanel[] = [];
|
|
20
29
|
const fieldTypes: FieldTypeDef[] = [];
|
|
@@ -27,7 +36,7 @@ export function composeRuntime(
|
|
|
27
36
|
}
|
|
28
37
|
return {
|
|
29
38
|
siteName: adapter.siteName,
|
|
30
|
-
concepts: normalizeConcepts(content,
|
|
39
|
+
concepts: normalizeConcepts(content, urlPolicyFrom(siteConfig)),
|
|
31
40
|
backend: adapter.backend,
|
|
32
41
|
sender: adapter.sender,
|
|
33
42
|
render: adapter.render,
|
|
@@ -43,6 +43,14 @@ export function normalizeConcepts(
|
|
|
43
43
|
const descriptors: ConceptDescriptor[] = [];
|
|
44
44
|
for (const [id, config] of Object.entries(content)) {
|
|
45
45
|
if (!config) continue;
|
|
46
|
+
const summaryFields = config.summaryFields ?? [];
|
|
47
|
+
const declared = new Set(config.schema.fields.map((field) => field.name));
|
|
48
|
+
const undeclared = summaryFields.find((key) => !declared.has(key));
|
|
49
|
+
if (undeclared !== undefined) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`cairn: concept "${id}" summaryFields key "${undeclared}" is not a declared field`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
46
54
|
const policy = urlPolicy[id] ?? {};
|
|
47
55
|
descriptors.push({
|
|
48
56
|
id,
|
|
@@ -52,6 +60,7 @@ export function normalizeConcepts(
|
|
|
52
60
|
permalink: policy.permalink ?? defaultPermalink(id),
|
|
53
61
|
datePrefix: policy.datePrefix ?? 'day',
|
|
54
62
|
fields: config.schema.fields,
|
|
63
|
+
summaryFields,
|
|
55
64
|
validate: config.schema.validate,
|
|
56
65
|
});
|
|
57
66
|
}
|
|
@@ -56,6 +56,27 @@ export function dateInputValue(value: unknown): string {
|
|
|
56
56
|
return '';
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* True when `s` is a canonical zero-padded `YYYY-MM-DD` string naming a real calendar date.
|
|
61
|
+
* Rejects a wrong format, an impossible month or day, and a JS date-rollover such as
|
|
62
|
+
* `2026-02-30` (which `Date` would silently roll forward to March 2). The committed form a
|
|
63
|
+
* date field carries is exactly this canonical shape, which is what the form and
|
|
64
|
+
* `dateInputValue` emit, so a value outside it is a hand-edit or odd-YAML error.
|
|
65
|
+
*/
|
|
66
|
+
export function isCalendarDate(s: string): boolean {
|
|
67
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
68
|
+
if (!match) return false;
|
|
69
|
+
const year = Number(match[1]);
|
|
70
|
+
const month = Number(match[2]);
|
|
71
|
+
const day = Number(match[3]);
|
|
72
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
73
|
+
return (
|
|
74
|
+
date.getUTCFullYear() === year &&
|
|
75
|
+
date.getUTCMonth() === month - 1 &&
|
|
76
|
+
date.getUTCDate() === day
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
59
80
|
/** Reassemble a markdown file from frontmatter and body for committing. */
|
|
60
81
|
export function serializeMarkdown(frontmatter: object, body: string): string {
|
|
61
82
|
return matter.stringify(body, frontmatter);
|
|
@@ -140,15 +140,71 @@ export function parseManifest(raw: string): Manifest {
|
|
|
140
140
|
return { version: 1, entries: obj.entries as ManifestEntry[] };
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
143
|
+
/** A changed entry and the fields that differ between the built and committed manifests. */
|
|
144
|
+
export interface ManifestEntryDiff {
|
|
145
|
+
concept: string;
|
|
146
|
+
id: string;
|
|
147
|
+
fields: string[];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The drift between a freshly built manifest and the committed one, keyed by concept+id. */
|
|
151
|
+
export interface ManifestDiff {
|
|
152
|
+
added: ManifestEntry[];
|
|
153
|
+
removed: ManifestEntry[];
|
|
154
|
+
changed: ManifestEntryDiff[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const keyOf = (e: ManifestEntry) => `${e.concept}/${e.id}`;
|
|
158
|
+
|
|
159
|
+
/** Compare a built manifest against a committed one, keyed by concept+id (the same identity
|
|
160
|
+
* upsertEntry and removeEntry use). A changed entry names the fields that differ. Pure, so it is
|
|
161
|
+
* unit-tested apart from any build. */
|
|
162
|
+
export function diffManifests(built: Manifest, committed: Manifest): ManifestDiff {
|
|
163
|
+
const builtByKey = new Map(built.entries.map((e) => [keyOf(e), e]));
|
|
164
|
+
const committedByKey = new Map(committed.entries.map((e) => [keyOf(e), e]));
|
|
165
|
+
const added = built.entries.filter((e) => !committedByKey.has(keyOf(e)));
|
|
166
|
+
const removed = committed.entries.filter((e) => !builtByKey.has(keyOf(e)));
|
|
167
|
+
const changed: ManifestEntryDiff[] = [];
|
|
168
|
+
for (const b of built.entries) {
|
|
169
|
+
const c = committedByKey.get(keyOf(b));
|
|
170
|
+
if (!c) continue;
|
|
171
|
+
// ManifestEntry has no index signature, so read its keys through an unknown-cast record.
|
|
172
|
+
const br = b as unknown as Record<string, unknown>;
|
|
173
|
+
const cr = c as unknown as Record<string, unknown>;
|
|
174
|
+
const fields = [...new Set([...Object.keys(b), ...Object.keys(c)])].filter(
|
|
175
|
+
(k) => JSON.stringify(br[k]) !== JSON.stringify(cr[k]),
|
|
150
176
|
);
|
|
177
|
+
if (fields.length > 0) changed.push({ concept: b.concept, id: b.id, fields });
|
|
151
178
|
}
|
|
179
|
+
return { added, removed, changed };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Format a diff into a short human-readable block for a build error. */
|
|
183
|
+
function formatDiff(d: ManifestDiff): string {
|
|
184
|
+
const lines: string[] = [];
|
|
185
|
+
for (const e of d.added) lines.push(` + ${keyOf(e)}`);
|
|
186
|
+
for (const e of d.removed) lines.push(` - ${keyOf(e)}`);
|
|
187
|
+
for (const e of d.changed) lines.push(` ~ ${e.concept}/${e.id} (${e.fields.join(', ')})`);
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Throw if the committed manifest drifts from what the corpus says. The canonical serialized form
|
|
192
|
+
* is the fast-path equality guard, so semantic equality never spuriously fails. On a mismatch the
|
|
193
|
+
* error names the added, removed, and changed entries, so a raw-git content edit that leaves the
|
|
194
|
+
* committed manifest stale fails the build loudly with what drifted. */
|
|
195
|
+
export function verifyManifest(built: Manifest, committedRaw: string): void {
|
|
196
|
+
const builtRaw = serializeManifest(built);
|
|
197
|
+
if (committedRaw === builtRaw) return;
|
|
198
|
+
// Diff the canonical built form, not the raw one. serializeManifest sorts each entry's links, so a
|
|
199
|
+
// build whose links are in extraction order would otherwise report a false (links) drift for an
|
|
200
|
+
// entry whose link set is identical and only the order differs. Reuse the serialized form so both
|
|
201
|
+
// sides are canonical.
|
|
202
|
+
const diff = diffManifests(parseManifest(builtRaw), parseManifest(committedRaw));
|
|
203
|
+
throw new Error(
|
|
204
|
+
'content manifest is stale: the committed file does not match the corpus.\n' +
|
|
205
|
+
formatDiff(diff) +
|
|
206
|
+
'\nRegenerate it (npm run cairn:manifest) and commit the result.',
|
|
207
|
+
);
|
|
152
208
|
}
|
|
153
209
|
|
|
154
210
|
/** Replace the entry with the same concept and id, or add it. Order does not matter, since
|
package/src/lib/content/types.ts
CHANGED
|
@@ -110,6 +110,9 @@ export interface ConceptConfig<S extends ConceptSchema = ConceptSchema> {
|
|
|
110
110
|
label?: string;
|
|
111
111
|
/** The concept's schema: the form projection, the generated validator, and the inferred type. */
|
|
112
112
|
schema: S;
|
|
113
|
+
/** Frontmatter keys to surface on each `ContentSummary.fields`, so a list card reads an authored
|
|
114
|
+
* field without a per-entry detail read. Each key should also be declared in `schema`. */
|
|
115
|
+
summaryFields?: string[];
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
/**
|
|
@@ -215,6 +218,9 @@ export interface ConceptDescriptor {
|
|
|
215
218
|
/** Filename date-prefix granularity for a dated concept; resolved by `normalizeConcepts`. */
|
|
216
219
|
datePrefix: DatePrefix;
|
|
217
220
|
fields: FrontmatterField[];
|
|
221
|
+
/** Frontmatter keys the index copies onto each summary's `fields` record. `normalizeConcepts`
|
|
222
|
+
* resolves it to `[]` when a concept omits `summaryFields`. */
|
|
223
|
+
summaryFields: string[];
|
|
218
224
|
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
219
225
|
}
|
|
220
226
|
|
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
// validator stays thin (engine-fat rule). Saving runs the concept's validator on the
|
|
4
4
|
// server before any commit; invalid input bounces to the form (spec §7.4).
|
|
5
5
|
import type { FrontmatterField, ValidationResult } from './types.js';
|
|
6
|
-
import { dateInputValue } from './frontmatter.js';
|
|
6
|
+
import { dateInputValue, isCalendarDate } from './frontmatter.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Validate raw frontmatter against a field list. Required text and date fields must be
|
|
10
10
|
* non-empty; required tag fields must be non-empty lists. A present boolean coerces to `true`
|
|
11
11
|
* and an unchecked one is omitted; a present tag field coerces to a string array and an empty
|
|
12
|
-
* one is omitted
|
|
12
|
+
* one is omitted, so validated data carries no key for an absent tag field (`tags` or `freetags`).
|
|
13
|
+
* The delivery read model
|
|
14
|
+
* (`ContentSummary.tags`) fills that case with an empty array; the two layers differ on purpose.
|
|
15
|
+
* An empty optional text or date field is omitted, so the normalized data
|
|
13
16
|
* carries only meaningful values and committed frontmatter stays minimal. Returns the
|
|
14
17
|
* normalized data, or field-keyed errors when any required field is empty.
|
|
15
18
|
*
|
|
@@ -34,12 +37,17 @@ export function validateFields(
|
|
|
34
37
|
case 'freetags': {
|
|
35
38
|
const list = Array.isArray(value) ? value.map(String) : [];
|
|
36
39
|
if (field.required && list.length === 0) errors[field.name] = `${field.label} is required`;
|
|
40
|
+
else if (field.type === 'tags') {
|
|
41
|
+
const unknown = list.find((tag) => !field.options.includes(tag));
|
|
42
|
+
if (unknown !== undefined) errors[field.name] = `${field.label} contains an unknown value: ${unknown}`;
|
|
43
|
+
}
|
|
37
44
|
if (list.length > 0) data[field.name] = list;
|
|
38
45
|
break;
|
|
39
46
|
}
|
|
40
47
|
case 'date': {
|
|
41
48
|
const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
|
|
42
49
|
if (field.required && text === '') errors[field.name] = `${field.label} is required`;
|
|
50
|
+
else if (text !== '' && !isCalendarDate(text)) errors[field.name] = `${field.label} must be a valid date (YYYY-MM-DD)`;
|
|
43
51
|
if (text !== '') data[field.name] = text;
|
|
44
52
|
break;
|
|
45
53
|
}
|
|
@@ -16,16 +16,27 @@ export interface RawFile {
|
|
|
16
16
|
|
|
17
17
|
/** The cheap, plain-data view of one entry, for lists, feeds, and the sitemap. */
|
|
18
18
|
export interface ContentSummary {
|
|
19
|
+
/** The descriptor id this entry belongs to, e.g. "posts". Lets a list or page branch per
|
|
20
|
+
* concept without re-deriving it from a proxy like `entry.date`. */
|
|
21
|
+
concept: string;
|
|
19
22
|
id: string;
|
|
20
23
|
slug: string;
|
|
21
24
|
permalink: string;
|
|
22
25
|
title: string;
|
|
23
26
|
date?: string;
|
|
24
27
|
updated?: string;
|
|
28
|
+
/** The entry's tags, always present as an array and empty when the file declares none. This is the
|
|
29
|
+
* read-model normalization. It differs on purpose from the validated `frontmatter.tags`, which the
|
|
30
|
+
* validator omits when empty, so a published file carries no `tags: []` noise. Read `tags` here for
|
|
31
|
+
* a list; read `frontmatter.tags` only when you need the validated, possibly-absent value. */
|
|
25
32
|
tags: string[];
|
|
26
33
|
excerpt: string;
|
|
27
34
|
wordCount: number;
|
|
28
35
|
draft: boolean;
|
|
36
|
+
/** The frontmatter keys the descriptor nominated via `summaryFields`, read off the validated,
|
|
37
|
+
* normalized frontmatter. Held in a separate record so a nominated key cannot collide with a
|
|
38
|
+
* typed summary field. Empty when the concept declares no `summaryFields`. */
|
|
39
|
+
fields: Record<string, unknown>;
|
|
29
40
|
}
|
|
30
41
|
|
|
31
42
|
/** The detail view: a summary plus the frontmatter and the body to render. The frontmatter
|
|
@@ -98,7 +109,12 @@ export function createContentIndex<F = Record<string, unknown>>(
|
|
|
98
109
|
problems.push({ id, draft, errors: result.errors });
|
|
99
110
|
continue;
|
|
100
111
|
}
|
|
112
|
+
const summaryFieldValues: Record<string, unknown> = {};
|
|
113
|
+
for (const key of descriptor.summaryFields) {
|
|
114
|
+
if (key in result.data) summaryFieldValues[key] = result.data[key];
|
|
115
|
+
}
|
|
101
116
|
entries.push({
|
|
117
|
+
concept: descriptor.id,
|
|
102
118
|
id,
|
|
103
119
|
slug,
|
|
104
120
|
permalink: permalink(descriptor, { id, slug, date }),
|
|
@@ -109,6 +125,7 @@ export function createContentIndex<F = Record<string, unknown>>(
|
|
|
109
125
|
excerpt: deriveExcerpt(body, { description: asString(raw.description) }),
|
|
110
126
|
wordCount: wordCount(body),
|
|
111
127
|
draft,
|
|
128
|
+
fields: summaryFieldValues,
|
|
112
129
|
frontmatter: result.data as F,
|
|
113
130
|
body,
|
|
114
131
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// cairn-cms: the node-safe delivery data surface (@glw907/cairn-cms/delivery/data). The pure corpus
|
|
2
|
+
// projections a SvelteKit site or a plain-Node tool reads, with no @sveltejs/kit and no .svelte in
|
|
3
|
+
// the graph. The full ./delivery barrel re-exports this and adds the route loaders.
|
|
4
|
+
export { createContentIndex, fromGlob } from './content-index.js';
|
|
5
|
+
export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
|
|
6
|
+
export { createSiteIndex } from './site-index.js';
|
|
7
|
+
export type { SiteIndex, ConceptIndex } from './site-index.js';
|
|
8
|
+
export { createSiteIndexes } from './site-indexes.js';
|
|
9
|
+
export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
|
|
10
|
+
export { siteDescriptors } from './site-descriptors.js';
|
|
11
|
+
export { deriveExcerpt, wordCount } from './excerpt.js';
|
|
12
|
+
export { buildRssFeed, buildJsonFeed } from './feeds.js';
|
|
13
|
+
export type { FeedChannel, FeedItem } from './feeds.js';
|
|
14
|
+
export { buildSitemap } from './sitemap.js';
|
|
15
|
+
export type { SitemapUrl } from './sitemap.js';
|
|
16
|
+
export { buildRobots } from './robots.js';
|
|
17
|
+
export { buildSeoMeta } from './seo.js';
|
|
18
|
+
export type { SeoInput, SeoMeta } from './seo.js';
|
|
19
|
+
export { readSeoFields, resolveImageUrl } from './seo-fields.js';
|
|
20
|
+
export type { SeoFields } from './seo-fields.js';
|
|
21
|
+
export { paginate } from './paginate.js';
|
|
22
|
+
export type { Page } from './paginate.js';
|
|
23
|
+
export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
|
|
24
|
+
export { jsonLdScript } from './json-ld.js';
|
|
25
|
+
export { permalink } from '../content/permalink.js';
|
|
26
|
+
export { buildSiteManifest, buildLinkResolver } from './manifest.js';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// cairn-cms: the delivery head component entry (@glw907/cairn-cms/delivery/head). CairnHead lives
|
|
2
|
+
// behind its own export so importing a delivery data helper from /delivery never pulls a .svelte
|
|
3
|
+
// module into the graph. A node-environment data import then needs no Svelte plugin.
|
|
4
|
+
export { default as CairnHead } from './CairnHead.svelte';
|
|
@@ -1,31 +1,8 @@
|
|
|
1
|
-
// cairn-cms: the public delivery entry (@glw907/cairn-cms/delivery). The
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
export { createContentIndex, fromGlob } from './content-index.js';
|
|
7
|
-
export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
|
|
8
|
-
export { createSiteIndex } from './site-index.js';
|
|
9
|
-
export type { SiteIndex, ConceptIndex } from './site-index.js';
|
|
10
|
-
export { createSiteIndexes } from './site-indexes.js';
|
|
11
|
-
export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
|
|
12
|
-
export { siteDescriptors } from './site-descriptors.js';
|
|
13
|
-
export { deriveExcerpt, wordCount } from './excerpt.js';
|
|
14
|
-
export { buildRssFeed, buildJsonFeed } from './feeds.js';
|
|
15
|
-
export type { FeedChannel, FeedItem } from './feeds.js';
|
|
16
|
-
export { buildSitemap } from './sitemap.js';
|
|
17
|
-
export type { SitemapUrl } from './sitemap.js';
|
|
18
|
-
export { buildRobots } from './robots.js';
|
|
19
|
-
export { buildSeoMeta } from './seo.js';
|
|
20
|
-
export type { SeoInput, SeoMeta } from './seo.js';
|
|
21
|
-
export { readSeoFields, resolveImageUrl } from './seo-fields.js';
|
|
22
|
-
export type { SeoFields } from './seo-fields.js';
|
|
23
|
-
export { paginate } from './paginate.js';
|
|
24
|
-
export type { Page } from './paginate.js';
|
|
25
|
-
export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
|
|
26
|
-
export { jsonLdScript } from './json-ld.js';
|
|
27
|
-
export { permalink } from '../content/permalink.js';
|
|
28
|
-
export { buildSiteManifest, buildLinkResolver } from './manifest.js';
|
|
1
|
+
// cairn-cms: the public delivery entry (@glw907/cairn-cms/delivery). The node-safe data surface
|
|
2
|
+
// (re-exported from ./delivery/data) plus the SvelteKit catch-all route loaders. The head component
|
|
3
|
+
// lives at ./delivery/head. Importing this pulls @sveltejs/kit through the route loaders, so a
|
|
4
|
+
// plain-Node tool imports from ./delivery/data instead.
|
|
5
|
+
export * from './data.js';
|
|
29
6
|
export { createPublicRoutes } from '../sveltekit/public-routes.js';
|
|
30
7
|
export type {
|
|
31
8
|
PublicRoutesDeps,
|
|
@@ -34,4 +11,3 @@ export type {
|
|
|
34
11
|
TagIndexData,
|
|
35
12
|
EntryData,
|
|
36
13
|
} from '../sveltekit/public-routes.js';
|
|
37
|
-
export { default as CairnHead } from './CairnHead.svelte';
|
package/src/lib/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ export type {
|
|
|
31
31
|
} from './content/types.js';
|
|
32
32
|
export { CONCEPT_ROUTING, normalizeConcepts, findConcept } from './content/concepts.js';
|
|
33
33
|
export { composeRuntime } from './content/compose.js';
|
|
34
|
+
export type { ComposeInput } from './content/compose.js';
|
|
34
35
|
export {
|
|
35
36
|
frontmatterFromForm,
|
|
36
37
|
dateInputValue,
|
|
@@ -59,13 +60,14 @@ export {
|
|
|
59
60
|
parseManifest,
|
|
60
61
|
emptyManifest,
|
|
61
62
|
verifyManifest,
|
|
63
|
+
diffManifests,
|
|
62
64
|
upsertEntry,
|
|
63
65
|
removeEntry,
|
|
64
66
|
manifestEntryFromFile,
|
|
65
67
|
manifestLinkResolver,
|
|
66
68
|
inboundLinks,
|
|
67
69
|
} from './content/manifest.js';
|
|
68
|
-
export type { Manifest, ManifestEntry, LinkTarget, InboundLink } from './content/manifest.js';
|
|
70
|
+
export type { Manifest, ManifestEntry, ManifestDiff, ManifestEntryDiff, LinkTarget, InboundLink } from './content/manifest.js';
|
|
69
71
|
// Render engine (Plan 04): generic directive pipeline; sites own the component registry.
|
|
70
72
|
export { defineRegistry, emptyValues } from './render/registry.js';
|
|
71
73
|
export type {
|
|
@@ -92,6 +94,7 @@ export {
|
|
|
92
94
|
strProp,
|
|
93
95
|
iconSpan,
|
|
94
96
|
cardShell,
|
|
97
|
+
headRow,
|
|
95
98
|
markFirstList,
|
|
96
99
|
} from './render/rehype-dispatch.js';
|
|
97
100
|
export type { MakeIcon } from './render/rehype-dispatch.js';
|
|
@@ -155,3 +158,9 @@ export { readSeoFields, resolveImageUrl } from './delivery/seo-fields.js';
|
|
|
155
158
|
export type { SeoFields } from './delivery/seo-fields.js';
|
|
156
159
|
export { paginate } from './delivery/paginate.js';
|
|
157
160
|
export type { Page } from './delivery/paginate.js';
|
|
161
|
+
// Root superset of the delivery route surface: a wrong guess from root for a route loader or a
|
|
162
|
+
// response helper now resolves. The CairnHead component stays out of root so the root barrel stays
|
|
163
|
+
// node-importable for the unit suite; it resolves from @glw907/cairn-cms/delivery/head.
|
|
164
|
+
export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './delivery/responses.js';
|
|
165
|
+
export { createPublicRoutes } from './sveltekit/public-routes.js';
|
|
166
|
+
export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData } from './sveltekit/public-routes.js';
|