@glw907/cairn-cms 0.24.0 → 0.29.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 (193) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +50 -37
  3. package/dist/auth/crypto.d.ts +0 -1
  4. package/dist/auth/store.d.ts +0 -1
  5. package/dist/auth/types.d.ts +0 -1
  6. package/dist/components/AdminLayout.svelte.d.ts +0 -1
  7. package/dist/components/ComponentForm.svelte.d.ts +0 -1
  8. package/dist/components/ComponentInsertDialog.svelte.d.ts +0 -1
  9. package/dist/components/ConceptList.svelte.d.ts +0 -1
  10. package/dist/components/ConfirmPage.svelte.d.ts +0 -1
  11. package/dist/components/DeleteDialog.svelte.d.ts +0 -1
  12. package/dist/components/EditPage.svelte.d.ts +0 -1
  13. package/dist/components/EditorToolbar.svelte.d.ts +0 -1
  14. package/dist/components/IconPicker.svelte.d.ts +0 -1
  15. package/dist/components/LinkPicker.svelte.d.ts +0 -1
  16. package/dist/components/LoginPage.svelte.d.ts +0 -1
  17. package/dist/components/ManageEditors.svelte.d.ts +0 -1
  18. package/dist/components/MarkdownEditor.svelte.d.ts +0 -1
  19. package/dist/components/NavTree.svelte.d.ts +0 -1
  20. package/dist/components/RenameDialog.svelte.d.ts +0 -1
  21. package/dist/components/index.d.ts +0 -1
  22. package/dist/components/link-completion.d.ts +0 -1
  23. package/dist/components/markdown-format.d.ts +0 -1
  24. package/dist/content/adapter.d.ts +0 -1
  25. package/dist/content/compose.d.ts +15 -5
  26. package/dist/content/compose.js +9 -5
  27. package/dist/content/concepts.d.ts +7 -1
  28. package/dist/content/concepts.js +49 -1
  29. package/dist/content/frontmatter.d.ts +0 -1
  30. package/dist/content/identity.d.ts +23 -0
  31. package/dist/content/identity.js +43 -0
  32. package/dist/content/ids.d.ts +0 -1
  33. package/dist/content/links.d.ts +0 -1
  34. package/dist/content/manifest.d.ts +23 -5
  35. package/dist/content/manifest.js +55 -32
  36. package/dist/content/permalink.d.ts +0 -1
  37. package/dist/content/schema.d.ts +0 -1
  38. package/dist/content/types.d.ts +0 -1
  39. package/dist/content/validate.d.ts +4 -2
  40. package/dist/content/validate.js +4 -1
  41. package/dist/delivery/CairnHead.svelte.d.ts +0 -1
  42. package/dist/delivery/content-index.d.ts +4 -1
  43. package/dist/delivery/content-index.js +8 -25
  44. package/dist/delivery/data.d.ts +23 -0
  45. package/dist/delivery/data.js +18 -0
  46. package/dist/delivery/excerpt.d.ts +0 -1
  47. package/dist/delivery/feeds.d.ts +0 -1
  48. package/dist/delivery/head.d.ts +0 -1
  49. package/dist/delivery/index.d.ts +1 -24
  50. package/dist/delivery/index.js +5 -20
  51. package/dist/delivery/json-ld.d.ts +0 -1
  52. package/dist/delivery/manifest.d.ts +0 -1
  53. package/dist/delivery/paginate.d.ts +0 -1
  54. package/dist/delivery/responses.d.ts +0 -1
  55. package/dist/delivery/robots.d.ts +0 -1
  56. package/dist/delivery/seo-fields.d.ts +0 -1
  57. package/dist/delivery/seo.d.ts +0 -1
  58. package/dist/delivery/site-descriptors.d.ts +0 -1
  59. package/dist/delivery/site-descriptors.js +5 -6
  60. package/dist/delivery/site-index.d.ts +0 -1
  61. package/dist/delivery/site-indexes.d.ts +0 -1
  62. package/dist/delivery/sitemap.d.ts +0 -1
  63. package/dist/email.d.ts +0 -1
  64. package/dist/env.d.ts +0 -1
  65. package/dist/github/credentials.d.ts +0 -1
  66. package/dist/github/repo.d.ts +0 -1
  67. package/dist/github/signing.d.ts +0 -1
  68. package/dist/github/types.d.ts +0 -1
  69. package/dist/index.d.ts +4 -30
  70. package/dist/index.js +2 -24
  71. package/dist/nav/site-config.d.ts +0 -1
  72. package/dist/render/component-grammar.d.ts +0 -1
  73. package/dist/render/component-insert.d.ts +0 -1
  74. package/dist/render/component-reference.d.ts +0 -1
  75. package/dist/render/component-validate.d.ts +0 -1
  76. package/dist/render/glyph.d.ts +0 -1
  77. package/dist/render/index.d.ts +0 -1
  78. package/dist/render/pipeline.d.ts +2 -3
  79. package/dist/render/pipeline.js +7 -2
  80. package/dist/render/registry.d.ts +0 -1
  81. package/dist/render/rehype-dispatch.d.ts +0 -1
  82. package/dist/render/remark-directives.d.ts +0 -1
  83. package/dist/render/resolve-links.d.ts +0 -1
  84. package/dist/render/sanitize-schema.d.ts +14 -1
  85. package/dist/render/sanitize-schema.js +96 -0
  86. package/dist/sveltekit/auth-routes.d.ts +0 -1
  87. package/dist/sveltekit/content-routes.d.ts +0 -1
  88. package/dist/sveltekit/editors-routes.d.ts +0 -1
  89. package/dist/sveltekit/guard.d.ts +0 -1
  90. package/dist/sveltekit/health.d.ts +0 -1
  91. package/dist/sveltekit/index.d.ts +1 -3
  92. package/dist/sveltekit/index.js +0 -1
  93. package/dist/sveltekit/nav-routes.d.ts +0 -1
  94. package/dist/sveltekit/public-routes.d.ts +0 -1
  95. package/dist/sveltekit/types.d.ts +0 -1
  96. package/dist/vite/bin.d.ts +2 -0
  97. package/dist/vite/bin.js +9 -0
  98. package/dist/vite/index.d.ts +32 -0
  99. package/dist/vite/index.js +178 -0
  100. package/package.json +22 -4
  101. package/src/lib/content/compose.ts +19 -10
  102. package/src/lib/content/concepts.ts +61 -1
  103. package/src/lib/content/identity.ts +60 -0
  104. package/src/lib/content/manifest.ts +69 -34
  105. package/src/lib/content/validate.ts +4 -1
  106. package/src/lib/delivery/content-index.ts +12 -27
  107. package/src/lib/delivery/data.ts +26 -0
  108. package/src/lib/delivery/index.ts +5 -28
  109. package/src/lib/delivery/site-descriptors.ts +5 -6
  110. package/src/lib/index.ts +4 -57
  111. package/src/lib/render/pipeline.ts +9 -3
  112. package/src/lib/render/sanitize-schema.ts +97 -0
  113. package/src/lib/sveltekit/index.ts +2 -8
  114. package/src/lib/vite/bin.ts +10 -0
  115. package/src/lib/vite/index.ts +213 -0
  116. package/dist/auth/crypto.d.ts.map +0 -1
  117. package/dist/auth/store.d.ts.map +0 -1
  118. package/dist/auth/types.d.ts.map +0 -1
  119. package/dist/components/AdminLayout.svelte.d.ts.map +0 -1
  120. package/dist/components/ComponentForm.svelte.d.ts.map +0 -1
  121. package/dist/components/ComponentInsertDialog.svelte.d.ts.map +0 -1
  122. package/dist/components/ConceptList.svelte.d.ts.map +0 -1
  123. package/dist/components/ConfirmPage.svelte.d.ts.map +0 -1
  124. package/dist/components/DeleteDialog.svelte.d.ts.map +0 -1
  125. package/dist/components/EditPage.svelte.d.ts.map +0 -1
  126. package/dist/components/EditorToolbar.svelte.d.ts.map +0 -1
  127. package/dist/components/IconPicker.svelte.d.ts.map +0 -1
  128. package/dist/components/LinkPicker.svelte.d.ts.map +0 -1
  129. package/dist/components/LoginPage.svelte.d.ts.map +0 -1
  130. package/dist/components/ManageEditors.svelte.d.ts.map +0 -1
  131. package/dist/components/MarkdownEditor.svelte.d.ts.map +0 -1
  132. package/dist/components/NavTree.svelte.d.ts.map +0 -1
  133. package/dist/components/RenameDialog.svelte.d.ts.map +0 -1
  134. package/dist/components/index.d.ts.map +0 -1
  135. package/dist/components/link-completion.d.ts.map +0 -1
  136. package/dist/components/markdown-format.d.ts.map +0 -1
  137. package/dist/content/adapter.d.ts.map +0 -1
  138. package/dist/content/compose.d.ts.map +0 -1
  139. package/dist/content/concepts.d.ts.map +0 -1
  140. package/dist/content/frontmatter.d.ts.map +0 -1
  141. package/dist/content/ids.d.ts.map +0 -1
  142. package/dist/content/links.d.ts.map +0 -1
  143. package/dist/content/manifest.d.ts.map +0 -1
  144. package/dist/content/permalink.d.ts.map +0 -1
  145. package/dist/content/schema.d.ts.map +0 -1
  146. package/dist/content/types.d.ts.map +0 -1
  147. package/dist/content/validate.d.ts.map +0 -1
  148. package/dist/delivery/CairnHead.svelte.d.ts.map +0 -1
  149. package/dist/delivery/content-index.d.ts.map +0 -1
  150. package/dist/delivery/excerpt.d.ts.map +0 -1
  151. package/dist/delivery/feeds.d.ts.map +0 -1
  152. package/dist/delivery/head.d.ts.map +0 -1
  153. package/dist/delivery/index.d.ts.map +0 -1
  154. package/dist/delivery/json-ld.d.ts.map +0 -1
  155. package/dist/delivery/manifest.d.ts.map +0 -1
  156. package/dist/delivery/paginate.d.ts.map +0 -1
  157. package/dist/delivery/responses.d.ts.map +0 -1
  158. package/dist/delivery/robots.d.ts.map +0 -1
  159. package/dist/delivery/seo-fields.d.ts.map +0 -1
  160. package/dist/delivery/seo.d.ts.map +0 -1
  161. package/dist/delivery/site-descriptors.d.ts.map +0 -1
  162. package/dist/delivery/site-index.d.ts.map +0 -1
  163. package/dist/delivery/site-indexes.d.ts.map +0 -1
  164. package/dist/delivery/sitemap.d.ts.map +0 -1
  165. package/dist/email.d.ts.map +0 -1
  166. package/dist/env.d.ts.map +0 -1
  167. package/dist/github/credentials.d.ts.map +0 -1
  168. package/dist/github/repo.d.ts.map +0 -1
  169. package/dist/github/signing.d.ts.map +0 -1
  170. package/dist/github/types.d.ts.map +0 -1
  171. package/dist/index.d.ts.map +0 -1
  172. package/dist/nav/site-config.d.ts.map +0 -1
  173. package/dist/render/component-grammar.d.ts.map +0 -1
  174. package/dist/render/component-insert.d.ts.map +0 -1
  175. package/dist/render/component-reference.d.ts.map +0 -1
  176. package/dist/render/component-validate.d.ts.map +0 -1
  177. package/dist/render/glyph.d.ts.map +0 -1
  178. package/dist/render/index.d.ts.map +0 -1
  179. package/dist/render/pipeline.d.ts.map +0 -1
  180. package/dist/render/registry.d.ts.map +0 -1
  181. package/dist/render/rehype-dispatch.d.ts.map +0 -1
  182. package/dist/render/remark-directives.d.ts.map +0 -1
  183. package/dist/render/resolve-links.d.ts.map +0 -1
  184. package/dist/render/sanitize-schema.d.ts.map +0 -1
  185. package/dist/sveltekit/auth-routes.d.ts.map +0 -1
  186. package/dist/sveltekit/content-routes.d.ts.map +0 -1
  187. package/dist/sveltekit/editors-routes.d.ts.map +0 -1
  188. package/dist/sveltekit/guard.d.ts.map +0 -1
  189. package/dist/sveltekit/health.d.ts.map +0 -1
  190. package/dist/sveltekit/index.d.ts.map +0 -1
  191. package/dist/sveltekit/nav-routes.d.ts.map +0 -1
  192. package/dist/sveltekit/public-routes.d.ts.map +0 -1
  193. package/dist/sveltekit/types.d.ts.map +0 -1
@@ -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, ConceptUrlPolicy, FieldTypeDef } from './types.js';
7
- import { normalizeConcepts } from './concepts.js';
6
+ import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, FieldTypeDef } from './types.js';
7
+ import { resolveConcepts } from './concepts.js';
8
+ import 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). Extension concepts
11
- * merge after the adapter's. The asset slot (seam 4) passes through untouched.
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
- adapter: CairnAdapter,
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, urlPolicy),
39
+ concepts: resolveConcepts(content, siteConfig),
31
40
  backend: adapter.backend,
32
41
  sender: adapter.sender,
33
42
  render: adapter.render,
@@ -4,6 +4,7 @@
4
4
  // future Fragments concept attaches by adding one key under `content` and one routing
5
5
  // entry, with no reshape here.
6
6
  import type { ConceptConfig, ConceptDescriptor, ConceptUrlPolicy, RoutingRule } from './types.js';
7
+ import { urlPolicyFrom, type SiteConfig } from '../nav/site-config.js';
7
8
 
8
9
  /**
9
10
  * Concept-fixed routing, keyed by concept id (spec §7.2). Posts are dated feed entries;
@@ -28,6 +29,43 @@ function defaultPermalink(id: string): string {
28
29
  return id === 'pages' ? '/:slug' : `/${id}/:slug`;
29
30
  }
30
31
 
32
+ /** Permalink tokens the resolver understands. */
33
+ const KNOWN_TOKENS = new Set(['slug', 'year', 'month', 'day']);
34
+ /** The date-bearing tokens; valid only for a dated concept. */
35
+ const DATE_TOKENS = new Set(['year', 'month', 'day']);
36
+ /** The valid date-prefix granularities. A runtime check, since the YAML is untyped. */
37
+ const DATE_PREFIXES = new Set<string>(['year', 'month', 'day']);
38
+
39
+ /**
40
+ * Validate one concept's URL policy at build, so a misconfigured permalink or datePrefix fails loudly
41
+ * here rather than emitting a wrong or defaulted URL at render. The permalink must be root-relative and
42
+ * use only known tokens, a date token requires a dated concept, and the datePrefix must be in range.
43
+ */
44
+ function validateUrlPolicy(id: string, policy: ConceptUrlPolicy, dated: boolean): void {
45
+ if (policy.permalink !== undefined) {
46
+ const pattern = policy.permalink;
47
+ if (!pattern.startsWith('/')) {
48
+ throw new Error(`cairn: concept "${id}" permalink "${pattern}" must start with "/"`);
49
+ }
50
+ for (const match of pattern.matchAll(/:(\w+)/g)) {
51
+ const token = match[1];
52
+ if (!KNOWN_TOKENS.has(token)) {
53
+ throw new Error(`cairn: concept "${id}" permalink "${pattern}" uses unknown token ":${token}"`);
54
+ }
55
+ if (DATE_TOKENS.has(token) && !dated) {
56
+ throw new Error(
57
+ `cairn: concept "${id}" is not dated, so permalink "${pattern}" cannot use the date token ":${token}"`,
58
+ );
59
+ }
60
+ }
61
+ }
62
+ if (policy.datePrefix !== undefined && !DATE_PREFIXES.has(policy.datePrefix)) {
63
+ throw new Error(
64
+ `cairn: concept "${id}" datePrefix "${policy.datePrefix}" must be one of year, month, day`,
65
+ );
66
+ }
67
+ }
68
+
31
69
  /**
32
70
  * Normalize an adapter's declared concepts into uniform descriptors (seam 1). URL policy
33
71
  * (`permalink`, `datePrefix`) comes from the YAML site-config, passed here as `urlPolicy` keyed by
@@ -41,6 +79,14 @@ export function normalizeConcepts(
41
79
  routing: Readonly<Record<string, RoutingRule>> = CONCEPT_ROUTING,
42
80
  ): ConceptDescriptor[] {
43
81
  const descriptors: ConceptDescriptor[] = [];
82
+ const declaredConcepts = new Set(
83
+ Object.keys(content).filter((key) => content[key] !== undefined),
84
+ );
85
+ for (const key of Object.keys(urlPolicy)) {
86
+ if (!declaredConcepts.has(key)) {
87
+ throw new Error(`cairn: URL policy names concept "${key}", which is not declared under content`);
88
+ }
89
+ }
44
90
  for (const [id, config] of Object.entries(content)) {
45
91
  if (!config) continue;
46
92
  const summaryFields = config.summaryFields ?? [];
@@ -51,12 +97,14 @@ export function normalizeConcepts(
51
97
  `cairn: concept "${id}" summaryFields key "${undeclared}" is not a declared field`,
52
98
  );
53
99
  }
100
+ const conceptRouting = routing[id] ?? DEFAULT_ROUTING;
54
101
  const policy = urlPolicy[id] ?? {};
102
+ validateUrlPolicy(id, policy, conceptRouting.dated);
55
103
  descriptors.push({
56
104
  id,
57
105
  label: config.label ?? defaultLabel(id),
58
106
  dir: config.dir,
59
- routing: routing[id] ?? DEFAULT_ROUTING,
107
+ routing: conceptRouting,
60
108
  permalink: policy.permalink ?? defaultPermalink(id),
61
109
  datePrefix: policy.datePrefix ?? 'day',
62
110
  fields: config.schema.fields,
@@ -67,6 +115,18 @@ export function normalizeConcepts(
67
115
  return descriptors;
68
116
  }
69
117
 
118
+ /**
119
+ * Resolve a site's concept descriptors from its content map and parsed site config. The admin runtime
120
+ * (composeRuntime) and the delivery layer (siteDescriptors) both call this, so the per-concept URL
121
+ * policy is derived once from the YAML and the runtime and delivery permalinks cannot diverge.
122
+ */
123
+ export function resolveConcepts(
124
+ content: Record<string, ConceptConfig | undefined>,
125
+ siteConfig: SiteConfig,
126
+ ): ConceptDescriptor[] {
127
+ return normalizeConcepts(content, urlPolicyFrom(siteConfig));
128
+ }
129
+
70
130
  /** Look up a normalized concept by id, or undefined when the site does not enable it. */
71
131
  export function findConcept(
72
132
  concepts: ConceptDescriptor[],
@@ -0,0 +1,60 @@
1
+ // cairn-cms: a content entry's URL identity in one place (engine-hardening pass 3). The id, the
2
+ // slug, the date, and the permalink are computed here, so the content index and the manifest cannot
3
+ // drift on what an entry's URL is. A cairn: link resolves through the manifest in the admin preview
4
+ // and through the content index in the public build, so the two must agree by construction.
5
+ import { idFromFilename, slugFromId } from './ids.js';
6
+ import { permalink } from './permalink.js';
7
+ import type { ConceptDescriptor } from './types.js';
8
+
9
+ /** A content entry's resolved URL identity. */
10
+ export interface EntryIdentity {
11
+ id: string;
12
+ slug: string;
13
+ date?: string;
14
+ permalink: string;
15
+ }
16
+
17
+ /** The basename of a glob path: the segment after the last slash, or the whole path. */
18
+ function basename(path: string): string {
19
+ const slash = path.lastIndexOf('/');
20
+ return slash >= 0 ? path.slice(slash + 1) : path;
21
+ }
22
+
23
+ /** A present, non-empty string, else undefined. The read-model string coercion. */
24
+ export function asString(value: unknown): string | undefined {
25
+ return typeof value === 'string' && value.trim() ? value : undefined;
26
+ }
27
+
28
+ /** A YYYY-MM-DD date. An unquoted YAML date parses as a JS Date; a string is sliced to its date head. */
29
+ export function asDate(value: unknown): string | undefined {
30
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
31
+ if (typeof value === 'string') return value.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
32
+ return undefined;
33
+ }
34
+
35
+ /** Tags as an array, empty when the file declares none. */
36
+ export function asTags(value: unknown): string[] {
37
+ return Array.isArray(value) ? value.map(String) : [];
38
+ }
39
+
40
+ /** A content entry's id: its filename stem (the date prefix is part of a dated id). */
41
+ export function entryId(path: string): string {
42
+ return idFromFilename(basename(path));
43
+ }
44
+
45
+ /**
46
+ * Resolve a content entry's URL identity from its concept descriptor, its file path, and its parsed
47
+ * frontmatter. The slug strips the leading date prefix for a dated concept and is the id verbatim for
48
+ * an undated one. The permalink is the one resolver every reader shares. The caller parses the markdown
49
+ * once and passes the frontmatter, so there is no second parse here.
50
+ */
51
+ export function entryIdentity(
52
+ descriptor: ConceptDescriptor,
53
+ path: string,
54
+ frontmatter: Record<string, unknown>,
55
+ ): EntryIdentity {
56
+ const id = entryId(path);
57
+ const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
58
+ const date = asDate(frontmatter.date);
59
+ return { id, slug, date, permalink: permalink(descriptor, { id, slug, date }) };
60
+ }
@@ -3,9 +3,8 @@
3
3
  // code reads the content graph without an N+1 GitHub crawl. The build regenerates and verifies
4
4
  // it; the save path patches one entry and commits it with the content in one commit. Each entry
5
5
  // carries its identity and its outbound cairn: edges, so the manifest is the link graph.
6
- import { idFromFilename, slugFromId } from './ids.js';
7
6
  import { parseMarkdown } from './frontmatter.js';
8
- import { permalink } from './permalink.js';
7
+ import { entryIdentity, asString } from './identity.js';
9
8
  import { extractCairnLinks, type CairnRef, type LinkResolve } from './links.js';
10
9
  import type { ConceptDescriptor } from './types.js';
11
10
 
@@ -36,38 +35,18 @@ export interface LinkTarget {
36
35
  draft: boolean;
37
36
  }
38
37
 
39
- function basename(path: string): string {
40
- const slash = path.lastIndexOf('/');
41
- return slash >= 0 ? path.slice(slash + 1) : path;
42
- }
43
-
44
- /** Mirror content-index's frontmatter coercion: a present non-empty string, else undefined. */
45
- function asString(value: unknown): string | undefined {
46
- return typeof value === 'string' && value.trim() ? value : undefined;
47
- }
48
-
49
- /** Mirror content-index's date coercion: an unquoted YAML date is a JS Date, a string is sliced. */
50
- function asDate(value: unknown): string | undefined {
51
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
52
- if (typeof value === 'string') return value.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
53
- return undefined;
54
- }
55
-
56
- /** Build one manifest entry from a content file. Drafts are included and flagged. */
38
+ /** Build one manifest entry from a content file. Drafts are included and flagged. The id, date, and
39
+ * permalink come from entryIdentity, the same source content-index uses, so a cairn: link resolves to
40
+ * one URL whether the admin preview reads the manifest or the public build reads the content index. */
57
41
  export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { path: string; raw: string }): ManifestEntry {
58
- const id = idFromFilename(basename(file.path));
59
- // Use the same slug rule content-index uses, so the manifest's permalink for an entry always
60
- // equals content-index's permalink for it. A cairn link must resolve to one URL whether the
61
- // admin preview reads the manifest or the public build reads the content index.
62
- const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
63
42
  const { frontmatter, body } = parseMarkdown(file.raw);
64
- const date = asDate(frontmatter.date);
43
+ const { id, date, permalink } = entryIdentity(descriptor, file.path, frontmatter);
65
44
  return {
66
45
  id,
67
46
  concept: descriptor.id,
68
47
  title: asString(frontmatter.title) ?? id,
69
48
  date,
70
- permalink: permalink(descriptor, { id, slug, date }),
49
+ permalink,
71
50
  draft: frontmatter.draft === true,
72
51
  links: extractCairnLinks(body),
73
52
  };
@@ -140,15 +119,71 @@ export function parseManifest(raw: string): Manifest {
140
119
  return { version: 1, entries: obj.entries as ManifestEntry[] };
141
120
  }
142
121
 
143
- /** Throw if the committed manifest drifts from what the corpus says. Both sides are compared in the
144
- * canonical serialized form, so semantic equality never spuriously fails. The build calls this so a
145
- * raw-git content edit, which leaves the committed manifest stale, fails the build loudly. */
146
- export function verifyManifest(built: Manifest, committedRaw: string): void {
147
- if (committedRaw !== serializeManifest(built)) {
148
- throw new Error(
149
- 'content manifest is stale: the committed file does not match the corpus. Regenerate it (npm run cairn:manifest) and commit the result.',
122
+ /** A changed entry and the fields that differ between the built and committed manifests. */
123
+ export interface ManifestEntryDiff {
124
+ concept: string;
125
+ id: string;
126
+ fields: string[];
127
+ }
128
+
129
+ /** The drift between a freshly built manifest and the committed one, keyed by concept+id. */
130
+ export interface ManifestDiff {
131
+ added: ManifestEntry[];
132
+ removed: ManifestEntry[];
133
+ changed: ManifestEntryDiff[];
134
+ }
135
+
136
+ const keyOf = (e: ManifestEntry) => `${e.concept}/${e.id}`;
137
+
138
+ /** Compare a built manifest against a committed one, keyed by concept+id (the same identity
139
+ * upsertEntry and removeEntry use). A changed entry names the fields that differ. Pure, so it is
140
+ * unit-tested apart from any build. */
141
+ export function diffManifests(built: Manifest, committed: Manifest): ManifestDiff {
142
+ const builtByKey = new Map(built.entries.map((e) => [keyOf(e), e]));
143
+ const committedByKey = new Map(committed.entries.map((e) => [keyOf(e), e]));
144
+ const added = built.entries.filter((e) => !committedByKey.has(keyOf(e)));
145
+ const removed = committed.entries.filter((e) => !builtByKey.has(keyOf(e)));
146
+ const changed: ManifestEntryDiff[] = [];
147
+ for (const b of built.entries) {
148
+ const c = committedByKey.get(keyOf(b));
149
+ if (!c) continue;
150
+ // ManifestEntry has no index signature, so read its keys through an unknown-cast record.
151
+ const br = b as unknown as Record<string, unknown>;
152
+ const cr = c as unknown as Record<string, unknown>;
153
+ const fields = [...new Set([...Object.keys(b), ...Object.keys(c)])].filter(
154
+ (k) => JSON.stringify(br[k]) !== JSON.stringify(cr[k]),
150
155
  );
156
+ if (fields.length > 0) changed.push({ concept: b.concept, id: b.id, fields });
151
157
  }
158
+ return { added, removed, changed };
159
+ }
160
+
161
+ /** Format a diff into a short human-readable block for a build error. */
162
+ function formatDiff(d: ManifestDiff): string {
163
+ const lines: string[] = [];
164
+ for (const e of d.added) lines.push(` + ${keyOf(e)}`);
165
+ for (const e of d.removed) lines.push(` - ${keyOf(e)}`);
166
+ for (const e of d.changed) lines.push(` ~ ${e.concept}/${e.id} (${e.fields.join(', ')})`);
167
+ return lines.join('\n');
168
+ }
169
+
170
+ /** Throw if the committed manifest drifts from what the corpus says. The canonical serialized form
171
+ * is the fast-path equality guard, so semantic equality never spuriously fails. On a mismatch the
172
+ * error names the added, removed, and changed entries, so a raw-git content edit that leaves the
173
+ * committed manifest stale fails the build loudly with what drifted. */
174
+ export function verifyManifest(built: Manifest, committedRaw: string): void {
175
+ const builtRaw = serializeManifest(built);
176
+ if (committedRaw === builtRaw) return;
177
+ // Diff the canonical built form, not the raw one. serializeManifest sorts each entry's links, so a
178
+ // build whose links are in extraction order would otherwise report a false (links) drift for an
179
+ // entry whose link set is identical and only the order differs. Reuse the serialized form so both
180
+ // sides are canonical.
181
+ const diff = diffManifests(parseManifest(builtRaw), parseManifest(committedRaw));
182
+ throw new Error(
183
+ 'content manifest is stale: the committed file does not match the corpus.\n' +
184
+ formatDiff(diff) +
185
+ '\nRegenerate it (npm run cairn:manifest) and commit the result.',
186
+ );
152
187
  }
153
188
 
154
189
  /** Replace the entry with the same concept and id, or add it. Order does not matter, since
@@ -9,7 +9,10 @@ import { dateInputValue, isCalendarDate } from './frontmatter.js';
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; an empty optional text or date field is omitted, so the normalized data
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
  *
@@ -3,8 +3,7 @@
3
3
  // returns cheap plain-data summaries plus an on-demand detail lookup. It is concept-generic:
4
4
  // every operation reads the descriptor and its routing rule, never a hardcoded concept id.
5
5
  import { parseMarkdown } from '../content/frontmatter.js';
6
- import { idFromFilename, slugFromId } from '../content/ids.js';
7
- import { permalink } from '../content/permalink.js';
6
+ import { entryId, entryIdentity, asDate, asString, asTags } from '../content/identity.js';
8
7
  import { deriveExcerpt, wordCount } from './excerpt.js';
9
8
  import type { ConceptDescriptor } from '../content/types.js';
10
9
 
@@ -25,6 +24,10 @@ export interface ContentSummary {
25
24
  title: string;
26
25
  date?: string;
27
26
  updated?: string;
27
+ /** The entry's tags, always present as an array and empty when the file declares none. This is the
28
+ * read-model normalization. It differs on purpose from the validated `frontmatter.tags`, which the
29
+ * validator omits when empty, so a published file carries no `tags: []` noise. Read `tags` here for
30
+ * a list; read `frontmatter.tags` only when you need the validated, possibly-absent value. */
28
31
  tags: string[];
29
32
  excerpt: string;
30
33
  wordCount: number;
@@ -66,25 +69,6 @@ export function fromGlob(record: Record<string, string>): RawFile[] {
66
69
  return Object.entries(record).map(([path, raw]) => ({ path, raw }));
67
70
  }
68
71
 
69
- function basename(path: string): string {
70
- const slash = path.lastIndexOf('/');
71
- return slash >= 0 ? path.slice(slash + 1) : path;
72
- }
73
-
74
- function asString(value: unknown): string | undefined {
75
- return typeof value === 'string' && value.trim() ? value : undefined;
76
- }
77
-
78
- function asDate(value: unknown): string | undefined {
79
- if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
80
- if (typeof value === 'string') return value.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
81
- return undefined;
82
- }
83
-
84
- function asTags(value: unknown): string[] {
85
- return Array.isArray(value) ? value.map(String) : [];
86
- }
87
-
88
72
  /** Build a concept's index from its raw files and normalized descriptor. */
89
73
  export function createContentIndex<F = Record<string, unknown>>(
90
74
  files: RawFile[],
@@ -93,18 +77,19 @@ export function createContentIndex<F = Record<string, unknown>>(
93
77
  const problems: ContentProblem[] = [];
94
78
  const entries: ContentEntry<F>[] = [];
95
79
  for (const file of files) {
96
- const id = idFromFilename(basename(file.path));
97
- const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
98
80
  const { frontmatter: raw, body } = parseMarkdown(file.raw);
99
- const date = asDate(raw.date);
81
+ const id = entryId(file.path);
100
82
  const draft = raw.draft === true;
101
- // Validate once at build. A failure is recorded for the site gate and excluded from the typed
102
- // read, so every readable entry's frontmatter is the validator's normalized output, never raw.
83
+ // Validate before resolving the permalink. A date-token permalink throws on an entry with no
84
+ // valid date; the validate gate records that as a content problem rather than aborting the whole
85
+ // index build, so one bad entry degrades to a skip, not a crash. A failure is also excluded from
86
+ // the typed read, so every readable entry's frontmatter is the validator's normalized output.
103
87
  const result = descriptor.validate(raw, body);
104
88
  if (!result.ok) {
105
89
  problems.push({ id, draft, errors: result.errors });
106
90
  continue;
107
91
  }
92
+ const { slug, date, permalink } = entryIdentity(descriptor, file.path, raw);
108
93
  const summaryFieldValues: Record<string, unknown> = {};
109
94
  for (const key of descriptor.summaryFields) {
110
95
  if (key in result.data) summaryFieldValues[key] = result.data[key];
@@ -113,7 +98,7 @@ export function createContentIndex<F = Record<string, unknown>>(
113
98
  concept: descriptor.id,
114
99
  id,
115
100
  slug,
116
- permalink: permalink(descriptor, { id, slug, date }),
101
+ permalink,
117
102
  title: asString(raw.title) ?? id,
118
103
  date,
119
104
  updated: asDate(raw.updated),
@@ -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';
@@ -1,31 +1,8 @@
1
- // cairn-cms: the public delivery entry (@glw907/cairn-cms/delivery). The complete, canonical,
2
- // backend-free toolkit a SvelteKit site wires its public pages with: the content index and the
3
- // site resolver, the descriptor helper, the syndication and SEO builders, the endpoint response
4
- // helpers, the catch-all route loaders, and the head component. It imports nothing from auth,
5
- // github, or email, so importing it does not pull the server backend into a public bundle.
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,
@@ -1,12 +1,11 @@
1
- // cairn-cms: the one-call descriptor helper. A delivery site needs the same per-concept
2
- // descriptors the admin runtime uses; this wraps the two calls that derive them so the
3
- // pairing is not tribal knowledge. The YAML URL policy stays the single source of truth.
4
- import { normalizeConcepts } from '../content/concepts.js';
5
- import { urlPolicyFrom } from '../nav/site-config.js';
1
+ // cairn-cms: the one-call descriptor helper. A delivery site needs the same per-concept descriptors
2
+ // the admin runtime uses; this delegates to the shared resolveConcepts so the pairing is one path, not
3
+ // tribal knowledge. The YAML URL policy stays the single source of truth.
4
+ import { resolveConcepts } from '../content/concepts.js';
6
5
  import type { CairnAdapter, ConceptDescriptor } from '../content/types.js';
7
6
  import type { SiteConfig } from '../nav/site-config.js';
8
7
 
9
8
  /** Per-concept descriptors for a site, from its adapter content and its parsed site config. */
10
9
  export function siteDescriptors(adapter: CairnAdapter, siteConfig: SiteConfig): ConceptDescriptor[] {
11
- return normalizeConcepts(adapter.content, urlPolicyFrom(siteConfig));
10
+ return resolveConcepts(adapter.content, siteConfig);
12
11
  }
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 {
@@ -86,15 +88,7 @@ export type { ReferenceOptions } from './render/component-reference.js';
86
88
  export { glyph } from './render/glyph.js';
87
89
  export type { IconSet } from './render/glyph.js';
88
90
  export { remarkDirectiveStamp } from './render/remark-directives.js';
89
- export {
90
- rehypeDispatch,
91
- isElement,
92
- strProp,
93
- iconSpan,
94
- cardShell,
95
- headRow,
96
- markFirstList,
97
- } from './render/rehype-dispatch.js';
91
+ export { rehypeDispatch, iconSpan, cardShell, headRow } from './render/rehype-dispatch.js';
98
92
  export type { MakeIcon } from './render/rehype-dispatch.js';
99
93
  export { createRenderer } from './render/pipeline.js';
100
94
  export type { RendererOptions } from './render/pipeline.js';
@@ -102,18 +96,6 @@ export type { RendererOptions } from './render/pipeline.js';
102
96
  // GitHub read-and-commit backend (Plan 03).
103
97
  export type { RepoRef, RepoFile, CommitAuthor, AppCredentials } from './github/types.js';
104
98
  export { CommitConflictError } from './github/types.js';
105
- export { appJwt, installationToken, signingSelfTest } from './github/signing.js';
106
- export {
107
- treeUrl,
108
- markdownFilesIn,
109
- listMarkdown,
110
- contentsUrl,
111
- readRaw,
112
- fileSha,
113
- commitFile,
114
- } from './github/repo.js';
115
- export { appCredentials } from './github/credentials.js';
116
- export type { GithubKeyEnv } from './github/credentials.js';
117
99
 
118
100
  // Nav tree and site-config helpers (Plan 06).
119
101
  export {
@@ -127,38 +109,3 @@ export {
127
109
  SiteConfigError,
128
110
  } from './nav/site-config.js';
129
111
  export type { NavNode, SiteConfig } from './nav/site-config.js';
130
-
131
- // Public content delivery (public-delivery design): the query index, syndication, and
132
- // discovery surface that sites read. Pure builders plus the one permalink resolver; the
133
- // SvelteKit loaders live under the /sveltekit subpath.
134
- export { permalink } from './content/permalink.js';
135
- export { createContentIndex, fromGlob } from './delivery/content-index.js';
136
- export type {
137
- RawFile,
138
- ContentSummary,
139
- ContentEntry,
140
- ContentIndex,
141
- ContentProblem,
142
- } from './delivery/content-index.js';
143
- export { createSiteIndex } from './delivery/site-index.js';
144
- export type { SiteIndex, ConceptIndex } from './delivery/site-index.js';
145
- export { createSiteIndexes } from './delivery/site-indexes.js';
146
- export type { SiteIndexes, SiteGlobs } from './delivery/site-indexes.js';
147
- export { deriveExcerpt, wordCount } from './delivery/excerpt.js';
148
- export { buildRssFeed, buildJsonFeed } from './delivery/feeds.js';
149
- export type { FeedChannel, FeedItem } from './delivery/feeds.js';
150
- export { buildSitemap } from './delivery/sitemap.js';
151
- export type { SitemapUrl } from './delivery/sitemap.js';
152
- export { buildRobots } from './delivery/robots.js';
153
- export { buildSeoMeta } from './delivery/seo.js';
154
- export type { SeoInput, SeoMeta } from './delivery/seo.js';
155
- export { readSeoFields, resolveImageUrl } from './delivery/seo-fields.js';
156
- export type { SeoFields } from './delivery/seo-fields.js';
157
- export { paginate } from './delivery/paginate.js';
158
- export type { Page } from './delivery/paginate.js';
159
- // Root superset of the delivery route surface: a wrong guess from root for a route loader or a
160
- // response helper now resolves. The CairnHead component stays out of root so the root barrel stays
161
- // node-importable for the unit suite; it resolves from @glw907/cairn-cms/delivery/head.
162
- export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './delivery/responses.js';
163
- export { createPublicRoutes } from './sveltekit/public-routes.js';
164
- export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData } from './sveltekit/public-routes.js';