@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.
- package/CHANGELOG.md +82 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +5 -5
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- package/dist/components/EditPage.svelte +29 -107
- package/dist/components/EditPage.svelte.d.ts +2 -7
- package/dist/components/EntryPicker.svelte +117 -0
- package/dist/components/EntryPicker.svelte.d.ts +35 -0
- package/dist/components/FieldInput.svelte +218 -0
- package/dist/components/FieldInput.svelte.d.ts +51 -0
- package/dist/components/IconPicker.svelte +2 -2
- package/dist/components/IconPicker.svelte.d.ts +2 -0
- package/dist/components/LinkPicker.svelte +8 -75
- package/dist/components/LinkPicker.svelte.d.ts +4 -5
- package/dist/components/MediaHeroField.svelte +8 -5
- package/dist/components/MediaHeroField.svelte.d.ts +4 -0
- package/dist/components/ObjectGroupField.svelte +54 -0
- package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
- package/dist/components/ReferenceField.svelte +94 -0
- package/dist/components/ReferenceField.svelte.d.ts +27 -0
- package/dist/components/RepeatableField.svelte +221 -0
- package/dist/components/RepeatableField.svelte.d.ts +53 -0
- package/dist/components/cairn-admin.css +4 -0
- package/dist/components/preview-doc.js +5 -1
- package/dist/components/tidy-validate.js +1 -1
- package/dist/content/adapter.js +18 -0
- package/dist/content/advisories.d.ts +2 -2
- package/dist/content/advisories.js +3 -5
- package/dist/content/compose.d.ts +7 -6
- package/dist/content/compose.js +26 -20
- package/dist/content/concepts.d.ts +21 -15
- package/dist/content/concepts.js +55 -32
- package/dist/content/field-rules.js +3 -4
- package/dist/content/fields.d.ts +49 -1
- package/dist/content/fields.js +11 -0
- package/dist/content/fieldset.d.ts +31 -10
- package/dist/content/fieldset.js +262 -109
- package/dist/content/frontmatter-region.d.ts +38 -0
- package/dist/content/frontmatter-region.js +75 -0
- package/dist/content/frontmatter.d.ts +35 -2
- package/dist/content/frontmatter.js +232 -11
- package/dist/content/manifest.d.ts +34 -0
- package/dist/content/manifest.js +80 -4
- package/dist/content/media-refs.d.ts +2 -2
- package/dist/content/media-rewrite.js +1 -69
- package/dist/content/reference-index.d.ts +56 -0
- package/dist/content/reference-index.js +95 -0
- package/dist/content/references.d.ts +40 -0
- package/dist/content/references.js +0 -0
- package/dist/content/standard-schema.d.ts +30 -0
- package/dist/content/standard-schema.js +4 -0
- package/dist/content/types.d.ts +127 -178
- package/dist/delivery/data.d.ts +2 -2
- package/dist/delivery/data.js +1 -1
- package/dist/delivery/public-routes.d.ts +2 -5
- package/dist/delivery/public-routes.js +15 -1
- package/dist/delivery/site-descriptors.d.ts +5 -1
- package/dist/delivery/site-descriptors.js +8 -3
- package/dist/delivery/site-indexes.d.ts +2 -2
- package/dist/delivery/site-resolver.d.ts +25 -0
- package/dist/delivery/site-resolver.js +49 -0
- package/dist/doctor/checks-local.js +6 -11
- package/dist/github/backend.d.ts +83 -0
- package/dist/github/backend.js +76 -0
- package/dist/github/credentials.d.ts +11 -5
- package/dist/github/credentials.js +3 -3
- package/dist/github/repo.d.ts +8 -19
- package/dist/github/repo.js +69 -80
- package/dist/github/types.d.ts +1 -1
- package/dist/github/types.js +4 -4
- package/dist/index.d.ts +16 -12
- package/dist/index.js +7 -8
- package/dist/islands/index.d.ts +12 -0
- package/dist/islands/index.js +83 -0
- package/dist/islands/types.d.ts +7 -0
- package/dist/islands/types.js +1 -0
- package/dist/media/rewrite-plan.d.ts +2 -3
- package/dist/media/rewrite-plan.js +2 -3
- package/dist/media/usage.d.ts +2 -2
- package/dist/media/usage.js +3 -5
- package/dist/nav/site-config.d.ts +0 -6
- package/dist/nav/site-config.js +6 -4
- package/dist/render/component-grammar.js +11 -11
- package/dist/render/component-reference.js +5 -3
- package/dist/render/component-validate.d.ts +4 -1
- package/dist/render/component-validate.js +10 -35
- package/dist/render/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +1 -1
- package/dist/render/registry.d.ts +34 -34
- package/dist/render/registry.js +26 -5
- package/dist/render/rehype-dispatch.d.ts +4 -4
- package/dist/render/rehype-dispatch.js +36 -11
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.js +1 -1
- package/dist/sveltekit/cairn-admin.d.ts +5 -5
- package/dist/sveltekit/cairn-admin.js +3 -4
- package/dist/sveltekit/content-routes.d.ts +10 -8
- package/dist/sveltekit/content-routes.js +269 -181
- package/dist/sveltekit/health.d.ts +7 -3
- package/dist/sveltekit/health.js +9 -3
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/nav-routes.d.ts +6 -5
- package/dist/sveltekit/nav-routes.js +22 -20
- package/dist/sveltekit/types.d.ts +2 -0
- package/dist/vite/index.d.ts +3 -3
- package/dist/vite/index.js +17 -8
- package/package.json +5 -1
- package/src/lib/ambient.ts +7 -0
- package/src/lib/components/CairnAdmin.svelte +2 -6
- package/src/lib/components/ComponentForm.svelte +48 -27
- package/src/lib/components/ComponentInsertDialog.svelte +9 -8
- package/src/lib/components/EditPage.svelte +43 -119
- package/src/lib/components/EntryPicker.svelte +154 -0
- package/src/lib/components/FieldInput.svelte +262 -0
- package/src/lib/components/IconPicker.svelte +4 -2
- package/src/lib/components/LinkPicker.svelte +10 -81
- package/src/lib/components/MediaHeroField.svelte +12 -5
- package/src/lib/components/ObjectGroupField.svelte +97 -0
- package/src/lib/components/ReferenceField.svelte +126 -0
- package/src/lib/components/RepeatableField.svelte +310 -0
- package/src/lib/components/preview-doc.ts +5 -1
- package/src/lib/components/tidy-validate.ts +1 -1
- package/src/lib/content/adapter.ts +21 -0
- package/src/lib/content/advisories.ts +4 -7
- package/src/lib/content/compose.ts +30 -23
- package/src/lib/content/concepts.ts +68 -40
- package/src/lib/content/field-rules.ts +3 -4
- package/src/lib/content/fields.ts +52 -1
- package/src/lib/content/fieldset.ts +291 -128
- package/src/lib/content/frontmatter-region.ts +90 -0
- package/src/lib/content/frontmatter.ts +231 -15
- package/src/lib/content/manifest.ts +101 -4
- package/src/lib/content/media-refs.ts +2 -2
- package/src/lib/content/media-rewrite.ts +7 -80
- package/src/lib/content/reference-index.ts +159 -0
- package/src/lib/content/references.ts +0 -0
- package/src/lib/content/standard-schema.ts +25 -0
- package/src/lib/content/types.ts +128 -195
- package/src/lib/delivery/data.ts +2 -2
- package/src/lib/delivery/public-routes.ts +17 -3
- package/src/lib/delivery/site-descriptors.ts +8 -3
- package/src/lib/delivery/site-indexes.ts +2 -2
- package/src/lib/delivery/site-resolver.ts +64 -0
- package/src/lib/doctor/checks-local.ts +6 -14
- package/src/lib/github/backend.ts +161 -0
- package/src/lib/github/credentials.ts +10 -7
- package/src/lib/github/repo.ts +79 -83
- package/src/lib/github/types.ts +5 -5
- package/src/lib/index.ts +38 -23
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -0
- package/src/lib/media/rewrite-plan.ts +4 -6
- package/src/lib/media/usage.ts +4 -7
- package/src/lib/nav/site-config.ts +8 -9
- package/src/lib/render/component-grammar.ts +10 -10
- package/src/lib/render/component-reference.ts +4 -3
- package/src/lib/render/component-validate.ts +10 -35
- package/src/lib/render/pipeline.ts +1 -7
- package/src/lib/render/registry.ts +58 -39
- package/src/lib/render/rehype-dispatch.ts +45 -10
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +1 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- package/src/lib/sveltekit/health.ts +13 -6
- package/src/lib/sveltekit/index.ts +2 -2
- package/src/lib/sveltekit/nav-routes.ts +33 -29
- package/src/lib/sveltekit/types.ts +5 -1
- package/src/lib/vite/index.ts +20 -11
- package/dist/content/schema.d.ts +0 -87
- package/dist/content/schema.js +0 -85
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -163
- 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
|
-
/**
|
|
10
|
-
|
|
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
|
|
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
|
-
|
|
21
|
-
env:
|
|
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:
|
|
32
|
+
return { appId: identity.appId, installationId: identity.installationId, privateKeyB64 };
|
|
30
33
|
}
|
package/src/lib/github/repo.ts
CHANGED
|
@@ -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
|
|
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.
|
|
244
|
-
if (
|
|
239
|
+
// is preserved.
|
|
240
|
+
if ('sha' in result) return result.sha;
|
|
245
241
|
}
|
|
246
242
|
throw new CommitConflictError(`${repo.branch} (atomic commit)`);
|
|
247
243
|
}
|
package/src/lib/github/types.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// cairn-cms: the GitHub backend's plain data types and its one typed error. The
|
|
2
|
-
//
|
|
3
|
-
// `{ owner, repo, branch }` subset
|
|
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
|
|
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
|
-
|
|
13
|
-
TextField,
|
|
14
|
-
TextareaField,
|
|
15
|
-
DateField,
|
|
16
|
-
BooleanField,
|
|
17
|
-
TagsField,
|
|
18
|
-
FreeTagsField,
|
|
19
|
-
ImageField,
|
|
12
|
+
NamedField,
|
|
20
13
|
ImageValue,
|
|
21
14
|
ValidationResult,
|
|
22
|
-
|
|
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 {
|
|
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 {
|
|
48
|
-
// The Contract v2 field vocabulary
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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:
|
|
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.
|
|
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
|
|
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;
|