@glw907/cairn-cms 0.68.0 → 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 (177) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/ambient.d.ts +2 -0
  3. package/dist/components/CairnAdmin.svelte.d.ts +2 -7
  4. package/dist/components/ComponentForm.svelte +44 -27
  5. package/dist/components/ComponentInsertDialog.svelte +5 -5
  6. package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
  7. package/dist/components/EditPage.svelte +29 -107
  8. package/dist/components/EditPage.svelte.d.ts +2 -7
  9. package/dist/components/EntryPicker.svelte +117 -0
  10. package/dist/components/EntryPicker.svelte.d.ts +35 -0
  11. package/dist/components/FieldInput.svelte +218 -0
  12. package/dist/components/FieldInput.svelte.d.ts +51 -0
  13. package/dist/components/IconPicker.svelte +2 -2
  14. package/dist/components/IconPicker.svelte.d.ts +2 -0
  15. package/dist/components/LinkPicker.svelte +8 -75
  16. package/dist/components/LinkPicker.svelte.d.ts +4 -5
  17. package/dist/components/MediaHeroField.svelte +8 -5
  18. package/dist/components/MediaHeroField.svelte.d.ts +4 -0
  19. package/dist/components/ObjectGroupField.svelte +54 -0
  20. package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
  21. package/dist/components/ReferenceField.svelte +94 -0
  22. package/dist/components/ReferenceField.svelte.d.ts +27 -0
  23. package/dist/components/RepeatableField.svelte +221 -0
  24. package/dist/components/RepeatableField.svelte.d.ts +53 -0
  25. package/dist/components/cairn-admin.css +4 -0
  26. package/dist/components/preview-doc.js +5 -1
  27. package/dist/components/tidy-validate.js +1 -1
  28. package/dist/content/adapter.js +18 -0
  29. package/dist/content/advisories.d.ts +2 -2
  30. package/dist/content/advisories.js +3 -5
  31. package/dist/content/compose.d.ts +7 -6
  32. package/dist/content/compose.js +26 -20
  33. package/dist/content/concepts.d.ts +21 -15
  34. package/dist/content/concepts.js +55 -32
  35. package/dist/content/field-rules.js +3 -4
  36. package/dist/content/fields.d.ts +49 -1
  37. package/dist/content/fields.js +11 -0
  38. package/dist/content/fieldset.d.ts +31 -10
  39. package/dist/content/fieldset.js +262 -109
  40. package/dist/content/frontmatter-region.d.ts +38 -0
  41. package/dist/content/frontmatter-region.js +75 -0
  42. package/dist/content/frontmatter.d.ts +35 -2
  43. package/dist/content/frontmatter.js +232 -11
  44. package/dist/content/manifest.d.ts +34 -0
  45. package/dist/content/manifest.js +80 -4
  46. package/dist/content/media-refs.d.ts +2 -2
  47. package/dist/content/media-rewrite.js +1 -69
  48. package/dist/content/reference-index.d.ts +56 -0
  49. package/dist/content/reference-index.js +95 -0
  50. package/dist/content/references.d.ts +40 -0
  51. package/dist/content/references.js +0 -0
  52. package/dist/content/standard-schema.d.ts +30 -0
  53. package/dist/content/standard-schema.js +4 -0
  54. package/dist/content/types.d.ts +127 -178
  55. package/dist/delivery/data.d.ts +2 -2
  56. package/dist/delivery/data.js +1 -1
  57. package/dist/delivery/public-routes.d.ts +2 -5
  58. package/dist/delivery/public-routes.js +15 -1
  59. package/dist/delivery/site-descriptors.d.ts +5 -1
  60. package/dist/delivery/site-descriptors.js +8 -3
  61. package/dist/delivery/site-indexes.d.ts +2 -2
  62. package/dist/delivery/site-resolver.d.ts +25 -0
  63. package/dist/delivery/site-resolver.js +49 -0
  64. package/dist/doctor/checks-local.js +6 -11
  65. package/dist/github/backend.d.ts +83 -0
  66. package/dist/github/backend.js +76 -0
  67. package/dist/github/credentials.d.ts +11 -5
  68. package/dist/github/credentials.js +3 -3
  69. package/dist/github/repo.d.ts +8 -19
  70. package/dist/github/repo.js +69 -80
  71. package/dist/github/types.d.ts +1 -1
  72. package/dist/github/types.js +4 -4
  73. package/dist/index.d.ts +16 -12
  74. package/dist/index.js +7 -8
  75. package/dist/islands/index.d.ts +12 -0
  76. package/dist/islands/index.js +83 -0
  77. package/dist/islands/types.d.ts +7 -0
  78. package/dist/islands/types.js +1 -0
  79. package/dist/media/rewrite-plan.d.ts +2 -3
  80. package/dist/media/rewrite-plan.js +2 -3
  81. package/dist/media/usage.d.ts +2 -2
  82. package/dist/media/usage.js +3 -5
  83. package/dist/nav/site-config.d.ts +0 -6
  84. package/dist/nav/site-config.js +6 -4
  85. package/dist/render/component-grammar.js +11 -11
  86. package/dist/render/component-reference.js +5 -3
  87. package/dist/render/component-validate.d.ts +4 -1
  88. package/dist/render/component-validate.js +10 -35
  89. package/dist/render/pipeline.d.ts +0 -6
  90. package/dist/render/pipeline.js +1 -1
  91. package/dist/render/registry.d.ts +34 -34
  92. package/dist/render/registry.js +26 -5
  93. package/dist/render/rehype-dispatch.d.ts +4 -4
  94. package/dist/render/rehype-dispatch.js +36 -11
  95. package/dist/render/remark-directives.js +4 -5
  96. package/dist/render/sanitize-schema.js +1 -1
  97. package/dist/sveltekit/cairn-admin.d.ts +5 -5
  98. package/dist/sveltekit/cairn-admin.js +3 -4
  99. package/dist/sveltekit/content-routes.d.ts +10 -8
  100. package/dist/sveltekit/content-routes.js +269 -181
  101. package/dist/sveltekit/health.d.ts +7 -3
  102. package/dist/sveltekit/health.js +9 -3
  103. package/dist/sveltekit/index.d.ts +1 -1
  104. package/dist/sveltekit/nav-routes.d.ts +6 -5
  105. package/dist/sveltekit/nav-routes.js +22 -20
  106. package/dist/sveltekit/types.d.ts +2 -0
  107. package/dist/vite/index.d.ts +3 -3
  108. package/dist/vite/index.js +17 -8
  109. package/package.json +5 -1
  110. package/src/lib/ambient.ts +7 -0
  111. package/src/lib/components/CairnAdmin.svelte +2 -6
  112. package/src/lib/components/ComponentForm.svelte +48 -27
  113. package/src/lib/components/ComponentInsertDialog.svelte +9 -8
  114. package/src/lib/components/EditPage.svelte +43 -119
  115. package/src/lib/components/EntryPicker.svelte +154 -0
  116. package/src/lib/components/FieldInput.svelte +262 -0
  117. package/src/lib/components/IconPicker.svelte +4 -2
  118. package/src/lib/components/LinkPicker.svelte +10 -81
  119. package/src/lib/components/MediaHeroField.svelte +12 -5
  120. package/src/lib/components/ObjectGroupField.svelte +97 -0
  121. package/src/lib/components/ReferenceField.svelte +126 -0
  122. package/src/lib/components/RepeatableField.svelte +310 -0
  123. package/src/lib/components/preview-doc.ts +5 -1
  124. package/src/lib/components/tidy-validate.ts +1 -1
  125. package/src/lib/content/adapter.ts +21 -0
  126. package/src/lib/content/advisories.ts +4 -7
  127. package/src/lib/content/compose.ts +30 -23
  128. package/src/lib/content/concepts.ts +68 -40
  129. package/src/lib/content/field-rules.ts +3 -4
  130. package/src/lib/content/fields.ts +52 -1
  131. package/src/lib/content/fieldset.ts +291 -128
  132. package/src/lib/content/frontmatter-region.ts +90 -0
  133. package/src/lib/content/frontmatter.ts +231 -15
  134. package/src/lib/content/manifest.ts +101 -4
  135. package/src/lib/content/media-refs.ts +2 -2
  136. package/src/lib/content/media-rewrite.ts +7 -80
  137. package/src/lib/content/reference-index.ts +159 -0
  138. package/src/lib/content/references.ts +0 -0
  139. package/src/lib/content/standard-schema.ts +25 -0
  140. package/src/lib/content/types.ts +128 -195
  141. package/src/lib/delivery/data.ts +2 -2
  142. package/src/lib/delivery/public-routes.ts +17 -3
  143. package/src/lib/delivery/site-descriptors.ts +8 -3
  144. package/src/lib/delivery/site-indexes.ts +2 -2
  145. package/src/lib/delivery/site-resolver.ts +64 -0
  146. package/src/lib/doctor/checks-local.ts +6 -14
  147. package/src/lib/github/backend.ts +161 -0
  148. package/src/lib/github/credentials.ts +10 -7
  149. package/src/lib/github/repo.ts +79 -83
  150. package/src/lib/github/types.ts +5 -5
  151. package/src/lib/index.ts +38 -23
  152. package/src/lib/islands/index.ts +84 -0
  153. package/src/lib/islands/types.ts +11 -0
  154. package/src/lib/media/rewrite-plan.ts +4 -6
  155. package/src/lib/media/usage.ts +4 -7
  156. package/src/lib/nav/site-config.ts +8 -9
  157. package/src/lib/render/component-grammar.ts +10 -10
  158. package/src/lib/render/component-reference.ts +4 -3
  159. package/src/lib/render/component-validate.ts +10 -35
  160. package/src/lib/render/pipeline.ts +1 -7
  161. package/src/lib/render/registry.ts +58 -39
  162. package/src/lib/render/rehype-dispatch.ts +45 -10
  163. package/src/lib/render/remark-directives.ts +4 -5
  164. package/src/lib/render/sanitize-schema.ts +1 -1
  165. package/src/lib/sveltekit/cairn-admin.ts +8 -9
  166. package/src/lib/sveltekit/content-routes.ts +330 -221
  167. package/src/lib/sveltekit/health.ts +13 -6
  168. package/src/lib/sveltekit/index.ts +2 -2
  169. package/src/lib/sveltekit/nav-routes.ts +33 -29
  170. package/src/lib/sveltekit/types.ts +5 -1
  171. package/src/lib/vite/index.ts +20 -11
  172. package/dist/content/schema.d.ts +0 -87
  173. package/dist/content/schema.js +0 -85
  174. package/dist/content/validate.d.ts +0 -17
  175. package/dist/content/validate.js +0 -93
  176. package/src/lib/content/schema.ts +0 -163
  177. package/src/lib/content/validate.ts +0 -90
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.68.0",
3
+ "version": "0.76.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -74,6 +74,10 @@
74
74
  },
75
75
  "./components/spellcheck-assets/spellchecker-wasm.wasm": "./dist/components/spellcheck-assets/spellchecker-wasm.wasm",
76
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
+ },
77
81
  "./render": {
78
82
  "types": "./dist/render/authoring.d.ts",
79
83
  "svelte": "./dist/render/authoring.js",
@@ -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
  }
@@ -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)}
@@ -16,7 +16,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
16
16
  * than inserting a bare template. Exported so a host deciding on its own guided-edit affordance
17
17
  * (the edit page's Edit-block control) reads the same notion the dialog lists and chooses by. */
18
18
  export function hasSchema(def: ComponentDef): boolean {
19
- return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
19
+ return Object.keys(def.attributes ?? {}).length > 0 || (def.slots?.length ?? 0) > 0;
20
20
  }
21
21
  /** The registry's pickable components. A def is actionable when a schema opens the guided form or
22
22
  * a template inserts directly; a def with neither is not listed. A `hidden` def is then dropped,
@@ -57,8 +57,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
57
57
  import { tick } from 'svelte';
58
58
  import type { IconSet } from '../render/glyph.js';
59
59
  import type { ComponentValues } from '../render/registry.js';
60
- import type { ResolvedPreview } from '../content/types.js';
61
- import type { LinkResolve } from '../content/links.js';
60
+ import type { ResolvedPreview, SiteRender } from '../content/types.js';
62
61
  import { serializeComponent } from '../render/component-grammar.js';
63
62
  import { buildPreviewDoc } from './preview-doc.js';
64
63
  import ComponentForm from './ComponentForm.svelte';
@@ -77,7 +76,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
77
76
  * `preview`, the configure step splits to two panes and renders the configured directive
78
77
  * through this into a sandboxed iframe (the same path EditPage's preview uses). Optional: a
79
78
  * host that passes none simply gets no preview pane. */
80
- render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
79
+ render?: SiteRender;
81
80
  /** The adapter's resolved preview knob (stylesheets and container class), threaded to
82
81
  * buildPreviewDoc so the preview frame links the site's own CSS, the same as EditPage. */
83
82
  preview?: ResolvedPreview | null;
@@ -122,10 +121,12 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
122
121
  const emptyRequired = $derived.by(() => {
123
122
  if (!picked || !formValues) return [] as string[];
124
123
  const out: string[] = [];
125
- for (const field of picked.attributes ?? []) {
124
+ for (const [name, field] of Object.entries(picked.attributes ?? {})) {
126
125
  if (!field.required || field.type === 'boolean') continue;
127
- const v = formValues.attributes[field.key];
128
- if (typeof v !== 'string' || v === '') out.push(field.label);
126
+ const v = formValues.attributes[name];
127
+ // A scalar attribute always carries a label; the `?? name` only satisfies the union type, whose
128
+ // object/array members have an optional label that checkComponentAttributes already rejects.
129
+ if (typeof v !== 'string' || v === '') out.push(field.label ?? name);
129
130
  }
130
131
  for (const slot of picked.slots ?? []) {
131
132
  if (!slot.required) continue;
@@ -154,7 +155,7 @@ trapping and Escape, following the dropdown's a11y conventions used elsewhere in
154
155
  previewState = 'settling';
155
156
  const handle = setTimeout(async () => {
156
157
  try {
157
- const html = await render(md);
158
+ const html = await render({ body: md });
158
159
  if (run === previewRun) {
159
160
  previewDoc = buildPreviewDoc(html, preview);
160
161
  previewState = 'settled';