@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
@@ -0,0 +1,161 @@
1
+ // cairn-cms: the Backend seam. A Backend is read, commit, and branch operations over files,
2
+ // never a query(): that line is the constraint that keeps a store swappable and a database out.
3
+ // The adapter holds a BackendProvider from githubApp(...); the engine resolves one live Backend
4
+ // per request via connect(env), with the GitHub App installation token minted and cached behind
5
+ // the seam. makeGithubBackend takes an injectable token getter so a test wires a literal token and
6
+ // the in-memory fetch double intercepts the same GitHub URLs the production getter would reach.
7
+ import { readRaw, listMarkdown, commitFiles } from './repo.js';
8
+ import type { FileChange } from './repo.js';
9
+ import { branchHeadSha, createBranch as createBranchRef, deleteBranch, listBranches } from './branches.js';
10
+ import { appCredentials } from './credentials.js';
11
+ import type { BackendEnv } from './credentials.js';
12
+ import { cachedInstallationToken } from './signing.js';
13
+ import { CommitConflictError } from './types.js';
14
+ import type { CommitAuthor, RepoFile } from './types.js';
15
+
16
+ // One BackendEnv declaration lives in credentials.js (the secret-channel owner). Re-export it here so
17
+ // the seam and connect() name the same type the public root surfaces, with no duplicate declaration.
18
+ export type { BackendEnv };
19
+
20
+ /**
21
+ * A live, connected content store pinned to a default branch. The GitHub implementation already
22
+ * holds a minted token behind it; the engine resolves one per request. Read, commit, and branch
23
+ * over files only: this interface never grows a query() method.
24
+ */
25
+ export interface Backend {
26
+ /** The site's default branch, for example "main". Callers reading published state pass it as the ref. */
27
+ readonly defaultBranch: string;
28
+
29
+ /** Raw file contents at a ref, or null when the path does not exist. */
30
+ readFile(path: string, ref: string): Promise<string | null>;
31
+
32
+ /** The markdown entries directly in a concept directory at a ref, newest id first. */
33
+ readEntries(dir: string, ref: string): Promise<RepoFile[]>;
34
+
35
+ /** A branch's head commit sha, or null when the branch does not exist. */
36
+ branchHead(branch: string): Promise<string | null>;
37
+
38
+ /** Branch names under a prefix, sorted. */
39
+ listBranches(prefix: string): Promise<string[]>;
40
+
41
+ /**
42
+ * Commit a set of path changes atomically on a branch; returns the new commit sha. When
43
+ * `expectedHead` is given the commit is fail-closed: it makes one attempt and throws
44
+ * CommitConflictError if the branch head is not `expectedHead`. Omitting it keeps the head-merge
45
+ * retry the entry and publish paths rely on.
46
+ */
47
+ commit(
48
+ branch: string,
49
+ changes: FileChange[],
50
+ author: CommitAuthor,
51
+ message: string,
52
+ expectedHead?: string,
53
+ ): Promise<string>;
54
+
55
+ /** Create a branch at another branch's current head. */
56
+ createBranch(name: string, fromBranch: string): Promise<void>;
57
+
58
+ /** Delete a branch; a missing branch is success. */
59
+ deleteBranch(name: string): Promise<void>;
60
+ }
61
+
62
+ /** The adapter's backend value: a provider that connect()s to a live Backend given the Worker env. */
63
+ export interface BackendProvider {
64
+ /** A stable tag for the implementation, for example "github-app". The non-request readers narrow on it. */
65
+ readonly kind: string;
66
+ /** The default branch, surfaced before connect() so compose-time code can read it. */
67
+ readonly branch: string;
68
+ /** Connect to a live Backend; the GitHub implementation mints and caches its token lazily. */
69
+ connect(env: BackendEnv): Backend;
70
+ }
71
+
72
+ /** What githubApp() returns: the generic provider plus the GitHub App's non-secret identity facts. */
73
+ export interface GithubAppProvider extends BackendProvider {
74
+ readonly kind: 'github-app';
75
+ readonly owner: string;
76
+ readonly repo: string;
77
+ readonly appId: string;
78
+ readonly installationId: string;
79
+ }
80
+
81
+ /**
82
+ * Narrow a provider to the GitHub App provider on its `kind` tag. The non-request readers (the
83
+ * health self-test, the build-time facts) call this before reading the GitHub-specific identity,
84
+ * since `BackendProvider.kind` is a bare string the compiler cannot narrow on its own.
85
+ */
86
+ export function isGithubApp(provider: BackendProvider): provider is GithubAppProvider {
87
+ return provider.kind === 'github-app';
88
+ }
89
+
90
+ /** The non-secret GitHub App identity an adapter carries in source; the private key stays a Worker secret. */
91
+ interface GithubAppConfig {
92
+ owner: string;
93
+ repo: string;
94
+ branch: string;
95
+ appId: string;
96
+ installationId: string;
97
+ }
98
+
99
+ /**
100
+ * The live GitHub Backend over the existing repo.ts and branches.ts transports. The token getter
101
+ * is injected rather than the env: connect() wires the production getter that mints and caches the
102
+ * installation token, and a unit test wires a literal so the in-memory fetch double intercepts the
103
+ * same GitHub URLs. Not barrel-exported; it is the internal test seam imported by path.
104
+ */
105
+ export function makeGithubBackend(config: GithubAppConfig, getToken: () => string | Promise<string>): Backend {
106
+ return {
107
+ defaultBranch: config.branch,
108
+ async readFile(path, ref) {
109
+ return readRaw({ ...config, branch: ref }, path, await getToken());
110
+ },
111
+ async readEntries(dir, ref) {
112
+ return listMarkdown({ ...config, branch: ref }, dir, await getToken());
113
+ },
114
+ async branchHead(branch) {
115
+ return branchHeadSha(config, branch, await getToken());
116
+ },
117
+ async listBranches(prefix) {
118
+ return listBranches(config, prefix, await getToken());
119
+ },
120
+ async commit(branch, changes, author, message, expectedHead) {
121
+ return commitFiles({ ...config, branch }, changes, { message, author }, await getToken(), expectedHead);
122
+ },
123
+ async createBranch(name, fromBranch) {
124
+ const tok = await getToken();
125
+ const head = await branchHeadSha(config, fromBranch, tok);
126
+ // A null head means the source branch is gone or unreadable. Throw a defined, catchable
127
+ // error the save path maps to its 500 rather than letting the createBranchRef POST fail raw.
128
+ if (head === null) throw new CommitConflictError(`${fromBranch} (unreadable source)`);
129
+ await createBranchRef(config, name, head, tok);
130
+ },
131
+ async deleteBranch(name) {
132
+ await deleteBranch(config, name, await getToken());
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * The default content backend: a GitHub App over a repo branch. Returns a provider carrying the
139
+ * App's non-secret identity (so the build-time, health, and doctor readers narrow on kind and read
140
+ * it) whose connect(env) mints and caches the installation token from the Worker's private-key
141
+ * secret. The missing-secret CairnError stays on first token use, inside connect.
142
+ */
143
+ export function githubApp(config: {
144
+ owner: string;
145
+ repo: string;
146
+ branch: string;
147
+ appId: string;
148
+ installationId: string;
149
+ }): GithubAppProvider {
150
+ return {
151
+ kind: 'github-app',
152
+ branch: config.branch,
153
+ owner: config.owner,
154
+ repo: config.repo,
155
+ appId: config.appId,
156
+ installationId: config.installationId,
157
+ connect(env) {
158
+ return makeGithubBackend(config, () => cachedInstallationToken(appCredentials(config, env)));
159
+ },
160
+ };
161
+ }
@@ -3,22 +3,25 @@
3
3
  // save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
4
4
  // TypeError. Mirrors requireDb/requireOrigin in env.ts.
5
5
  import { CairnError } from '../diagnostics/index.js';
6
- import type { BackendConfig } from '../content/types.js';
7
6
  import type { AppCredentials } from './types.js';
8
7
 
9
- /** The Worker secret holding the GitHub App private key: base64 of the PEM, single line. */
10
- export interface GithubKeyEnv {
8
+ /**
9
+ * The Worker secret carrier `Backend.connect` reads: the GitHub App private key as base64 of the
10
+ * PEM, single line. A consumer's `App.Platform.env` block names it. Aliased as the engine's
11
+ * `BackendEnv` since the backend seam owns the secret channel.
12
+ */
13
+ export interface BackendEnv {
11
14
  GITHUB_APP_PRIVATE_KEY_B64?: string;
12
15
  }
13
16
 
14
17
  /**
15
- * Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
18
+ * Assemble the `AppCredentials` the signer needs from the GitHub App's identity (app id,
16
19
  * installation) and the Worker's private-key secret. Throws a CairnError naming
17
20
  * `github.app-unreachable` when the secret is unset, since the App cannot authenticate without it.
18
21
  */
19
22
  export function appCredentials(
20
- backend: Pick<BackendConfig, 'appId' | 'installationId'>,
21
- env: GithubKeyEnv,
23
+ identity: { appId: string; installationId: string },
24
+ env: BackendEnv,
22
25
  ): AppCredentials {
23
26
  const privateKeyB64 = env.GITHUB_APP_PRIVATE_KEY_B64;
24
27
  if (!privateKeyB64) {
@@ -26,5 +29,5 @@ export function appCredentials(
26
29
  message: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured',
27
30
  });
28
31
  }
29
- return { appId: backend.appId, installationId: backend.installationId, privateKeyB64 };
32
+ return { appId: identity.appId, installationId: identity.installationId, privateKeyB64 };
30
33
  }
@@ -86,56 +86,6 @@ export async function readRaw(repo: RepoRef, path: string, token?: string): Prom
86
86
  return res.text();
87
87
  }
88
88
 
89
- /** Standard (padded) base64 of UTF-8 text, the form the contents API expects. */
90
- function toBase64(text: string): string {
91
- return btoa(Array.from(new TextEncoder().encode(text), (b) => String.fromCharCode(b)).join(''));
92
- }
93
-
94
- /** The current blob sha for a path, or null if the file does not yet exist. */
95
- export async function fileSha(repo: RepoRef, path: string, token: string): Promise<string | null> {
96
- const res = await fetch(contentsUrl(repo, path), { headers: ghHeaders('application/vnd.github+json', token) });
97
- if (res.status === 404) return null;
98
- if (!res.ok) throw new Error(`GitHub stat ${path} failed: ${res.status}`);
99
- return ((await res.json()) as { sha: string }).sha;
100
- }
101
-
102
- /**
103
- * Commit `content` to `path` on the configured branch through the contents API. The author is
104
- * the editor; the committer is omitted, so GitHub attributes it to the App (`cairn-cms[bot]`).
105
- * Updates the file in place when it exists (passing its sha), creates it otherwise. Returns the
106
- * commit sha. A stale-sha 409 (someone committed in between) becomes a `CommitConflictError`,
107
- * so the save fails safe: re-fetch and ask the editor to reapply, never a merge.
108
- *
109
- * Caller preconditions this layer cannot enforce, and the save action (Plan 05) must:
110
- * `path` is confined to the concept's configured directory (the App token can write anywhere
111
- * in the repo, so an unvalidated path could overwrite CI config or source), and `author` is
112
- * derived from the verified server-side session, never from request input.
113
- */
114
- export async function commitFile(
115
- repo: RepoRef,
116
- path: string,
117
- content: string,
118
- opts: { message: string; author: CommitAuthor },
119
- token: string,
120
- ): Promise<string> {
121
- const sha = await fileSha(repo, path, token);
122
- const url = `${API}/repos/${repo.owner}/${repo.repo}/contents/${path.replace(/^\/+|\/+$/g, '')}`;
123
- const res = await fetch(url, {
124
- method: 'PUT',
125
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
126
- body: JSON.stringify({
127
- message: opts.message,
128
- content: toBase64(content),
129
- branch: repo.branch,
130
- author: opts.author,
131
- ...(sha ? { sha } : {}),
132
- }),
133
- });
134
- if (res.status === 409) throw new CommitConflictError(path);
135
- if (!res.ok) throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
136
- return ((await res.json()) as { commit: { sha: string } }).commit.sha;
137
- }
138
-
139
89
  /** A path change for an atomic commit: write `content`, or delete the path when `content` is null. */
140
90
  export interface FileChange {
141
91
  path: string;
@@ -186,6 +136,69 @@ function treeChanges(changes: FileChange[]): TreeChange[] {
186
136
  /** Retries after the initial attempt when the branch moves under an atomic commit. */
187
137
  const COMMIT_RETRIES = 3;
188
138
 
139
+ /**
140
+ * Build a tree on `parent`'s tree, create the commit, and PATCH the branch ref to it. Returns the
141
+ * new commit sha, or null when the ref PATCH is a non-fast-forward (the head moved). A tree-create
142
+ * 422 (an unprocessable delete) becomes a `CommitConflictError`, and any other non-fast-forward
143
+ * detail is left to the caller to map.
144
+ */
145
+ async function commitOnTree(
146
+ repo: RepoRef,
147
+ parent: string,
148
+ tree: TreeChange[],
149
+ opts: { message: string; author: CommitAuthor },
150
+ token: string,
151
+ ): Promise<{ sha: string } | { conflict: true }> {
152
+ const baseTree = await commitTreeSha(repo, parent, token);
153
+
154
+ const treeRes = await fetch(gitUrl(repo, 'trees'), {
155
+ method: 'POST',
156
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ base_tree: baseTree, tree }),
158
+ });
159
+ if (!treeRes.ok) {
160
+ // A 422 means an entry is unprocessable against the base tree, which a delete of an
161
+ // already-removed path produces (a concurrent delete or rename got there first). Treat it as
162
+ // the same non-fast-forward conflict the ref PATCH surfaces, so the caller fails safe with the
163
+ // reload-and-retry path instead of a raw 500.
164
+ if (treeRes.status === 422) throw new CommitConflictError(`${repo.branch} (tree create)`);
165
+ throw new Error(`GitHub tree create failed: ${treeRes.status} ${await treeRes.text()}`);
166
+ }
167
+ const newTree = ((await treeRes.json()) as { sha: string }).sha;
168
+
169
+ const commitRes = await fetch(gitUrl(repo, 'commits'), {
170
+ method: 'POST',
171
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({ message: opts.message, tree: newTree, parents: [parent], author: opts.author }),
173
+ });
174
+ if (!commitRes.ok) throw new Error(`GitHub commit create failed: ${commitRes.status} ${await commitRes.text()}`);
175
+ const newCommit = ((await commitRes.json()) as { sha: string }).sha;
176
+
177
+ const refRes = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(repo.branch)}`), {
178
+ method: 'PATCH',
179
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ sha: newCommit, force: false }),
181
+ });
182
+ if (refRes.ok) return { sha: newCommit };
183
+ // A non-fast-forward means the branch moved; the caller decides whether to retry or fail closed.
184
+ // Any other failure is not a race, so surface it.
185
+ if (refRes.status !== 422) throw new Error(`GitHub ref update failed: ${refRes.status} ${await refRes.text()}`);
186
+ return { conflict: true };
187
+ }
188
+
189
+ /** Fail-closed commit on a known head: a non-fast-forward becomes a `CommitConflictError`. */
190
+ async function commitOnHead(
191
+ repo: RepoRef,
192
+ head: string,
193
+ tree: TreeChange[],
194
+ opts: { message: string; author: CommitAuthor },
195
+ token: string,
196
+ ): Promise<string> {
197
+ const result = await commitOnTree(repo, head, tree, opts, token);
198
+ if ('conflict' in result) throw new CommitConflictError(`${repo.branch} (head moved)`);
199
+ return result.sha;
200
+ }
201
+
189
202
  /**
190
203
  * Commit several path changes in one commit over the Git Data API. The author is the editor; the
191
204
  * committer is omitted, so GitHub attributes the commit to the App. Returns the new commit sha.
@@ -197,51 +210,34 @@ const COMMIT_RETRIES = 3;
197
210
  *
198
211
  * An empty change set is rejected, since it would otherwise push an empty commit that triggers a
199
212
  * site redeploy for no content change.
213
+ *
214
+ * When `expectedHead` is supplied the commit is fail-closed: it makes a single attempt with no
215
+ * retry, throws a `CommitConflictError` when the branch head is not `expectedHead` (a concurrent
216
+ * commit landed), and otherwise commits onto that head. The nav and settings writes, which land on
217
+ * the default branch and trigger a deploy, pass it so a same-branch race surfaces the editor's
218
+ * reload-and-reapply prompt rather than a silent last-writer-wins. Omitting it keeps the
219
+ * head-merge retry the entry and publish paths rely on.
200
220
  */
201
221
  export async function commitFiles(
202
222
  repo: RepoRef,
203
223
  changes: FileChange[],
204
224
  opts: { message: string; author: CommitAuthor },
205
225
  token: string,
226
+ expectedHead?: string,
206
227
  ): Promise<string> {
207
228
  if (changes.length === 0) throw new Error('commitFiles: no changes to commit');
208
229
  const tree = treeChanges(changes);
230
+ if (expectedHead !== undefined) {
231
+ const head = await headCommitSha(repo, token);
232
+ if (head !== expectedHead) throw new CommitConflictError(`${repo.branch} (head moved)`);
233
+ return commitOnHead(repo, head, tree, opts, token);
234
+ }
209
235
  for (let attempt = 0; attempt <= COMMIT_RETRIES; attempt++) {
210
236
  const parent = await headCommitSha(repo, token);
211
- const baseTree = await commitTreeSha(repo, parent, token);
212
-
213
- const treeRes = await fetch(gitUrl(repo, 'trees'), {
214
- method: 'POST',
215
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
216
- body: JSON.stringify({ base_tree: baseTree, tree }),
217
- });
218
- if (!treeRes.ok) {
219
- // A 422 means an entry is unprocessable against the base tree, which a delete of an
220
- // already-removed path produces (a concurrent delete or rename got there first). Treat it as
221
- // the same non-fast-forward conflict the ref PATCH surfaces, so the caller fails safe with the
222
- // reload-and-retry path instead of a raw 500.
223
- if (treeRes.status === 422) throw new CommitConflictError(`${repo.branch} (tree create)`);
224
- throw new Error(`GitHub tree create failed: ${treeRes.status} ${await treeRes.text()}`);
225
- }
226
- const newTree = ((await treeRes.json()) as { sha: string }).sha;
227
-
228
- const commitRes = await fetch(gitUrl(repo, 'commits'), {
229
- method: 'POST',
230
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
231
- body: JSON.stringify({ message: opts.message, tree: newTree, parents: [parent], author: opts.author }),
232
- });
233
- if (!commitRes.ok) throw new Error(`GitHub commit create failed: ${commitRes.status} ${await commitRes.text()}`);
234
- const newCommit = ((await commitRes.json()) as { sha: string }).sha;
235
-
236
- const refRes = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(repo.branch)}`), {
237
- method: 'PATCH',
238
- headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
239
- body: JSON.stringify({ sha: newCommit, force: false }),
240
- });
241
- if (refRes.ok) return newCommit;
237
+ const result = await commitOnTree(repo, parent, tree, opts, token);
242
238
  // A non-fast-forward means the branch moved; retry on the new head so a concurrent commit
243
- // is preserved. Any other failure is not a race, so surface it.
244
- if (refRes.status !== 422) throw new Error(`GitHub ref update failed: ${refRes.status} ${await refRes.text()}`);
239
+ // is preserved.
240
+ if ('sha' in result) return result.sha;
245
241
  }
246
242
  throw new CommitConflictError(`${repo.branch} (atomic commit)`);
247
243
  }
@@ -1,9 +1,9 @@
1
- // cairn-cms: the GitHub backend's plain data types and its one typed error. The backend
2
- // reads repo coordinates from the adapter's `BackendConfig` (spec §8); `RepoRef` is the
3
- // `{ owner, repo, branch }` subset, so `backend` is assignable wherever a `RepoRef` is
4
- // wanted with no conversion.
1
+ // cairn-cms: the GitHub backend's plain data types and its one typed error. The GitHub App
2
+ // provider config (`githubApp`'s input) carries these repo coordinates; `RepoRef` is the
3
+ // `{ owner, repo, branch }` subset the read and commit transports take, so the provider config
4
+ // is assignable wherever a `RepoRef` is wanted with no conversion.
5
5
 
6
- /** Repo coordinates pinned to a branch: the structural subset of `BackendConfig` the read and commit paths need. */
6
+ /** Repo coordinates pinned to a branch: the `{ owner, repo, branch }` subset the read and commit paths need. */
7
7
  export interface RepoRef {
8
8
  owner: string;
9
9
  repo: string;
package/src/lib/index.ts CHANGED
@@ -9,17 +9,10 @@ export { buildMagicLinkMessage, cloudflareSend } from './email.js';
9
9
  export type {
10
10
  CairnAdapter,
11
11
  ConceptConfig,
12
- FrontmatterField,
13
- TextField,
14
- TextareaField,
15
- DateField,
16
- BooleanField,
17
- TagsField,
18
- FreeTagsField,
19
- ImageField,
12
+ NamedField,
20
13
  ImageValue,
21
14
  ValidationResult,
22
- BackendConfig,
15
+ ValidationIssue,
23
16
  SenderConfig,
24
17
  NavMenuConfig,
25
18
  PreviewConfig,
@@ -30,10 +23,11 @@ export type {
30
23
  ConceptUrlPolicy,
31
24
  CairnExtension,
32
25
  CairnRuntime,
26
+ SiteRender,
33
27
  AdminPanel,
34
28
  FieldTypeDef,
35
29
  } from './content/types.js';
36
- export { CONCEPT_ROUTING, normalizeConcepts, findConcept } from './content/concepts.js';
30
+ export { normalizeConcepts, findConcept, defineConcept } from './content/concepts.js';
37
31
  export { composeRuntime } from './content/compose.js';
38
32
  export type { ComposeInput } from './content/compose.js';
39
33
  export {
@@ -42,16 +36,30 @@ export {
42
36
  serializeMarkdown,
43
37
  parseMarkdown,
44
38
  } from './content/frontmatter.js';
45
- export { defineFields } from './content/schema.js';
46
39
  export { defineAdapter } from './content/adapter.js';
47
- export type { ConceptSchema, Infer, InferFields, DefineFieldsOptions, StandardInput, StandardSchemaV1 } from './content/schema.js';
48
- // The Contract v2 field vocabulary, additive beside `defineFields`. The individual *Field
49
- // interfaces and the bare `Infer` stay module-local: the old `FrontmatterField` model above
50
- // already exports those names, and the cutover plan frees them.
40
+ export type { StandardInput, StandardSchemaV1 } from './content/standard-schema.js';
41
+ // The Contract v2 field vocabulary: the one live field system.
51
42
  export { fields } from './content/fields.js';
52
- export type { FieldDescriptor } from './content/fields.js';
43
+ export type {
44
+ FieldDescriptor,
45
+ TextField,
46
+ TextareaField,
47
+ NumberField,
48
+ SelectField,
49
+ MultiselectField,
50
+ UrlField,
51
+ EmailField,
52
+ DateField,
53
+ DatetimeField,
54
+ BooleanField,
55
+ IconField,
56
+ ImageField,
57
+ ObjectField,
58
+ ReferenceField,
59
+ ArrayField,
60
+ } from './content/fields.js';
53
61
  export { fieldset, initialValues } from './content/fieldset.js';
54
- export type { Fieldset, InferFieldset, FieldsetOptions, BehaviorTable } from './content/fieldset.js';
62
+ export type { Fieldset, InferFieldset, FieldsetOptions, BehaviorTable, FieldBehavior } from './content/fieldset.js';
55
63
  export {
56
64
  isValidId,
57
65
  idFromFilename,
@@ -71,6 +79,7 @@ export {
71
79
  parseManifest,
72
80
  emptyManifest,
73
81
  verifyManifest,
82
+ verifyReferences,
74
83
  diffManifests,
75
84
  upsertEntry,
76
85
  removeEntry,
@@ -78,14 +87,17 @@ export {
78
87
  manifestLinkResolver,
79
88
  inboundLinks,
80
89
  } from './content/manifest.js';
81
- export type { Manifest, ManifestEntry, ManifestDiff, ManifestEntryDiff, LinkTarget, InboundLink } from './content/manifest.js';
90
+ export type { Manifest, ManifestEntry, ManifestDiff, ManifestEntryDiff, LinkTarget, InboundLink, InboundReference } from './content/manifest.js';
91
+ export type { ReferenceEdge } from './content/references.js';
92
+ // The read-model resolution of a reference edge to its target's identity lives at the cross-concept
93
+ // site-resolver layer (a per-concept index cannot reach a different concept's entries). The resolver
94
+ // function ships from the /delivery subpath; this is the type a route reads off the resolved map.
95
+ export type { ResolvedReference } from './delivery/site-resolver.js';
82
96
  // Render engine (Plan 04): generic directive pipeline; sites own the component registry.
83
- export { defineRegistry, emptyValues } from './render/registry.js';
97
+ export { defineRegistry, defineComponent, emptyValues } from './render/registry.js';
84
98
  export type {
85
99
  ComponentDef,
86
100
  ComponentRegistry,
87
- FieldType,
88
- AttributeField,
89
101
  SlotKind,
90
102
  SlotDef,
91
103
  ComponentValues,
@@ -107,13 +119,16 @@ export { createRenderer } from './render/pipeline.js';
107
119
  export type { RendererOptions } from './render/pipeline.js';
108
120
 
109
121
  // GitHub read-and-commit backend (Plan 03).
110
- export type { RepoRef, RepoFile, CommitAuthor, AppCredentials } from './github/types.js';
122
+ export type { RepoFile, CommitAuthor } from './github/types.js';
111
123
  export { CommitConflictError } from './github/types.js';
124
+ // The Backend seam (Contract v2 backend phase): the store interface and its default GitHub provider.
125
+ export { githubApp } from './github/backend.js';
126
+ export type { Backend, BackendProvider, GithubAppProvider, BackendEnv } from './github/backend.js';
127
+ export type { FileChange } from './github/repo.js';
112
128
 
113
129
  // Nav tree and site-config helpers (Plan 06).
114
130
  export {
115
131
  parseSiteConfig,
116
- urlPolicyFrom,
117
132
  extractMenu,
118
133
  setMenu,
119
134
  validateNavTree,
@@ -0,0 +1,84 @@
1
+ // cairn-cms islands (@glw907/cairn-cms/islands): the client runtime that mounts a site's live Svelte
2
+ // components over the static fallbacks the render pipeline emits. cairn is Svelte-only by design, so this
3
+ // mounts with Svelte's own mount()/unmount() directly, with no framework abstraction. A site imports this
4
+ // dynamically, gated on a non-empty registry, so a static site never ships it (zero cost when unused).
5
+ import { mount, unmount, type Component } from 'svelte';
6
+ import type { IslandRegistry } from './types.js';
7
+
8
+ export type { IslandRegistry } from './types.js';
9
+
10
+ // The live Svelte instances of the current pass and the observers still waiting to fire, kept module-level
11
+ // so the next pass can tear the previous one down. A layout calls hydrateIslands once per navigation, and
12
+ // the previous mounts must unmount before the next mount over the same DOM.
13
+ let mounted: Record<string, unknown>[] = [];
14
+ let observers: IntersectionObserver[] = [];
15
+
16
+ // Tear down the previous pass: unmount live instances and disconnect observers that never fired. unmount
17
+ // runs with outro: false so teardown is synchronous and deterministic on navigation; an island declaring an
18
+ // out: transition would otherwise linger and briefly double-render against the next pass's fresh mount.
19
+ function teardown(): void {
20
+ for (const o of observers) o.disconnect();
21
+ observers = [];
22
+ for (const instance of mounted) {
23
+ try {
24
+ void unmount(instance, { outro: false });
25
+ } catch {
26
+ // a component that throws on teardown must not block the rest
27
+ }
28
+ }
29
+ mounted = [];
30
+ }
31
+
32
+ // Mount one island over its boundary: parse props (try/catch, a malformed payload leaves the fallback),
33
+ // clear the fallback, mount, and on a mount failure restore the fallback so the reader still sees content.
34
+ // WATCH: props are trusted to equal the directive's declared scalar attributes (serializeIslandProps emits
35
+ // only those). If a directive ever carries an attribute its island does not declare, this forwards it as-is.
36
+ function mountIsland(node: Element, Comp: Component<Record<string, unknown>>): void {
37
+ let props: Record<string, unknown>;
38
+ try {
39
+ props = JSON.parse(node.getAttribute('data-cairn-props') ?? '{}') as Record<string, unknown>;
40
+ } catch {
41
+ return;
42
+ }
43
+ const fallback = [...node.childNodes];
44
+ node.replaceChildren();
45
+ try {
46
+ mounted.push(mount(Comp, { target: node as HTMLElement, props }));
47
+ } catch {
48
+ node.replaceChildren(...fallback);
49
+ }
50
+ }
51
+
52
+ // Defer a 'visible' island to first intersection, then mount once and stop observing.
53
+ function observeIsland(node: Element, Comp: Component<Record<string, unknown>>): void {
54
+ const observer = new IntersectionObserver((entries, self) => {
55
+ for (const entry of entries) {
56
+ if (entry.isIntersecting) {
57
+ self.disconnect();
58
+ mountIsland(node, Comp);
59
+ }
60
+ }
61
+ });
62
+ observer.observe(node);
63
+ observers.push(observer);
64
+ }
65
+
66
+ /**
67
+ * Mount each island in `root` (default `document`) over its server-rendered fallback. Call it after each
68
+ * client-side navigation, once the new DOM is in place (an `afterNavigate` callback): it tears down the
69
+ * previous pass first, so it is idempotent and leak-free. An eager island (`hydrate: true`) mounts at once;
70
+ * a `'visible'` island mounts on first intersection. An unknown directive name, a malformed prop payload,
71
+ * or a component that throws leaves the static fallback in place, so one bad island never breaks the page.
72
+ * Mount-and-replace clears the fallback, so an island whose fallback holds a focusable control should
73
+ * restore focus itself; the shipped fallbacks are non-interactive.
74
+ */
75
+ export function hydrateIslands(islands: IslandRegistry, root: ParentNode = document): void {
76
+ teardown();
77
+ for (const node of root.querySelectorAll('[data-cairn-island]')) {
78
+ const name = node.getAttribute('data-cairn-island');
79
+ const Comp = name ? islands[name] : undefined;
80
+ if (!Comp) continue;
81
+ if (node.getAttribute('data-cairn-hydrate') === 'visible') observeIsland(node, Comp);
82
+ else mountIsland(node, Comp);
83
+ }
84
+ }
@@ -0,0 +1,11 @@
1
+ // cairn-cms islands (@glw907/cairn-cms/islands): the type contract shared by the adapter and the client
2
+ // runtime. Kept in its own runtime-free module so the adapter types can import it without pulling
3
+ // Svelte's mount() into the server graph.
4
+ import type { Component } from 'svelte';
5
+
6
+ /**
7
+ * A site's island components, keyed by directive name. Each value is the live Svelte component
8
+ * {@link hydrateIslands} mounts over the matching `hydrate` directive's static fallback. The props a
9
+ * component receives are the directive's declared scalar attributes (see the island boundary contract).
10
+ */
11
+ export type IslandRegistry = Record<string, Component<Record<string, unknown>>>;
@@ -16,11 +16,10 @@
16
16
  // injected, so the planner never imports the editor surface. It is internal, exported from no package
17
17
  // subpath, so it carries no reference page.
18
18
  import type { ConceptDescriptor } from '../content/types.js';
19
- import type { RepoRef } from '../github/types.js';
19
+ import type { Backend } from '../github/backend.js';
20
20
  import type { Manifest } from '../content/manifest.js';
21
21
  import { findConcept } from '../content/concepts.js';
22
22
  import { filenameFromId } from '../content/ids.js';
23
- import { readRaw } from '../github/repo.js';
24
23
  import { buildUsageIndex } from './usage.js';
25
24
 
26
25
  /**
@@ -81,8 +80,7 @@ export interface RewritePlan<P = unknown> {
81
80
  * editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
82
81
  */
83
82
  export async function planMediaRewrite<P = unknown>(args: {
84
- backend: RepoRef;
85
- token: string;
83
+ backend: Backend;
86
84
  concepts: ConceptDescriptor[];
87
85
  contentManifest: Manifest;
88
86
  hash: string;
@@ -90,7 +88,7 @@ export async function planMediaRewrite<P = unknown>(args: {
90
88
  }): Promise<RewritePlan<P>> {
91
89
  // Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
92
90
  // Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
93
- const index = await buildUsageIndex(args.backend, args.token, args.concepts, args.contentManifest, {
91
+ const index = await buildUsageIndex(args.backend, args.concepts, args.contentManifest, {
94
92
  strict: true,
95
93
  });
96
94
  const rows = index.get(args.hash) ?? [];
@@ -104,7 +102,7 @@ export async function planMediaRewrite<P = unknown>(args: {
104
102
  const concept = findConcept(args.concepts, row.concept);
105
103
  if (!concept) return null;
106
104
  const path = `${concept.dir}/${filenameFromId(row.id)}`;
107
- const markdown = await readRaw(args.backend, path, args.token);
105
+ const markdown = await args.backend.readFile(path, args.backend.defaultBranch);
108
106
  if (markdown === null) return null;
109
107
  const result = args.transform(markdown);
110
108
  if (result.placements.length === 0) return null;