@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
@@ -32,6 +32,16 @@ function isLocalHost(hostname) {
32
32
  export function createAuthGuard() {
33
33
  return async function handle({ event, resolve }) {
34
34
  const { pathname } = event.url;
35
+ // Fail closed if the dev-backend flag is set in a deployed runtime. Read both env sources: a
36
+ // Cloudflare Worker var lands on platform.env, an adapter-node OS var on process.env. A correct
37
+ // production build already eliminated the dev backend (the consumer gates it on the build-foldable
38
+ // `dev`), so a set flag signals a polluted environment; refuse loudly.
39
+ const platformFlag = event.platform?.env?.CAIRN_DEV_BACKEND;
40
+ const processFlag = typeof process !== 'undefined' ? process.env?.CAIRN_DEV_BACKEND : undefined;
41
+ if (platformFlag === '1' || platformFlag === true || processFlag === '1') {
42
+ log.error('guard.rejected', { reason: 'dev_backend_in_prod', path: pathname });
43
+ return new Response('cairn: the dev backend flag is set in a deployed environment. Unset CAIRN_DEV_BACKEND.', { status: 503 });
44
+ }
35
45
  // Rule 2 - non-admin: restore the framework's strict Origin check the consumer disabled when
36
46
  // they set checkOrigin: false to hand cairn the admin CSRF authority.
37
47
  if (!isAdminPath(pathname)) {
@@ -1,5 +1,5 @@
1
1
  import type { CairnRuntime } from '../content/types.js';
2
- import type { GithubKeyEnv } from '../github/credentials.js';
2
+ import type { BackendEnv } from '../github/credentials.js';
3
3
  /** The `/admin/healthz` payload. */
4
4
  export interface HealthData {
5
5
  ok: boolean;
@@ -10,9 +10,13 @@ export interface HealthData {
10
10
  };
11
11
  };
12
12
  }
13
- /** Run the signing self-test against the configured App id and the Worker's key secret. */
13
+ /**
14
+ * Run the signing self-test against the configured App id and the Worker's key secret. The self-test
15
+ * is GitHub-specific, so it narrows the provider on `kind === 'github-app'` for the App id; a
16
+ * non-GitHub backend skips the signing check.
17
+ */
14
18
  export declare function healthLoad(event: {
15
19
  platform?: {
16
- env?: GithubKeyEnv;
20
+ env?: BackendEnv;
17
21
  };
18
22
  }, runtime: CairnRuntime): Promise<HealthData>;
@@ -2,11 +2,17 @@
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
- /** Run the signing self-test against the configured App id and the Worker's key secret. */
5
+ import { isGithubApp } from '../github/backend.js';
6
+ /**
7
+ * Run the signing self-test against the configured App id and the Worker's key secret. The self-test
8
+ * is GitHub-specific, so it narrows the provider on `kind === 'github-app'` for the App id; a
9
+ * non-GitHub backend skips the signing check.
10
+ */
6
11
  export async function healthLoad(event, runtime) {
7
12
  const key = event.platform?.env?.GITHUB_APP_PRIVATE_KEY_B64;
8
- const githubAppSigning = key
9
- ? await signingSelfTest(runtime.backend.appId, key)
13
+ const provider = runtime.backend;
14
+ const githubAppSigning = isGithubApp(provider) && key
15
+ ? await signingSelfTest(provider.appId, key)
10
16
  : { ok: false, detail: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured' };
11
17
  return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
12
18
  }
@@ -10,5 +10,5 @@ export { parseAdminPath, type AdminView } from './admin-dispatch.js';
10
10
  export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-admin.js';
11
11
  export { healthLoad, type HealthData } from './health.js';
12
12
  export type { RequestContext, CookieJar, HandleInput } from './types.js';
13
- export type { GithubKeyEnv } from '../github/credentials.js';
13
+ export type { BackendEnv } from '../github/credentials.js';
14
14
  export type { AuthEnv } from '../auth/types.js';
@@ -1,6 +1,6 @@
1
- import { type GithubKeyEnv } from '../github/credentials.js';
2
1
  import { type NavNode } from '../nav/site-config.js';
3
2
  import type { CairnRuntime } from '../content/types.js';
3
+ import type { Backend } from '../github/backend.js';
4
4
  import type { ContentEvent } from './content-routes.js';
5
5
  /** One page option for the URL picker datalist. */
6
6
  export interface NavPageOption {
@@ -19,13 +19,14 @@ export interface NavLoadData {
19
19
  saved: boolean;
20
20
  error: string | null;
21
21
  }
22
- /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
22
+ /** Injectable dependencies; a test injects a live `Backend` so the read and commit paths run with no real token mint. */
23
23
  export interface NavRoutesDeps {
24
24
  /**
25
- * Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
26
- * A bare string works too; the routes await whatever comes back.
25
+ * Override the resolved content backend. A test injects a live `Backend` (a `makeGithubBackend`
26
+ * over a fetch double) so the read and commit paths run with no real token mint. When set it
27
+ * replaces the per-handler `locals.backend ?? runtime.backend.connect(env)` resolve.
27
28
  */
28
- mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
29
+ backend?: Backend;
29
30
  }
30
31
  /**
31
32
  *
@@ -1,10 +1,7 @@
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 } 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 } from '../nav/site-config.js';
@@ -13,13 +10,19 @@ import { requireSession } from './guard.js';
13
10
  *
14
11
  */
15
12
  export function createNavRoutes(runtime, deps = {}) {
16
- const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
13
+ /**
14
+ * Resolve the live content backend for one request: the test seam, then the dev double's
15
+ * `event.locals.backend`, then the production `runtime.backend.connect(env)`.
16
+ */
17
+ function resolveBackend(event) {
18
+ return deps.backend ?? event.locals.backend ?? runtime.backend.connect(event.platform?.env ?? {});
19
+ }
17
20
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
18
- async function pageOptions(token) {
21
+ async function pageOptions(backend) {
19
22
  const pageConcepts = runtime.concepts.filter((c) => c.routing.routable && !c.routing.dated);
20
23
  const lists = await Promise.all(pageConcepts.map(async (c) => {
21
24
  try {
22
- const files = await listMarkdown(runtime.backend, c.dir, token);
25
+ const files = await backend.readEntries(c.dir, backend.defaultBranch);
23
26
  return files.map((f) => ({ label: f.id, url: `/${f.id}` }));
24
27
  }
25
28
  catch {
@@ -36,17 +39,11 @@ export function createNavRoutes(runtime, deps = {}) {
36
39
  throw error(404, 'No navigation menu configured');
37
40
  const maxDepth = config.maxDepth ?? 2;
38
41
  const menu = { name: config.menuName, label: config.label, maxDepth };
39
- let token;
40
- try {
41
- token = await mintToken(event.platform?.env ?? {});
42
- }
43
- catch {
44
- return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
45
- }
42
+ const backend = resolveBackend(event);
46
43
  let tree = [];
47
44
  let raw = null;
48
45
  try {
49
- raw = await readRaw(runtime.backend, config.configPath, token);
46
+ raw = await backend.readFile(config.configPath, backend.defaultBranch);
50
47
  }
51
48
  catch {
52
49
  // An unreadable config degrades to an empty tree; the first save writes a clean menu.
@@ -69,7 +66,7 @@ export function createNavRoutes(runtime, deps = {}) {
69
66
  return {
70
67
  menu,
71
68
  tree,
72
- pages: await pageOptions(token),
69
+ pages: await pageOptions(backend),
73
70
  saved: event.url.searchParams.get('saved') === '1',
74
71
  error: event.url.searchParams.get('error'),
75
72
  };
@@ -90,13 +87,18 @@ export function createNavRoutes(runtime, deps = {}) {
90
87
  const message = err instanceof Error ? err.message : 'Invalid navigation';
91
88
  throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
92
89
  }
93
- const token = await mintToken(event.platform?.env ?? {});
94
- const raw = await readRaw(runtime.backend, config.configPath, token);
90
+ const backend = resolveBackend(event);
91
+ // Read the head BEFORE the content, so this expectedHead is at-or-before the bytes the commit
92
+ // merges. The nav write lands on the default branch and triggers a deploy, so it is fail-closed:
93
+ // a concurrent commit to the config moves the head off this value and the commit throws a
94
+ // conflict, surfacing the reload-and-reapply prompt below rather than a silent last-writer-wins.
95
+ const head = await backend.branchHead(backend.defaultBranch);
96
+ const raw = await backend.readFile(config.configPath, backend.defaultBranch);
95
97
  if (raw === null)
96
98
  throw error(404, 'Site config not found');
97
99
  const commitFields = { concept: 'nav', id: 'site-config', editor: editor.email };
98
100
  try {
99
- await commitFile(runtime.backend, config.configPath, setMenu(raw, config.menuName, tree), { message: `Update ${config.label.toLowerCase()}`, author: { name: editor.displayName, email: editor.email } }, token);
101
+ await backend.commit(backend.defaultBranch, [{ path: config.configPath, content: setMenu(raw, config.menuName, tree) }], { name: editor.displayName, email: editor.email }, `Update ${config.label.toLowerCase()}`, head ?? undefined);
100
102
  log.info('commit.succeeded', commitFields);
101
103
  }
102
104
  catch (err) {
@@ -1,4 +1,5 @@
1
1
  import type { AuthEnv, Editor } from '../auth/types.js';
2
+ import type { Backend } from '../github/backend.js';
2
3
  export interface CookieSetOptions {
3
4
  path: string;
4
5
  httpOnly?: boolean;
@@ -33,6 +34,7 @@ export interface EventBase<Env> {
33
34
  request: Request;
34
35
  locals: {
35
36
  editor?: Editor | null;
37
+ backend?: Backend;
36
38
  };
37
39
  platform?: PlatformContext<Env>;
38
40
  }
@@ -50,11 +50,11 @@ export interface AdapterFacts {
50
50
  owner?: string;
51
51
  /** `cairn.backend.repo`. */
52
52
  repo?: string;
53
- /** `cairn.sender.from`. */
53
+ /** `cairn.email.from`. */
54
54
  from?: string;
55
55
  /**
56
- * `cairn.assets.bucketBinding`, the media R2 binding name; undefined when the adapter declares no
57
- * assets. The doctor's conditional media-bucket check reads it.
56
+ * `cairn.media.bucketBinding`, the media R2 binding name; undefined when the adapter declares no
57
+ * media. The doctor's conditional media-bucket check reads it.
58
58
  */
59
59
  mediaBucketBinding?: string;
60
60
  }
@@ -22,10 +22,16 @@ function virtualSource(opts, mode) {
22
22
  .join('\n');
23
23
  // In write mode the committed file may not exist yet, so do not import it.
24
24
  const committedImport = mode === 'verify' ? `import committed from ${JSON.stringify(manifestPath + '?raw')};` : '';
25
- const resultExpr = mode === 'write' ? 'serializeManifest(built)' : '(verifyManifest(built, committed), "ok")';
25
+ // In verify mode, run verifyReferences after verifyManifest, inside the generated source where the
26
+ // built manifest is in scope. References have no prerender backstop, so this build gate is their only
27
+ // integrity authority; it cannot move to the verifyManifestFromVite TS call site, where `built` does
28
+ // not exist (it lives only in this evaluated string).
29
+ const resultExpr = mode === 'write'
30
+ ? 'serializeManifest(built)'
31
+ : '(verifyManifest(built, committed), verifyReferences(built), "ok")';
26
32
  return `
27
33
  import { buildSiteManifest } from '@glw907/cairn-cms/delivery/data';
28
- import { serializeManifest, verifyManifest } from '@glw907/cairn-cms';
34
+ import { serializeManifest, verifyManifest, verifyReferences } from '@glw907/cairn-cms';
29
35
  import { cairn, siteConfig } from ${JSON.stringify(opts.configModule)};
30
36
  ${committedImport}
31
37
  const globs = {
@@ -212,13 +218,16 @@ function adapterFactsSource(opts) {
212
218
  return `
213
219
  import { cairn } from ${JSON.stringify(opts.configModule)};
214
220
  const backend = cairn?.backend ?? {};
215
- const sender = cairn?.sender ?? {};
216
- const assets = cairn?.assets ?? {};
221
+ const email = cairn?.email ?? {};
222
+ const media = cairn?.media ?? {};
217
223
  const facts = {};
218
- if (typeof backend.owner === 'string') facts.owner = backend.owner;
219
- if (typeof backend.repo === 'string') facts.repo = backend.repo;
220
- if (typeof sender.from === 'string') facts.from = sender.from;
221
- if (typeof assets.bucketBinding === 'string') facts.mediaBucketBinding = assets.bucketBinding;
224
+ // The owner/repo identity is GitHub-specific, so it is read only off the github-app provider.
225
+ if (backend.kind === 'github-app') {
226
+ if (typeof backend.owner === 'string') facts.owner = backend.owner;
227
+ if (typeof backend.repo === 'string') facts.repo = backend.repo;
228
+ }
229
+ if (typeof email.from === 'string') facts.from = email.from;
230
+ if (typeof media.bucketBinding === 'string') facts.mediaBucketBinding = media.bucketBinding;
222
231
  export const result = JSON.stringify(facts);
223
232
  `;
224
233
  }
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.62.2",
3
+ "version": "0.76.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
+ "workspaces": [
7
+ "packages/*"
8
+ ],
6
9
  "sideEffects": [
7
10
  "**/*.svelte",
8
11
  "**/*.css"
@@ -34,15 +37,20 @@
34
37
  "check:docs": "node scripts/docs-links.mjs",
35
38
  "check:version": "node scripts/check-version.mjs",
36
39
  "check:prose": "node scripts/check-admin-prose.mjs",
40
+ "check:public-tokens": "node scripts/check-public-tokens.mjs",
41
+ "test:reskin": "node scripts/reskin-fixture.mjs",
37
42
  "lint": "eslint src/lib",
38
43
  "check:comments": "bash scripts/check-comments.sh",
44
+ "check:dev-package": "node scripts/check-dev-package.mjs",
39
45
  "prepare": "npm run package",
40
46
  "check": "svelte-check --tsconfig ./tsconfig.json",
41
47
  "test": "vitest run",
42
48
  "test:watch": "vitest",
43
49
  "test:unit": "vitest run --project unit",
44
50
  "test:integration": "vitest run --project integration",
45
- "test:component": "vitest run --project component"
51
+ "test:component": "vitest run --project component",
52
+ "emit-template": "node scripts/emit-template.mjs",
53
+ "test:emit": "node --test scripts/emit-template.test.mjs"
46
54
  },
47
55
  "exports": {
48
56
  ".": {
@@ -66,6 +74,10 @@
66
74
  },
67
75
  "./components/spellcheck-assets/spellchecker-wasm.wasm": "./dist/components/spellcheck-assets/spellchecker-wasm.wasm",
68
76
  "./components/spellcheck-assets/dictionary-en-us.txt": "./dist/components/spellcheck-assets/dictionary-en-us.txt",
77
+ "./islands": {
78
+ "types": "./dist/islands/index.d.ts",
79
+ "default": "./dist/islands/index.js"
80
+ },
69
81
  "./render": {
70
82
  "types": "./dist/render/authoring.d.ts",
71
83
  "svelte": "./dist/render/authoring.js",
@@ -131,6 +143,7 @@
131
143
  "codemirror": "^6.0.2",
132
144
  "gray-matter": "^4",
133
145
  "hast-util-sanitize": "^5.0.2",
146
+ "hast-util-to-string": "^3.0.1",
134
147
  "hastscript": "^9.0.1",
135
148
  "heic-to": "^1.5.2",
136
149
  "mdast-util-directive": "^3.1.0",
@@ -143,6 +156,7 @@
143
156
  "remark-parse": "^11.0.0",
144
157
  "remark-rehype": "^11.1.2",
145
158
  "remark-stringify": "^11.0.0",
159
+ "shiki": "^4.3.0",
146
160
  "spellchecker-wasm": "^0.3.3",
147
161
  "unified": "^11.0.5",
148
162
  "unist-util-visit": "^5.1.0",
@@ -159,6 +173,7 @@
159
173
  "@types/node": "^22.19.19",
160
174
  "@vitest/browser": "^4.1.7",
161
175
  "@vitest/browser-playwright": "^4.1.7",
176
+ "culori": "^4.0.2",
162
177
  "daisyui": "^5.5.23",
163
178
  "eslint": "^9.39.4",
164
179
  "eslint-plugin-jsdoc": "^63.0.7",
@@ -6,12 +6,19 @@
6
6
  // hand-writes the `declare global` block. The field is optional: the engine's own structural
7
7
  // event types read it as `editor?: Editor | null`, and a request the guard has not touched
8
8
  // carries no editor at all.
9
+ //
10
+ // `backend` is the per-request content-store channel: the dev-backend handle sets it so the engine
11
+ // resolves it ahead of the real `githubApp` provider (`locals.backend ?? runtime.backend.connect`).
12
+ // Typing it here makes that seam a checked contract, so a mis-keyed write cannot silently fall
13
+ // through to the production provider. A production request never sets it.
9
14
  import type { Editor } from './auth/types.js';
15
+ import type { Backend } from './github/backend.js';
10
16
 
11
17
  declare global {
12
18
  namespace App {
13
19
  interface Locals {
14
20
  editor?: Editor | null;
21
+ backend?: Backend;
15
22
  }
16
23
  }
17
24
  }
@@ -14,6 +14,13 @@ export interface AuthEnv {
14
14
  AUTH_DB?: D1Database;
15
15
  /** Canonical origin for confirmation links, never read from a request header (spec 7.1, risk H3). */
16
16
  PUBLIC_ORIGIN?: string;
17
+ /**
18
+ * Dev-backend tripwire flag. The dev backend sets this in local development; if it is ever set in
19
+ * a deployed runtime the guard refuses (the build-foldable `dev` gate should have eliminated the
20
+ * dev backend, so a set flag signals a polluted environment). A string from a Worker var or a
21
+ * boolean.
22
+ */
23
+ CAIRN_DEV_BACKEND?: string | boolean;
17
24
  /** Cloudflare Email Sending binding. */
18
25
  EMAIL?: {
19
26
  send(message: {
@@ -20,8 +20,7 @@ mount inside `AdminLayout`. No styling or wrapper elements of its own.
20
20
  import type { ContentFormFailure } from '../sveltekit/content-routes.js';
21
21
  import type { ComponentRegistry } from '../render/registry.js';
22
22
  import type { IconSet } from '../render/glyph.js';
23
- import type { LinkResolve } from '../content/links.js';
24
- import type { MediaResolve } from '../render/resolve-media.js';
23
+ import type { SiteRender } from '../content/types.js';
25
24
 
26
25
  interface Props {
27
26
  /** The discriminated view data from `createCairnAdmin`'s load. */
@@ -37,10 +36,7 @@ mount inside `AdminLayout`. No styling or wrapper elements of its own.
37
36
  })
38
37
  | null;
39
38
  /** The site's design-accurate render pipeline, for the edit view's preview pane. */
40
- render?: (
41
- md: string,
42
- opts?: { stagger?: boolean; resolve?: LinkResolve; resolveMedia?: MediaResolve },
43
- ) => string | Promise<string>;
39
+ render?: SiteRender;
44
40
  /** The site's component registry, for the edit view's insert palette. */
45
41
  registry?: ComponentRegistry;
46
42
  /** The site's icon set, for the edit view's guided form fields. */
@@ -55,7 +55,7 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
55
55
  values = working;
56
56
  });
57
57
 
58
- const attributes = $derived(def.attributes ?? []);
58
+ const attributes = $derived(Object.entries(def.attributes ?? {}));
59
59
  const flatSlots = $derived((def.slots ?? []).filter((s) => s.kind !== 'repeatable'));
60
60
  const repeatableSlots = $derived((def.slots ?? []).filter((s) => s.kind === 'repeatable'));
61
61
 
@@ -107,7 +107,7 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
107
107
  function rowLabel(slot: (typeof repeatableSlots)[number], value: string, index: number): string {
108
108
  const fallback = `${slot.label} ${index + 1}`;
109
109
  if (!slot.itemLabel) return fallback;
110
- const key = slot.itemFields?.[0]?.key ?? 'text';
110
+ const key = Object.keys(slot.itemFields ?? {})[0] ?? 'text';
111
111
  const derived = slot.itemLabel({ [key]: value }, index);
112
112
  return derived && derived.trim() ? derived : fallback;
113
113
  }
@@ -125,14 +125,34 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
125
125
  return typeof v === 'string' ? v : '';
126
126
  }
127
127
 
128
+ // The HTML input type for the text-fallback arm. ComponentForm has no dedicated number/date arm
129
+ // the way FieldInput does, so it folds those scalar types into the one fallback input; everything
130
+ // else renders a plain text box.
131
+ function inputType(type: string): string {
132
+ switch (type) {
133
+ case 'number':
134
+ return 'number';
135
+ case 'date':
136
+ return 'date';
137
+ case 'datetime':
138
+ return 'datetime-local';
139
+ case 'url':
140
+ return 'url';
141
+ case 'email':
142
+ return 'email';
143
+ default:
144
+ return 'text';
145
+ }
146
+ }
147
+
128
148
  // A required attribute is unmet only for a text/select/icon field left empty; a boolean is always
129
149
  // met (its false is a real choice). A required slot is unmet when its string is empty or its
130
150
  // repeatable list has no non-empty item. This drives the asterisk-marked fields, the disabled
131
151
  // Insert, and (through the bound `incomplete`) the dialog's incomplete preview state.
132
152
  const incompleteState = $derived.by(() => {
133
- for (const field of attributes) {
153
+ for (const [name, field] of attributes) {
134
154
  if (!field.required || field.type === 'boolean') continue;
135
- if (asString(field.key) === '') return true;
155
+ if (asString(name) === '') return true;
136
156
  }
137
157
  for (const slot of def.slots ?? []) {
138
158
  if (!slot.required) continue;
@@ -164,9 +184,9 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
164
184
  // next to the field meanwhile.
165
185
  const errors = $derived.by(() => {
166
186
  const out: Record<string, string> = {};
167
- for (const field of attributes) {
168
- if (field.required && field.type !== 'boolean' && touched[field.key] && asString(field.key) === '') {
169
- out[field.key] = `${field.label} is required.`;
187
+ for (const [name, field] of attributes) {
188
+ if (field.required && field.type !== 'boolean' && touched[name] && asString(name) === '') {
189
+ out[name] = `${field.label} is required.`;
170
190
  }
171
191
  }
172
192
  for (const slot of def.slots ?? []) {
@@ -201,16 +221,16 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
201
221
  </script>
202
222
 
203
223
  <div class="flex flex-col gap-3" bind:this={formEl}>
204
- {#each attributes as field (field.key)}
224
+ {#each attributes as [name, field] (name)}
205
225
  {#if field.type === 'boolean'}
206
226
  <label class="label cursor-pointer justify-start gap-2">
207
227
  <input
208
228
  class="checkbox checkbox-sm"
209
229
  type="checkbox"
210
- aria-invalid={Boolean(errors[field.key])}
211
- aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
212
- checked={asBool(field.key)}
213
- onchange={(e) => (working.attributes[field.key] = e.currentTarget.checked)}
230
+ aria-invalid={Boolean(errors[name])}
231
+ aria-describedby={errors[name] ? `err-${name}` : undefined}
232
+ checked={asBool(name)}
233
+ onchange={(e) => (working.attributes[name] = e.currentTarget.checked)}
214
234
  />
215
235
  <span class="text-sm">{field.label}</span>
216
236
  </label>
@@ -220,14 +240,14 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
220
240
  <select
221
241
  class="select"
222
242
  aria-required={field.required ? 'true' : undefined}
223
- aria-invalid={Boolean(errors[field.key])}
224
- aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
225
- value={asString(field.key)}
243
+ aria-invalid={Boolean(errors[name])}
244
+ aria-describedby={errors[name] ? `err-${name}` : undefined}
245
+ value={asString(name)}
226
246
  onchange={(e) => {
227
- working.attributes[field.key] = e.currentTarget.value;
228
- markTouched(field.key);
247
+ working.attributes[name] = e.currentTarget.value;
248
+ markTouched(name);
229
249
  }}
230
- onblur={() => markTouched(field.key)}
250
+ onblur={() => markTouched(name)}
231
251
  >
232
252
  {#if !field.required}<option value="">—</option>{/if}
233
253
  {#each field.options ?? [] as opt (opt)}<option value={opt}>{opt}</option>{/each}
@@ -239,9 +259,9 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
239
259
  <IconPicker
240
260
  {icons}
241
261
  label={field.label}
242
- value={asString(field.key)}
262
+ value={asString(name)}
243
263
  required={field.required ?? false}
244
- onChange={(name) => (working.attributes[field.key] = name)}
264
+ onChange={(glyph) => (working.attributes[name] = glyph)}
245
265
  />
246
266
  </div>
247
267
  {:else}
@@ -249,19 +269,20 @@ binds out its live `values` and `incomplete` so the dialog can render that previ
249
269
  <span class="text-sm font-medium">{field.label}{#if field.required}<span data-testid="cairn-pk-req" class="text-error" aria-hidden="true">*</span>{/if}</span>
250
270
  <input
251
271
  class="input"
272
+ type={inputType(field.type)}
252
273
  aria-required={field.required ? 'true' : undefined}
253
- aria-invalid={Boolean(errors[field.key])}
254
- aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
255
- value={asString(field.key)}
274
+ aria-invalid={Boolean(errors[name])}
275
+ aria-describedby={errors[name] ? `err-${name}` : undefined}
276
+ value={asString(name)}
256
277
  oninput={(e) => {
257
- working.attributes[field.key] = e.currentTarget.value;
258
- markTouched(field.key);
278
+ working.attributes[name] = e.currentTarget.value;
279
+ markTouched(name);
259
280
  }}
260
- onblur={() => markTouched(field.key)}
281
+ onblur={() => markTouched(name)}
261
282
  />
262
283
  </label>
263
284
  {/if}
264
- {#if errors[field.key]}<span id={`err-${field.key}`} role="alert" class="text-error text-xs">{errors[field.key]}</span>{/if}
285
+ {#if errors[name]}<span id={`err-${name}`} role="alert" class="text-error text-xs">{errors[name]}</span>{/if}
265
286
  {/each}
266
287
 
267
288
  {#each flatSlots as slot (slot.name)}