@glw907/cairn-cms 0.62.2 → 0.76.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 (196) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/auth/types.d.ts +7 -0
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  5. package/dist/components/ComponentForm.svelte +44 -27
  6. package/dist/components/ComponentInsertDialog.svelte +22 -11
  7. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  8. package/dist/components/ConceptList.svelte +25 -4
  9. package/dist/components/EditPage.svelte +29 -107
  10. package/dist/components/EditPage.svelte.d.ts +2 -7
  11. package/dist/components/EntryPicker.svelte +117 -0
  12. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  13. package/dist/components/FieldInput.svelte +218 -0
  14. package/dist/components/FieldInput.svelte.d.ts +51 -0
  15. package/dist/components/IconPicker.svelte +2 -2
  16. package/dist/components/IconPicker.svelte.d.ts +2 -0
  17. package/dist/components/LinkPicker.svelte +8 -75
  18. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  19. package/dist/components/MediaHeroField.svelte +8 -5
  20. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  21. package/dist/components/ObjectGroupField.svelte +54 -0
  22. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  23. package/dist/components/ReferenceField.svelte +94 -0
  24. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  25. package/dist/components/RepeatableField.svelte +221 -0
  26. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  27. package/dist/components/cairn-admin.css +179 -2
  28. package/dist/components/preview-doc.js +5 -1
  29. package/dist/components/tidy-validate.js +1 -1
  30. package/dist/content/adapter.js +18 -0
  31. package/dist/content/advisories.d.ts +2 -2
  32. package/dist/content/advisories.js +3 -5
  33. package/dist/content/compose.d.ts +7 -6
  34. package/dist/content/compose.js +26 -20
  35. package/dist/content/concepts.d.ts +21 -15
  36. package/dist/content/concepts.js +55 -32
  37. package/dist/content/field-rules.d.ts +15 -0
  38. package/dist/content/field-rules.js +38 -0
  39. package/dist/content/fields.d.ts +169 -0
  40. package/dist/content/fields.js +41 -0
  41. package/dist/content/fieldset.d.ts +107 -0
  42. package/dist/content/fieldset.js +386 -0
  43. package/dist/content/frontmatter-region.d.ts +38 -0
  44. package/dist/content/frontmatter-region.js +75 -0
  45. package/dist/content/frontmatter.d.ts +35 -2
  46. package/dist/content/frontmatter.js +232 -11
  47. package/dist/content/manifest.d.ts +34 -0
  48. package/dist/content/manifest.js +80 -4
  49. package/dist/content/media-refs.d.ts +2 -2
  50. package/dist/content/media-rewrite.js +1 -69
  51. package/dist/content/reference-index.d.ts +56 -0
  52. package/dist/content/reference-index.js +95 -0
  53. package/dist/content/references.d.ts +40 -0
  54. package/dist/content/references.js +0 -0
  55. package/dist/content/standard-schema.d.ts +30 -0
  56. package/dist/content/standard-schema.js +4 -0
  57. package/dist/content/types.d.ts +127 -178
  58. package/dist/delivery/data.d.ts +2 -2
  59. package/dist/delivery/data.js +1 -1
  60. package/dist/delivery/public-routes.d.ts +10 -5
  61. package/dist/delivery/public-routes.js +25 -2
  62. package/dist/delivery/site-descriptors.d.ts +5 -1
  63. package/dist/delivery/site-descriptors.js +8 -3
  64. package/dist/delivery/site-indexes.d.ts +2 -2
  65. package/dist/delivery/site-resolver.d.ts +25 -0
  66. package/dist/delivery/site-resolver.js +49 -0
  67. package/dist/doctor/checks-local.js +6 -11
  68. package/dist/github/backend.d.ts +83 -0
  69. package/dist/github/backend.js +76 -0
  70. package/dist/github/credentials.d.ts +11 -5
  71. package/dist/github/credentials.js +3 -3
  72. package/dist/github/repo.d.ts +8 -19
  73. package/dist/github/repo.js +69 -80
  74. package/dist/github/types.d.ts +1 -1
  75. package/dist/github/types.js +4 -4
  76. package/dist/index.d.ts +18 -10
  77. package/dist/index.js +9 -5
  78. package/dist/islands/index.d.ts +12 -0
  79. package/dist/islands/index.js +83 -0
  80. package/dist/islands/types.d.ts +7 -0
  81. package/dist/islands/types.js +1 -0
  82. package/dist/log/events.d.ts +1 -1
  83. package/dist/media/index.d.ts +1 -1
  84. package/dist/media/index.js +1 -1
  85. package/dist/media/manifest.d.ts +11 -0
  86. package/dist/media/manifest.js +13 -0
  87. package/dist/media/rewrite-plan.d.ts +2 -3
  88. package/dist/media/rewrite-plan.js +2 -3
  89. package/dist/media/usage.d.ts +2 -2
  90. package/dist/media/usage.js +3 -5
  91. package/dist/nav/site-config.d.ts +0 -6
  92. package/dist/nav/site-config.js +6 -4
  93. package/dist/render/component-grammar.js +11 -11
  94. package/dist/render/component-reference.js +5 -3
  95. package/dist/render/component-validate.d.ts +4 -1
  96. package/dist/render/component-validate.js +10 -35
  97. package/dist/render/highlight.d.ts +9 -0
  98. package/dist/render/highlight.js +206 -0
  99. package/dist/render/pipeline.d.ts +0 -6
  100. package/dist/render/pipeline.js +13 -2
  101. package/dist/render/registry.d.ts +44 -36
  102. package/dist/render/registry.js +47 -6
  103. package/dist/render/rehype-dispatch.d.ts +6 -10
  104. package/dist/render/rehype-dispatch.js +38 -17
  105. package/dist/render/remark-directives.js +4 -5
  106. package/dist/render/sanitize-schema.d.ts +10 -0
  107. package/dist/render/sanitize-schema.js +30 -1
  108. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  109. package/dist/sveltekit/cairn-admin.js +3 -4
  110. package/dist/sveltekit/content-routes.d.ts +10 -8
  111. package/dist/sveltekit/content-routes.js +269 -181
  112. package/dist/sveltekit/guard.js +10 -0
  113. package/dist/sveltekit/health.d.ts +7 -3
  114. package/dist/sveltekit/health.js +9 -3
  115. package/dist/sveltekit/index.d.ts +1 -1
  116. package/dist/sveltekit/nav-routes.d.ts +6 -5
  117. package/dist/sveltekit/nav-routes.js +22 -20
  118. package/dist/sveltekit/types.d.ts +2 -0
  119. package/dist/vite/index.d.ts +3 -3
  120. package/dist/vite/index.js +17 -8
  121. package/package.json +17 -2
  122. package/src/lib/ambient.ts +7 -0
  123. package/src/lib/auth/types.ts +7 -0
  124. package/src/lib/components/CairnAdmin.svelte +2 -6
  125. package/src/lib/components/ComponentForm.svelte +48 -27
  126. package/src/lib/components/ComponentInsertDialog.svelte +26 -14
  127. package/src/lib/components/ConceptList.svelte +41 -4
  128. package/src/lib/components/EditPage.svelte +43 -119
  129. package/src/lib/components/EntryPicker.svelte +154 -0
  130. package/src/lib/components/FieldInput.svelte +262 -0
  131. package/src/lib/components/IconPicker.svelte +4 -2
  132. package/src/lib/components/LinkPicker.svelte +10 -81
  133. package/src/lib/components/MediaHeroField.svelte +12 -5
  134. package/src/lib/components/ObjectGroupField.svelte +97 -0
  135. package/src/lib/components/ReferenceField.svelte +126 -0
  136. package/src/lib/components/RepeatableField.svelte +310 -0
  137. package/src/lib/components/preview-doc.ts +5 -1
  138. package/src/lib/components/tidy-validate.ts +1 -1
  139. package/src/lib/content/adapter.ts +21 -0
  140. package/src/lib/content/advisories.ts +4 -7
  141. package/src/lib/content/compose.ts +30 -23
  142. package/src/lib/content/concepts.ts +68 -40
  143. package/src/lib/content/field-rules.ts +39 -0
  144. package/src/lib/content/fields.ts +178 -0
  145. package/src/lib/content/fieldset.ts +470 -0
  146. package/src/lib/content/frontmatter-region.ts +90 -0
  147. package/src/lib/content/frontmatter.ts +231 -15
  148. package/src/lib/content/manifest.ts +101 -4
  149. package/src/lib/content/media-refs.ts +2 -2
  150. package/src/lib/content/media-rewrite.ts +7 -80
  151. package/src/lib/content/reference-index.ts +159 -0
  152. package/src/lib/content/references.ts +0 -0
  153. package/src/lib/content/standard-schema.ts +25 -0
  154. package/src/lib/content/types.ts +128 -195
  155. package/src/lib/delivery/data.ts +2 -2
  156. package/src/lib/delivery/public-routes.ts +36 -4
  157. package/src/lib/delivery/site-descriptors.ts +8 -3
  158. package/src/lib/delivery/site-indexes.ts +2 -2
  159. package/src/lib/delivery/site-resolver.ts +64 -0
  160. package/src/lib/doctor/checks-local.ts +6 -14
  161. package/src/lib/github/backend.ts +161 -0
  162. package/src/lib/github/credentials.ts +10 -7
  163. package/src/lib/github/repo.ts +79 -83
  164. package/src/lib/github/types.ts +5 -5
  165. package/src/lib/index.ts +40 -18
  166. package/src/lib/islands/index.ts +84 -0
  167. package/src/lib/islands/types.ts +11 -0
  168. package/src/lib/log/events.ts +1 -0
  169. package/src/lib/media/index.ts +1 -0
  170. package/src/lib/media/manifest.ts +14 -0
  171. package/src/lib/media/rewrite-plan.ts +4 -6
  172. package/src/lib/media/usage.ts +4 -7
  173. package/src/lib/nav/site-config.ts +8 -9
  174. package/src/lib/render/component-grammar.ts +10 -10
  175. package/src/lib/render/component-reference.ts +4 -3
  176. package/src/lib/render/component-validate.ts +10 -35
  177. package/src/lib/render/highlight.ts +259 -0
  178. package/src/lib/render/pipeline.ts +13 -8
  179. package/src/lib/render/registry.ts +88 -42
  180. package/src/lib/render/rehype-dispatch.ts +47 -16
  181. package/src/lib/render/remark-directives.ts +4 -5
  182. package/src/lib/render/sanitize-schema.ts +32 -1
  183. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  184. package/src/lib/sveltekit/content-routes.ts +330 -221
  185. package/src/lib/sveltekit/guard.ts +15 -0
  186. package/src/lib/sveltekit/health.ts +13 -6
  187. package/src/lib/sveltekit/index.ts +2 -2
  188. package/src/lib/sveltekit/nav-routes.ts +33 -29
  189. package/src/lib/sveltekit/types.ts +5 -1
  190. package/src/lib/vite/index.ts +20 -11
  191. package/dist/content/schema.d.ts +0 -87
  192. package/dist/content/schema.js +0 -89
  193. package/dist/content/validate.d.ts +0 -17
  194. package/dist/content/validate.js +0 -93
  195. package/src/lib/content/schema.ts +0 -167
  196. package/src/lib/content/validate.ts +0 -90
@@ -41,6 +41,21 @@ export function createAuthGuard() {
41
41
  return async function handle({ event, resolve }: HandleInput): Promise<Response> {
42
42
  const { pathname } = event.url;
43
43
 
44
+ // Fail closed if the dev-backend flag is set in a deployed runtime. Read both env sources: a
45
+ // Cloudflare Worker var lands on platform.env, an adapter-node OS var on process.env. A correct
46
+ // production build already eliminated the dev backend (the consumer gates it on the build-foldable
47
+ // `dev`), so a set flag signals a polluted environment; refuse loudly.
48
+ const platformFlag = event.platform?.env?.CAIRN_DEV_BACKEND;
49
+ const processFlag =
50
+ typeof process !== 'undefined' ? process.env?.CAIRN_DEV_BACKEND : undefined;
51
+ if (platformFlag === '1' || platformFlag === true || processFlag === '1') {
52
+ log.error('guard.rejected', { reason: 'dev_backend_in_prod', path: pathname });
53
+ return new Response(
54
+ 'cairn: the dev backend flag is set in a deployed environment. Unset CAIRN_DEV_BACKEND.',
55
+ { status: 503 },
56
+ );
57
+ }
58
+
44
59
  // Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
45
60
  // they set checkOrigin: false to hand cairn the admin CSRF authority.
46
61
  if (!isAdminPath(pathname)) {
@@ -2,8 +2,9 @@
2
2
  // PKCS#1-to-PKCS#8 conversion is caught early (spec §7.8). The payload is pass/fail and a
3
3
  // coarse detail only; it never carries the key or a token.
4
4
  import { signingSelfTest } from '../github/signing.js';
5
+ import { isGithubApp } from '../github/backend.js';
5
6
  import type { CairnRuntime } from '../content/types.js';
6
- import type { GithubKeyEnv } from '../github/credentials.js';
7
+ import type { BackendEnv } from '../github/credentials.js';
7
8
 
8
9
  /** The `/admin/healthz` payload. */
9
10
  export interface HealthData {
@@ -11,14 +12,20 @@ export interface HealthData {
11
12
  checks: { githubAppSigning: { ok: boolean; detail?: string } };
12
13
  }
13
14
 
14
- /** Run the signing self-test against the configured App id and the Worker's key secret. */
15
+ /**
16
+ * Run the signing self-test against the configured App id and the Worker's key secret. The self-test
17
+ * is GitHub-specific, so it narrows the provider on `kind === 'github-app'` for the App id; a
18
+ * non-GitHub backend skips the signing check.
19
+ */
15
20
  export async function healthLoad(
16
- event: { platform?: { env?: GithubKeyEnv } },
21
+ event: { platform?: { env?: BackendEnv } },
17
22
  runtime: CairnRuntime,
18
23
  ): Promise<HealthData> {
19
24
  const key = event.platform?.env?.GITHUB_APP_PRIVATE_KEY_B64;
20
- const githubAppSigning = key
21
- ? await signingSelfTest(runtime.backend.appId, key)
22
- : { ok: false, detail: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured' };
25
+ const provider = runtime.backend;
26
+ const githubAppSigning =
27
+ isGithubApp(provider) && key
28
+ ? await signingSelfTest(provider.appId, key)
29
+ : { ok: false, detail: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured' };
23
30
  return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
24
31
  }
@@ -35,7 +35,7 @@ export { parseAdminPath, type AdminView } from './admin-dispatch.js';
35
35
  export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-admin.js';
36
36
  export { healthLoad, type HealthData } from './health.js';
37
37
  export type { RequestContext, CookieJar, HandleInput } from './types.js';
38
- // Re-exported here, not from root, so the public ContentRoutesDeps consumer can name it.
39
- export type { GithubKeyEnv } from '../github/credentials.js';
38
+ // Re-exported here, not from root, so the consumer's app.d.ts Platform block can name it.
39
+ export type { BackendEnv } from '../github/credentials.js';
40
40
  // Re-exported here, not just from root, so the app.d.ts Platform block can name it.
41
41
  export type { AuthEnv } from '../auth/types.js';
@@ -1,15 +1,13 @@
1
1
  // The admin nav-editing routes: the load and save a site's /admin/nav shim calls. A factory closes
2
- // over the composed runtime and the GitHub token mint, mirroring createContentRoutes, so the read
3
- // and commit paths are unit-testable against a fetch double with an injected token.
2
+ // over the composed runtime, mirroring createContentRoutes, so the read and commit paths are
3
+ // unit-testable against a fetch double behind an injected Backend.
4
4
  import { redirect, error } from '@sveltejs/kit';
5
- import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
6
- import { cachedInstallationToken } from '../github/signing.js';
7
- import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
5
  import { isConflict } from '../github/types.js';
9
6
  import { log } from '../log/index.js';
10
7
  import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
11
8
  import { requireSession } from './guard.js';
12
9
  import type { CairnRuntime } from '../content/types.js';
10
+ import type { Backend } from '../github/backend.js';
13
11
  import type { ContentEvent } from './content-routes.js';
14
12
 
15
13
  /** One page option for the URL picker datalist. */
@@ -27,29 +25,35 @@ export interface NavLoadData {
27
25
  error: string | null;
28
26
  }
29
27
 
30
- /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
28
+ /** Injectable dependencies; a test injects a live `Backend` so the read and commit paths run with no real token mint. */
31
29
  export interface NavRoutesDeps {
32
30
  /**
33
- * Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
34
- * A bare string works too; the routes await whatever comes back.
31
+ * Override the resolved content backend. A test injects a live `Backend` (a `makeGithubBackend`
32
+ * over a fetch double) so the read and commit paths run with no real token mint. When set it
33
+ * replaces the per-handler `locals.backend ?? runtime.backend.connect(env)` resolve.
35
34
  */
36
- mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
35
+ backend?: Backend;
37
36
  }
38
37
 
39
38
  /**
40
39
  *
41
40
  */
42
41
  export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
43
- const mintToken =
44
- deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
42
+ /**
43
+ * Resolve the live content backend for one request: the test seam, then the dev double's
44
+ * `event.locals.backend`, then the production `runtime.backend.connect(env)`.
45
+ */
46
+ function resolveBackend(event: ContentEvent): Backend {
47
+ return deps.backend ?? event.locals.backend ?? runtime.backend.connect(event.platform?.env ?? {});
48
+ }
45
49
 
46
50
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
47
- async function pageOptions(token: string): Promise<NavPageOption[]> {
51
+ async function pageOptions(backend: Backend): Promise<NavPageOption[]> {
48
52
  const pageConcepts = runtime.concepts.filter((c) => c.routing.routable && !c.routing.dated);
49
53
  const lists = await Promise.all(
50
54
  pageConcepts.map(async (c) => {
51
55
  try {
52
- const files = await listMarkdown(runtime.backend, c.dir, token);
56
+ const files = await backend.readEntries(c.dir, backend.defaultBranch);
53
57
  return files.map((f): NavPageOption => ({ label: f.id, url: `/${f.id}` }));
54
58
  } catch {
55
59
  return [];
@@ -67,17 +71,12 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
67
71
  const maxDepth = config.maxDepth ?? 2;
68
72
  const menu = { name: config.menuName, label: config.label, maxDepth };
69
73
 
70
- let token: string;
71
- try {
72
- token = await mintToken(event.platform?.env ?? {});
73
- } catch {
74
- return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
75
- }
74
+ const backend = resolveBackend(event);
76
75
 
77
76
  let tree: NavNode[] = [];
78
77
  let raw: string | null = null;
79
78
  try {
80
- raw = await readRaw(runtime.backend, config.configPath, token);
79
+ raw = await backend.readFile(config.configPath, backend.defaultBranch);
81
80
  } catch {
82
81
  // An unreadable config degrades to an empty tree; the first save writes a clean menu.
83
82
  raw = null;
@@ -99,7 +98,7 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
99
98
  return {
100
99
  menu,
101
100
  tree,
102
- pages: await pageOptions(token),
101
+ pages: await pageOptions(backend),
103
102
  saved: event.url.searchParams.get('saved') === '1',
104
103
  error: event.url.searchParams.get('error'),
105
104
  };
@@ -121,18 +120,23 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
121
120
  throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
122
121
  }
123
122
 
124
- const token = await mintToken(event.platform?.env ?? {});
125
- const raw = await readRaw(runtime.backend, config.configPath, token);
123
+ const backend = resolveBackend(event);
124
+ // Read the head BEFORE the content, so this expectedHead is at-or-before the bytes the commit
125
+ // merges. The nav write lands on the default branch and triggers a deploy, so it is fail-closed:
126
+ // a concurrent commit to the config moves the head off this value and the commit throws a
127
+ // conflict, surfacing the reload-and-reapply prompt below rather than a silent last-writer-wins.
128
+ const head = await backend.branchHead(backend.defaultBranch);
129
+ const raw = await backend.readFile(config.configPath, backend.defaultBranch);
126
130
  if (raw === null) throw error(404, 'Site config not found');
127
131
 
128
132
  const commitFields = { concept: 'nav', id: 'site-config', editor: editor.email };
129
133
  try {
130
- await commitFile(
131
- runtime.backend,
132
- config.configPath,
133
- setMenu(raw, config.menuName, tree),
134
- { message: `Update ${config.label.toLowerCase()}`, author: { name: editor.displayName, email: editor.email } },
135
- token,
134
+ await backend.commit(
135
+ backend.defaultBranch,
136
+ [{ path: config.configPath, content: setMenu(raw, config.menuName, tree) }],
137
+ { name: editor.displayName, email: editor.email },
138
+ `Update ${config.label.toLowerCase()}`,
139
+ head ?? undefined,
136
140
  );
137
141
  log.info('commit.succeeded', commitFields);
138
142
  } catch (err) {
@@ -1,6 +1,7 @@
1
1
  // Structural subsets of SvelteKit's RequestEvent. A site passes its real event, which has
2
2
  // these and more, so the engine never imports a site's generated App.* ambient types.
3
3
  import type { AuthEnv, Editor } from '../auth/types.js';
4
+ import type { Backend } from '../github/backend.js';
4
5
 
5
6
  export interface CookieSetOptions {
6
7
  path: string;
@@ -31,7 +32,10 @@ export interface PlatformContext<Env> {
31
32
  export interface EventBase<Env> {
32
33
  url: URL;
33
34
  request: Request;
34
- locals: { editor?: Editor | null };
35
+ // `backend` is the per-request content store the dev-backend handle injects; the engine resolves
36
+ // it ahead of the real provider, so typing it here makes the seam a checked contract rather than a
37
+ // cast. A production request leaves it absent and the real `githubApp` provider connects.
38
+ locals: { editor?: Editor | null; backend?: Backend };
35
39
  platform?: PlatformContext<Env>;
36
40
  }
37
41
 
@@ -53,11 +53,17 @@ function virtualSource(opts: CairnManifestOptions, mode: 'verify' | 'write'): st
53
53
  .join('\n');
54
54
  // In write mode the committed file may not exist yet, so do not import it.
55
55
  const committedImport = mode === 'verify' ? `import committed from ${JSON.stringify(manifestPath + '?raw')};` : '';
56
+ // In verify mode, run verifyReferences after verifyManifest, inside the generated source where the
57
+ // built manifest is in scope. References have no prerender backstop, so this build gate is their only
58
+ // integrity authority; it cannot move to the verifyManifestFromVite TS call site, where `built` does
59
+ // not exist (it lives only in this evaluated string).
56
60
  const resultExpr =
57
- mode === 'write' ? 'serializeManifest(built)' : '(verifyManifest(built, committed), "ok")';
61
+ mode === 'write'
62
+ ? 'serializeManifest(built)'
63
+ : '(verifyManifest(built, committed), verifyReferences(built), "ok")';
58
64
  return `
59
65
  import { buildSiteManifest } from '@glw907/cairn-cms/delivery/data';
60
- import { serializeManifest, verifyManifest } from '@glw907/cairn-cms';
66
+ import { serializeManifest, verifyManifest, verifyReferences } from '@glw907/cairn-cms';
61
67
  import { cairn, siteConfig } from ${JSON.stringify(opts.configModule)};
62
68
  ${committedImport}
63
69
  const globs = {
@@ -242,11 +248,11 @@ export interface AdapterFacts {
242
248
  owner?: string;
243
249
  /** `cairn.backend.repo`. */
244
250
  repo?: string;
245
- /** `cairn.sender.from`. */
251
+ /** `cairn.email.from`. */
246
252
  from?: string;
247
253
  /**
248
- * `cairn.assets.bucketBinding`, the media R2 binding name; undefined when the adapter declares no
249
- * assets. The doctor's conditional media-bucket check reads it.
254
+ * `cairn.media.bucketBinding`, the media R2 binding name; undefined when the adapter declares no
255
+ * media. The doctor's conditional media-bucket check reads it.
250
256
  */
251
257
  mediaBucketBinding?: string;
252
258
  }
@@ -261,13 +267,16 @@ function adapterFactsSource(opts: CairnManifestOptions): string {
261
267
  return `
262
268
  import { cairn } from ${JSON.stringify(opts.configModule)};
263
269
  const backend = cairn?.backend ?? {};
264
- const sender = cairn?.sender ?? {};
265
- const assets = cairn?.assets ?? {};
270
+ const email = cairn?.email ?? {};
271
+ const media = cairn?.media ?? {};
266
272
  const facts = {};
267
- if (typeof backend.owner === 'string') facts.owner = backend.owner;
268
- if (typeof backend.repo === 'string') facts.repo = backend.repo;
269
- if (typeof sender.from === 'string') facts.from = sender.from;
270
- if (typeof assets.bucketBinding === 'string') facts.mediaBucketBinding = assets.bucketBinding;
273
+ // The owner/repo identity is GitHub-specific, so it is read only off the github-app provider.
274
+ if (backend.kind === 'github-app') {
275
+ if (typeof backend.owner === 'string') facts.owner = backend.owner;
276
+ if (typeof backend.repo === 'string') facts.repo = backend.repo;
277
+ }
278
+ if (typeof email.from === 'string') facts.from = email.from;
279
+ if (typeof media.bucketBinding === 'string') facts.mediaBucketBinding = media.bucketBinding;
271
280
  export const result = JSON.stringify(facts);
272
281
  `;
273
282
  }
@@ -1,87 +0,0 @@
1
- import type { FrontmatterField, ImageValue, ValidationResult } from './types.js';
2
- /** The validate input the cairn adapter takes: the raw frontmatter and the body. */
3
- export interface StandardInput {
4
- frontmatter: Record<string, unknown>;
5
- body: string;
6
- }
7
- /**
8
- * A minimal local copy of the Standard Schema v1 interface (https://standardschema.dev), so the
9
- * schema is a drop-in where the ecosystem accepts a validator, with no runtime dependency.
10
- */
11
- export interface StandardSchemaV1<Input = unknown, Output = Input> {
12
- readonly '~standard': {
13
- readonly version: 1;
14
- readonly vendor: string;
15
- readonly validate: (value: unknown) => StandardResult<Output>;
16
- readonly types?: {
17
- readonly input: Input;
18
- readonly output: Output;
19
- };
20
- };
21
- }
22
- type StandardResult<Output> = {
23
- readonly value: Output;
24
- readonly issues?: undefined;
25
- } | {
26
- readonly issues: ReadonlyArray<{
27
- readonly message: string;
28
- readonly path?: ReadonlyArray<PropertyKey>;
29
- }>;
30
- };
31
- /**
32
- * Map one field descriptor to the TS type of its normalized value. text, textarea, and date
33
- * normalize to a string; a closed-vocabulary `tags` field to the option-union array; an `image`
34
- * field to its nested object.
35
- */
36
- type FieldValue<K extends FrontmatterField> = K extends {
37
- type: 'boolean';
38
- } ? boolean : K extends {
39
- type: 'image';
40
- } ? ImageValue : K extends {
41
- type: 'tags';
42
- options: readonly (infer O extends string)[];
43
- } ? O[] : K extends {
44
- type: 'tags' | 'freetags';
45
- } ? string[] : string;
46
- /** Flatten an intersection into a single readable object type. */
47
- type Prettify<T> = {
48
- [K in keyof T]: T[K];
49
- } & {};
50
- /**
51
- * The normalized frontmatter type inferred from a field tuple. A field declared
52
- * `required: true` is a required key; every other field is optional.
53
- */
54
- export type InferFields<F extends readonly FrontmatterField[]> = Prettify<{
55
- [K in F[number] as K extends {
56
- required: true;
57
- } ? K['name'] : never]: FieldValue<K>;
58
- } & {
59
- [K in F[number] as K extends {
60
- required: true;
61
- } ? never : K['name']]?: FieldValue<K>;
62
- }>;
63
- /**
64
- * A concept's schema: the plain-data field projection, the generated validator, and the
65
- * Standard Schema conformance property.
66
- */
67
- export interface ConceptSchema<F extends readonly FrontmatterField[] = readonly FrontmatterField[]> {
68
- /** The declared fields as plain serializable data, for the editor form. */
69
- readonly fields: FrontmatterField[];
70
- /** Validate raw frontmatter, returning field-keyed errors or the normalized data. */
71
- validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
72
- /** Standard Schema v1 conformance, for ecosystem interop. A thin adapter over `validate`. */
73
- readonly '~standard': StandardSchemaV1<StandardInput, InferFields<F>>['~standard'];
74
- }
75
- /** Extract the inferred frontmatter type from a `ConceptSchema`. */
76
- export type Infer<S> = S extends ConceptSchema<infer F> ? InferFields<F> : never;
77
- /**
78
- * Options for `defineFields`. `refine` runs after the per-field rules pass, for cross-field and
79
- * body-dependent checks. It is validation-only: it returns field-keyed errors to merge, or
80
- * nothing, and never transforms the data.
81
- */
82
- export interface DefineFieldsOptions<F extends readonly FrontmatterField[]> {
83
- refine?: (data: InferFields<F>, body: string) => Record<string, string> | undefined;
84
- }
85
- /** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
86
- export declare function defineFields<const F extends readonly FrontmatterField[]>(fields: F, options?: DefineFieldsOptions<F>): ConceptSchema<F>;
87
- export {};
@@ -1,89 +0,0 @@
1
- import { validateFields } from './validate.js';
2
- // Enforce the declarative per-field rules on an already-coerced value. Rules run only on a
3
- // present, non-empty string value, so an absent optional field is never flagged. The first
4
- // failing rule per field wins, so the author sees one clear message at a time.
5
- function applyRules(field, value, errors, patterns) {
6
- if (typeof value !== 'string' || value === '')
7
- return;
8
- if (field.type === 'text' || field.type === 'textarea') {
9
- if (field.min != null && value.length < field.min)
10
- errors[field.name] = `${field.label} must be at least ${field.min} characters`;
11
- else if (field.max != null && value.length > field.max)
12
- errors[field.name] = `${field.label} must be at most ${field.max} characters`;
13
- else if (field.length != null && value.length !== field.length)
14
- errors[field.name] = `${field.label} must be exactly ${field.length} characters`;
15
- else if (field.pattern != null) {
16
- const re = patterns.get(field.name);
17
- if (re && !re.test(value))
18
- errors[field.name] = `${field.label} is not in the expected format`;
19
- }
20
- }
21
- else if (field.type === 'date') {
22
- if (field.min != null && value < field.min)
23
- errors[field.name] = `${field.label} must be on or after ${field.min}`;
24
- else if (field.max != null && value > field.max)
25
- errors[field.name] = `${field.label} must be on or before ${field.max}`;
26
- }
27
- }
28
- // Compile each declared text/textarea pattern once, so a malformed pattern fails loudly at
29
- // declaration (a site config error) instead of throwing from inside validate() on every save.
30
- function compilePatterns(fields) {
31
- const compiled = new Map();
32
- for (const field of fields) {
33
- if ((field.type === 'text' || field.type === 'textarea') && field.pattern != null) {
34
- try {
35
- compiled.set(field.name, new RegExp(field.pattern));
36
- }
37
- catch (cause) {
38
- throw new Error(`cairn: field "${field.name}" has an invalid pattern: ${field.pattern}`, { cause });
39
- }
40
- }
41
- }
42
- return compiled;
43
- }
44
- // True when an image field feeds the social card: an explicit `seo: true`, or the back-compat
45
- // default that the field named `image` is the SEO image. The SEO unify (Task 4) reads this flag.
46
- function isSeoImage(field) {
47
- return field.type === 'image' && (field.seo === true || (field.seo === undefined && field.name === 'image'));
48
- }
49
- // A concept declares at most one SEO image field, so the social card is unambiguous. More than one
50
- // is a site config error: a hero named `cover` plus an explicit `seo` on another, or two explicit
51
- // `seo` fields. Fail loudly at declaration rather than emit a silent or wrong og:image.
52
- function checkSeoImageFields(fields) {
53
- const seo = fields.filter(isSeoImage);
54
- if (seo.length > 1) {
55
- const names = seo.map((field) => `"${field.name}"`).join(', ');
56
- throw new Error(`cairn: a concept declares at most one SEO image field, but found ${seo.length} (${names}). ` +
57
- 'Set seo: false on all but one, or rename the extra image fields so only one feeds the social card.');
58
- }
59
- }
60
- /** Declare a concept's fields once. Returns the schema's faces derived from that one declaration. */
61
- export function defineFields(fields, options = {}) {
62
- const list = [...fields];
63
- const patterns = compilePatterns(list);
64
- checkSeoImageFields(list);
65
- const validate = (frontmatter, body) => {
66
- const base = validateFields(list, frontmatter);
67
- if (!base.ok)
68
- return base;
69
- const errors = {};
70
- for (const field of list)
71
- applyRules(field, base.data[field.name], errors, patterns);
72
- if (Object.keys(errors).length > 0)
73
- return { ok: false, errors };
74
- const refined = options.refine?.(base.data, body);
75
- return refined && Object.keys(refined).length > 0 ? { ok: false, errors: refined } : base;
76
- };
77
- const standard = {
78
- version: 1,
79
- vendor: 'cairn',
80
- validate: (value) => {
81
- const { frontmatter = {}, body = '' } = (value ?? {});
82
- const result = validate(frontmatter ?? {}, body ?? '');
83
- return result.ok
84
- ? { value: result.data }
85
- : { issues: Object.entries(result.errors).map(([field, message]) => ({ message, path: [field] })) };
86
- },
87
- };
88
- return { fields: list, validate, '~standard': standard };
89
- }
@@ -1,17 +0,0 @@
1
- import type { FrontmatterField, ValidationResult } from './types.js';
2
- /**
3
- * Validate raw frontmatter against a field list. Required text and date fields must be
4
- * non-empty; required tag fields must be non-empty lists. A present boolean coerces to `true`
5
- * and an unchecked one is omitted; a present tag field coerces to a string array and an empty
6
- * one is omitted, so validated data carries no key for an absent tag field (`tags` or `freetags`).
7
- * The delivery read model
8
- * (`ContentSummary.tags`) fills that case with an empty array; the two layers differ on purpose.
9
- * An empty optional text or date field is omitted, so the normalized data
10
- * carries only meaningful values and committed frontmatter stays minimal. Returns the
11
- * normalized data, or field-keyed errors when any required field is empty.
12
- *
13
- * Frontmatter may arrive from the edit form (all string values) or from `parseMarkdown`,
14
- * where gray-matter turns an unquoted YAML date into a JS `Date`. The `date` case coerces a
15
- * `Date` to `YYYY-MM-DD` so a valid parsed date is not mistaken for an empty one.
16
- */
17
- export declare function validateFields(fields: FrontmatterField[], frontmatter: Record<string, unknown>): ValidationResult;
@@ -1,93 +0,0 @@
1
- import { dateInputValue, isCalendarDate } from './frontmatter.js';
2
- /**
3
- * Validate raw frontmatter against a field list. Required text and date fields must be
4
- * non-empty; required tag fields must be non-empty lists. A present boolean coerces to `true`
5
- * and an unchecked one is omitted; a present tag field coerces to a string array and an empty
6
- * one is omitted, so validated data carries no key for an absent tag field (`tags` or `freetags`).
7
- * The delivery read model
8
- * (`ContentSummary.tags`) fills that case with an empty array; the two layers differ on purpose.
9
- * An empty optional text or date field is omitted, so the normalized data
10
- * carries only meaningful values and committed frontmatter stays minimal. Returns the
11
- * normalized data, or field-keyed errors when any required field is empty.
12
- *
13
- * Frontmatter may arrive from the edit form (all string values) or from `parseMarkdown`,
14
- * where gray-matter turns an unquoted YAML date into a JS `Date`. The `date` case coerces a
15
- * `Date` to `YYYY-MM-DD` so a valid parsed date is not mistaken for an empty one.
16
- */
17
- export function validateFields(fields, frontmatter) {
18
- const data = {};
19
- const errors = {};
20
- for (const field of fields) {
21
- const value = frontmatter[field.name];
22
- switch (field.type) {
23
- case 'boolean':
24
- // Absent or unchecked means false; omit it so a published file carries no draft: false noise.
25
- if (value === true)
26
- data[field.name] = true;
27
- break;
28
- case 'tags':
29
- case 'freetags': {
30
- const list = Array.isArray(value) ? value.map(String) : [];
31
- if (field.required && list.length === 0)
32
- errors[field.name] = `${field.label} is required`;
33
- else if (field.type === 'tags') {
34
- const unknown = list.find((tag) => !field.options.includes(tag));
35
- if (unknown !== undefined)
36
- errors[field.name] = `${field.label} contains an unknown value: ${unknown}`;
37
- }
38
- if (list.length > 0)
39
- data[field.name] = list;
40
- break;
41
- }
42
- case 'image': {
43
- // A hero is the nested object { src, alt, caption }. Normalize a well-formed value (default
44
- // a missing alt to empty, since alt is debt and never a save block), and drop the key when
45
- // src is empty or absent. A malformed value (a string, or an object without a string src)
46
- // drops the key rather than throwing, so a hand-edit never breaks a save.
47
- let src = '';
48
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
49
- const obj = value;
50
- src = typeof obj.src === 'string' ? obj.src.trim() : '';
51
- if (src !== '') {
52
- const normalized = {
53
- src,
54
- alt: typeof obj.alt === 'string' ? obj.alt : '',
55
- };
56
- const caption = typeof obj.caption === 'string' ? obj.caption.trim() : '';
57
- if (caption !== '')
58
- normalized.caption = caption;
59
- // An explicit decorative choice carries through; it is never required and never a save
60
- // block. A missing or non-boolean value drops the key, like a blank caption.
61
- if (obj.decorative === true)
62
- normalized.decorative = true;
63
- data[field.name] = normalized;
64
- }
65
- }
66
- // A required image needs a src (the presence check), like the other arms; alt is never
67
- // required, since alt is debt. The inferred type makes a required image non-optional, so the
68
- // validator must enforce it or a save could omit it against the type.
69
- if (field.required && src === '')
70
- errors[field.name] = `${field.label} is required`;
71
- break;
72
- }
73
- case 'date': {
74
- const text = value instanceof Date ? dateInputValue(value) : typeof value === 'string' ? value.trim() : '';
75
- if (field.required && text === '')
76
- errors[field.name] = `${field.label} is required`;
77
- else if (text !== '' && !isCalendarDate(text))
78
- errors[field.name] = `${field.label} must be a valid date (YYYY-MM-DD)`;
79
- if (text !== '')
80
- data[field.name] = text;
81
- break;
82
- }
83
- default: {
84
- const text = typeof value === 'string' ? value.trim() : '';
85
- if (field.required && text === '')
86
- errors[field.name] = `${field.label} is required`;
87
- if (text !== '')
88
- data[field.name] = text;
89
- }
90
- }
91
- }
92
- return Object.keys(errors).length > 0 ? { ok: false, errors } : { ok: true, data };
93
- }