@glw907/cairn-cms 0.40.0 → 0.50.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 +76 -0
- package/README.md +3 -3
- package/dist/ambient.d.ts +9 -0
- package/dist/ambient.js +1 -0
- package/dist/components/AdminLayout.svelte +6 -8
- package/dist/components/CairnAdmin.svelte +67 -0
- package/dist/components/CairnAdmin.svelte.d.ts +35 -0
- package/dist/components/ConceptList.svelte +18 -10
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +47 -19
- package/dist/components/EditPage.svelte.d.ts +4 -9
- package/dist/components/EditorToolbar.svelte +4 -0
- package/dist/components/LoginPage.svelte +2 -2
- package/dist/components/LoginPage.svelte.d.ts +1 -1
- package/dist/components/ManageEditors.svelte +4 -3
- package/dist/components/ManageEditors.svelte.d.ts +2 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- package/dist/content/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +2 -2
- package/dist/delivery/data.d.ts +3 -5
- package/dist/delivery/data.js +2 -3
- package/dist/delivery/feeds.js +1 -7
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +1 -1
- package/dist/delivery/manifest.d.ts +0 -5
- package/dist/delivery/manifest.js +5 -16
- package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
- package/dist/{sveltekit → delivery}/public-routes.js +7 -7
- package/dist/delivery/site-indexes.d.ts +3 -3
- package/dist/delivery/site-indexes.js +3 -3
- package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
- package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
- package/dist/delivery/sitemap.js +1 -3
- package/dist/delivery/xml.d.ts +2 -0
- package/dist/delivery/xml.js +11 -0
- package/dist/diagnostics/conditions.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/email.js +4 -11
- package/dist/env.d.ts +1 -1
- package/dist/env.js +3 -2
- package/dist/escape.d.ts +2 -0
- package/dist/escape.js +11 -0
- package/dist/github/credentials.d.ts +2 -1
- package/dist/github/credentials.js +10 -2
- package/dist/github/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/github/types.d.ts +2 -0
- package/dist/github/types.js +4 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +2 -0
- package/dist/nav/site-config.js +2 -0
- package/dist/sveltekit/admin-dispatch.d.ts +28 -0
- package/dist/sveltekit/admin-dispatch.js +62 -0
- package/dist/sveltekit/cairn-admin.d.ts +94 -0
- package/dist/sveltekit/cairn-admin.js +126 -0
- package/dist/sveltekit/condition-response.d.ts +1 -0
- package/dist/sveltekit/condition-response.js +25 -0
- package/dist/sveltekit/content-routes.d.ts +34 -14
- package/dist/sveltekit/content-routes.js +78 -44
- package/dist/sveltekit/guard.js +15 -3
- package/dist/sveltekit/https-required-page.js +2 -1
- package/dist/sveltekit/index.d.ts +3 -1
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/nav-routes.d.ts +3 -1
- package/dist/sveltekit/nav-routes.js +19 -10
- package/dist/sveltekit/static-admin-page.d.ts +0 -2
- package/dist/sveltekit/static-admin-page.js +1 -8
- package/dist/sveltekit/types.d.ts +18 -11
- package/package.json +10 -4
- package/src/lib/ambient.ts +19 -0
- package/src/lib/components/AdminLayout.svelte +6 -8
- package/src/lib/components/CairnAdmin.svelte +67 -0
- package/src/lib/components/ConceptList.svelte +18 -10
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +47 -19
- package/src/lib/components/EditorToolbar.svelte +4 -0
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +2 -2
- package/src/lib/delivery/data.ts +3 -5
- package/src/lib/delivery/feeds.ts +1 -8
- package/src/lib/delivery/index.ts +2 -2
- package/src/lib/delivery/manifest.ts +5 -18
- package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
- package/src/lib/delivery/site-indexes.ts +6 -6
- package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
- package/src/lib/delivery/sitemap.ts +1 -4
- package/src/lib/delivery/xml.ts +12 -0
- package/src/lib/diagnostics/conditions.ts +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/email.ts +4 -11
- package/src/lib/env.ts +3 -2
- package/src/lib/escape.ts +12 -0
- package/src/lib/github/credentials.ts +6 -2
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/github/types.ts +5 -0
- package/src/lib/log/events.ts +2 -0
- package/src/lib/nav/site-config.ts +3 -0
- package/src/lib/sveltekit/admin-dispatch.ts +75 -0
- package/src/lib/sveltekit/cairn-admin.ts +177 -0
- package/src/lib/sveltekit/condition-response.ts +27 -1
- package/src/lib/sveltekit/content-routes.ts +121 -55
- package/src/lib/sveltekit/guard.ts +16 -3
- package/src/lib/sveltekit/https-required-page.ts +2 -1
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/nav-routes.ts +21 -11
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- package/dist/delivery/paginate.d.ts +0 -12
- package/dist/delivery/paginate.js +0 -20
- package/dist/render/index.d.ts +0 -5
- package/dist/render/index.js +0 -8
- package/src/lib/delivery/paginate.ts +0 -32
- package/src/lib/render/index.ts +0 -8
|
@@ -4,20 +4,19 @@
|
|
|
4
4
|
// email `send` injection in auth-routes. A shim stays one line: `export const load = routes.editLoad`.
|
|
5
5
|
import { redirect, error, fail } from '@sveltejs/kit';
|
|
6
6
|
import { findConcept } from '../content/concepts.js';
|
|
7
|
-
import { extractCairnLinks, formatCairnToken } from '../content/links.js';
|
|
7
|
+
import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
|
|
8
8
|
import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
|
|
9
9
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
10
|
-
import { rewriteCairnLink } from '../components/markdown-format.js';
|
|
11
10
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
12
11
|
import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
|
|
13
12
|
import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
|
|
14
13
|
import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
|
|
15
14
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
16
15
|
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
17
|
-
import {
|
|
16
|
+
import { isConflict } from '../github/types.js';
|
|
18
17
|
import { log } from '../log/index.js';
|
|
19
18
|
import { issueCsrfToken } from './csrf.js';
|
|
20
|
-
import type { CookieJar } from './types.js';
|
|
19
|
+
import type { CookieJar, EventBase } from './types.js';
|
|
21
20
|
import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
|
|
22
21
|
import type { Editor, Role } from '../auth/types.js';
|
|
23
22
|
|
|
@@ -104,12 +103,8 @@ export interface EditData {
|
|
|
104
103
|
}
|
|
105
104
|
|
|
106
105
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
107
|
-
export interface ContentEvent {
|
|
108
|
-
url: URL;
|
|
106
|
+
export interface ContentEvent extends EventBase<GithubKeyEnv> {
|
|
109
107
|
params: Record<string, string>;
|
|
110
|
-
request: Request;
|
|
111
|
-
locals: { editor?: Editor | null };
|
|
112
|
-
platform?: { env?: GithubKeyEnv };
|
|
113
108
|
/** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
|
|
114
109
|
* token. Optional for non-route callers. */
|
|
115
110
|
cookies?: CookieJar;
|
|
@@ -117,10 +112,42 @@ export interface ContentEvent {
|
|
|
117
112
|
|
|
118
113
|
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
119
114
|
export interface ContentRoutesDeps {
|
|
120
|
-
/** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
|
|
121
|
-
|
|
115
|
+
/** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
|
|
116
|
+
* A bare string works too; the routes await whatever comes back. */
|
|
117
|
+
mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
|
|
122
118
|
}
|
|
123
119
|
|
|
120
|
+
/** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
|
|
121
|
+
export interface SaveFailure {
|
|
122
|
+
/** The one-line human summary every content action failure carries. */
|
|
123
|
+
error: string;
|
|
124
|
+
/** The cairn tokens that resolve to no entry, for the editor's fix-it banner. */
|
|
125
|
+
brokenLinks: string[];
|
|
126
|
+
/** The author's edited markdown, so the editor reseeds with the unsaved work. */
|
|
127
|
+
body: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A refused delete: `fail(409)` while other entries still link to this one. */
|
|
131
|
+
export interface DeleteRefusal {
|
|
132
|
+
/** The one-line human summary every content action failure carries. */
|
|
133
|
+
error: string;
|
|
134
|
+
/** The entries whose bodies link to the refused one, for the blockers list. */
|
|
135
|
+
inboundLinks: InboundLink[];
|
|
136
|
+
/** The refused entry's id, so a list view marks the right row. */
|
|
137
|
+
id: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A refused rename: `fail(400)` on a bad slug, `fail(409)` on a collision or pending edits. */
|
|
141
|
+
export interface RenameFailure {
|
|
142
|
+
/** The one-line human summary every content action failure carries. */
|
|
143
|
+
error: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What a route's single `form` export presents to a view component: whichever content action
|
|
147
|
+
* last failed, merged with every field optional. `error` is always set on a failure; the richer
|
|
148
|
+
* keys identify which guard refused. */
|
|
149
|
+
export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
|
|
150
|
+
|
|
124
151
|
/** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
|
|
125
152
|
function sessionOf(event: ContentEvent): Editor {
|
|
126
153
|
const editor = event.locals.editor;
|
|
@@ -178,8 +205,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
178
205
|
const entry = pendingEntryOf(name);
|
|
179
206
|
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
180
207
|
});
|
|
181
|
-
} catch {
|
|
208
|
+
} catch (err) {
|
|
182
209
|
pendingEntries = null;
|
|
210
|
+
log.warn('github.unreachable', { scope: 'layout', error: String(err) });
|
|
183
211
|
}
|
|
184
212
|
return {
|
|
185
213
|
siteName: runtime.siteName,
|
|
@@ -222,9 +250,37 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
222
250
|
}
|
|
223
251
|
}
|
|
224
252
|
|
|
225
|
-
/**
|
|
226
|
-
*
|
|
227
|
-
*
|
|
253
|
+
/** Read an entry's list row from its pending branch, so a pending title or draft change shows
|
|
254
|
+
* in the list instead of reading as a lost save. summarize degrades a failed or empty read to
|
|
255
|
+
* an id-only row, so a ghost ref still lists. */
|
|
256
|
+
function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], token: string): Promise<EntrySummary> {
|
|
257
|
+
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
|
|
258
|
+
...runtime.backend,
|
|
259
|
+
branch: pendingBranch(concept.id, id),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
|
|
264
|
+
* and read each one for its row, with edited and new rows reading branch-first. */
|
|
265
|
+
async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, token: string): Promise<EntrySummary[]> {
|
|
266
|
+
const files = await listMarkdown(runtime.backend, concept.dir, token);
|
|
267
|
+
const entries = await Promise.all(
|
|
268
|
+
files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))),
|
|
269
|
+
);
|
|
270
|
+
// A ref with no main file is a never-published entry; its row reads from its branch.
|
|
271
|
+
const listed = new Set(files.map((f) => f.id));
|
|
272
|
+
const newRows = await Promise.all(
|
|
273
|
+
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
|
|
274
|
+
);
|
|
275
|
+
return [...entries, ...newRows];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** List a concept's entries with their publish status. Published rows project straight from
|
|
279
|
+
* main's manifest, which publish, delete, and rename keep atomically in sync with main, so
|
|
280
|
+
* the listing costs one manifest read plus one branch read per pending entry rather than one
|
|
281
|
+
* read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
|
|
282
|
+
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
283
|
+
* to an inline error, not a thrown 500. */
|
|
228
284
|
async function listLoad(event: ContentEvent): Promise<ListData> {
|
|
229
285
|
sessionOf(event);
|
|
230
286
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -239,8 +295,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
239
295
|
return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
|
|
240
296
|
}
|
|
241
297
|
try {
|
|
242
|
-
const [
|
|
243
|
-
|
|
298
|
+
const [manifestRaw, refs] = await Promise.all([
|
|
299
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
244
300
|
listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
|
|
245
301
|
]);
|
|
246
302
|
const pendingIds = new Set(
|
|
@@ -249,27 +305,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
249
305
|
return entry && entry.concept.id === concept.id ? [entry.id] : [];
|
|
250
306
|
}),
|
|
251
307
|
);
|
|
252
|
-
//
|
|
253
|
-
//
|
|
308
|
+
// A repo with no committed manifest yet (a fresh site before its first publish) falls back
|
|
309
|
+
// to the crawl; a manifest that parses but is empty is trusted as-is.
|
|
310
|
+
if (manifestRaw === null) {
|
|
311
|
+
return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
|
|
312
|
+
}
|
|
313
|
+
// Newest id first, the same order the crawl's file listing produced.
|
|
314
|
+
const rows = parseManifest(manifestRaw)
|
|
315
|
+
.entries.filter((e) => e.concept === concept.id)
|
|
316
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
254
317
|
const entries = await Promise.all(
|
|
255
|
-
|
|
256
|
-
pendingIds.has(
|
|
257
|
-
?
|
|
258
|
-
:
|
|
318
|
+
rows.map((e) =>
|
|
319
|
+
pendingIds.has(e.id)
|
|
320
|
+
? pendingRow(concept, e.id, 'edited', token)
|
|
321
|
+
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const },
|
|
259
322
|
),
|
|
260
323
|
);
|
|
261
|
-
|
|
262
|
-
// summarize already degrades a failed read to an id-only row.
|
|
263
|
-
const listed = new Set(files.map((f) => f.id));
|
|
324
|
+
const listed = new Set(rows.map((e) => e.id));
|
|
264
325
|
const newRows = await Promise.all(
|
|
265
|
-
[...pendingIds]
|
|
266
|
-
.filter((id) => !listed.has(id))
|
|
267
|
-
.map((id) =>
|
|
268
|
-
summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
|
|
269
|
-
...runtime.backend,
|
|
270
|
-
branch: pendingBranch(concept.id, id),
|
|
271
|
-
}),
|
|
272
|
-
),
|
|
326
|
+
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
|
|
273
327
|
);
|
|
274
328
|
return { ...base, entries: [...entries, ...newRows], error: null };
|
|
275
329
|
} catch {
|
|
@@ -333,17 +387,21 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
333
387
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
334
388
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
335
389
|
// A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
|
|
336
|
-
// (link targets and the inbound-link guard) always reads main, the authoritative copy
|
|
337
|
-
//
|
|
390
|
+
// (link targets and the inbound-link guard) always reads main, the authoritative copy.
|
|
391
|
+
// Stage 1 runs the branch probe, the main-path read, and the manifest read concurrently,
|
|
392
|
+
// so the probe does not serialize ahead of the other two; stage 2 adds the branch read
|
|
393
|
+
// only when the probe found a branch, with the stage-1 main read serving as the published
|
|
394
|
+
// signal either way.
|
|
338
395
|
const branch = pendingBranch(concept.id, id);
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
readRaw(
|
|
396
|
+
const [headSha, mainRaw, manifestRaw] = await Promise.all([
|
|
397
|
+
branchHeadSha(runtime.backend, branch, token),
|
|
398
|
+
readRaw(runtime.backend, path, token),
|
|
342
399
|
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
343
|
-
pending ? readRaw(runtime.backend, path, token) : Promise.resolve(null),
|
|
344
400
|
]);
|
|
401
|
+
const pending = headSha !== null;
|
|
402
|
+
const raw = pending ? await readRaw({ ...runtime.backend, branch }, path, token) : mainRaw;
|
|
345
403
|
if (raw === null && !isNew) throw error(404, 'Entry not found');
|
|
346
|
-
const published =
|
|
404
|
+
const published = mainRaw !== null;
|
|
347
405
|
|
|
348
406
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
349
407
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
@@ -385,11 +443,6 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
385
443
|
};
|
|
386
444
|
}
|
|
387
445
|
|
|
388
|
-
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
389
|
-
function isConflict(err: unknown): boolean {
|
|
390
|
-
return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
|
|
391
|
-
}
|
|
392
|
-
|
|
393
446
|
/** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
394
447
|
* reason; any other error is unexpected and logs at error with the stringified cause. Publish
|
|
395
448
|
* failures carry the same shape under their own event name. */
|
|
@@ -487,7 +540,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
487
540
|
else if (target.draft) draftLinks.push(formatCairnToken(ref));
|
|
488
541
|
}
|
|
489
542
|
if (absent.length) {
|
|
490
|
-
|
|
543
|
+
const noun = absent.length === 1 ? 'page' : 'pages';
|
|
544
|
+
return fail(400, {
|
|
545
|
+
error: `This page links to ${absent.length} missing ${noun}.`,
|
|
546
|
+
brokenLinks: absent,
|
|
547
|
+
body,
|
|
548
|
+
} satisfies SaveFailure);
|
|
491
549
|
}
|
|
492
550
|
|
|
493
551
|
// Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
|
|
@@ -618,14 +676,18 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
618
676
|
next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
|
|
619
677
|
published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
|
|
620
678
|
}
|
|
621
|
-
if (published.length === 0)
|
|
679
|
+
if (published.length === 0) {
|
|
680
|
+
const message = 'Nothing to publish. Every entry is already live.';
|
|
681
|
+
throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
|
|
682
|
+
}
|
|
622
683
|
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
623
684
|
|
|
685
|
+
const noun = published.length === 1 ? 'entry' : 'entries';
|
|
624
686
|
try {
|
|
625
687
|
await commitFiles(
|
|
626
688
|
runtime.backend,
|
|
627
689
|
changes,
|
|
628
|
-
{ message: `Publish ${published.length}
|
|
690
|
+
{ message: `Publish ${published.length} ${noun}`, author: { name: editor.displayName, email: editor.email } },
|
|
629
691
|
token,
|
|
630
692
|
);
|
|
631
693
|
for (const entry of published) {
|
|
@@ -696,7 +758,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
696
758
|
const manifest = await readManifest(token);
|
|
697
759
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
698
760
|
if (inbound.length) {
|
|
699
|
-
return fail(409, {
|
|
761
|
+
return fail(409, {
|
|
762
|
+
error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
|
|
763
|
+
inboundLinks: inbound,
|
|
764
|
+
id,
|
|
765
|
+
} satisfies DeleteRefusal);
|
|
700
766
|
}
|
|
701
767
|
|
|
702
768
|
// When the entry was never published (absent from main), the branch delete is the whole
|
|
@@ -771,20 +837,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
771
837
|
// Pending edits on the branch are keyed to the old id; renaming underneath them would strand
|
|
772
838
|
// them, so refuse until the editor publishes or discards.
|
|
773
839
|
if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
|
|
774
|
-
return fail(409, {
|
|
840
|
+
return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' } satisfies RenameFailure);
|
|
775
841
|
}
|
|
776
842
|
|
|
777
843
|
const form = await event.request.formData();
|
|
778
844
|
const newSlug = String(form.get('slug') ?? '').trim();
|
|
779
845
|
if (!isValidId(newSlug)) {
|
|
780
|
-
return fail(400, {
|
|
846
|
+
return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' } satisfies RenameFailure);
|
|
781
847
|
}
|
|
782
848
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
783
849
|
if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
|
|
784
|
-
return fail(400, {
|
|
850
|
+
return fail(400, { error: 'Leave the date out of the slug.' } satisfies RenameFailure);
|
|
785
851
|
}
|
|
786
852
|
if (newSlug === slugFromId(id, datePrefix)) {
|
|
787
|
-
return fail(400, {
|
|
853
|
+
return fail(400, { error: 'That is already the slug.' } satisfies RenameFailure);
|
|
788
854
|
}
|
|
789
855
|
const newId = renameId(id, newSlug, datePrefix);
|
|
790
856
|
const oldPath = `${concept.dir}/${filenameFromId(id)}`;
|
|
@@ -795,7 +861,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
795
861
|
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
796
862
|
const clobber = await readRaw(runtime.backend, newPath, token);
|
|
797
863
|
if (clobber !== null) {
|
|
798
|
-
return fail(409, {
|
|
864
|
+
return fail(409, { error: 'An entry with that slug already exists.' } satisfies RenameFailure);
|
|
799
865
|
}
|
|
800
866
|
|
|
801
867
|
const [entryRaw, manifest] = await Promise.all([
|
|
@@ -6,7 +6,7 @@ import { resolveSession } from '../auth/store.js';
|
|
|
6
6
|
import { sessionCookieName } from '../auth/crypto.js';
|
|
7
7
|
import { isUnsafeFormRequest, originMatches, validateCsrfToken } from './csrf.js';
|
|
8
8
|
import { applySecurityHeaders } from './admin-response.js';
|
|
9
|
-
import { renderConditionResponse } from './condition-response.js';
|
|
9
|
+
import { renderConditionResponse, REASON_CONDITION } from './condition-response.js';
|
|
10
10
|
import { log } from '../log/index.js';
|
|
11
11
|
import type { Editor } from '../auth/types.js';
|
|
12
12
|
import type { HandleInput, RequestContext } from './types.js';
|
|
@@ -60,6 +60,20 @@ export function createAuthGuard() {
|
|
|
60
60
|
return renderConditionResponse('edge.https-not-forced', { url: event.url });
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// No auth store binding means no admin path can work: the gated views cannot resolve a
|
|
64
|
+
// session, and a login or confirm POST would die in its action with a raw 500. That is an
|
|
65
|
+
// operator fault, not a sign-in problem, so name the condition on every admin path, the
|
|
66
|
+
// public ones included, instead of rendering a login form that can never succeed.
|
|
67
|
+
const env = event.platform?.env ?? {};
|
|
68
|
+
if (!env.AUTH_DB) {
|
|
69
|
+
log.error('guard.rejected', {
|
|
70
|
+
reason: 'bindings',
|
|
71
|
+
conditionId: REASON_CONDITION.bindings,
|
|
72
|
+
path: pathname,
|
|
73
|
+
});
|
|
74
|
+
return renderConditionResponse(REASON_CONDITION.bindings);
|
|
75
|
+
}
|
|
76
|
+
|
|
63
77
|
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
64
78
|
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
65
79
|
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
@@ -68,9 +82,8 @@ export function createAuthGuard() {
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
if (!isPublicAdminPath(pathname)) {
|
|
71
|
-
const env = event.platform?.env ?? {};
|
|
72
85
|
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
73
|
-
const editor = id
|
|
86
|
+
const editor = id ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
|
|
74
87
|
if (!editor) throw redirect(303, '/admin/login');
|
|
75
88
|
event.locals.editor = editor;
|
|
76
89
|
}
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// not match, so the editor would otherwise hit an opaque 403. This page names the problem, says why
|
|
5
5
|
// https is needed, and gives the exact Cloudflare fix. The shared shell lives in
|
|
6
6
|
// static-admin-page.ts. See guard.ts.
|
|
7
|
-
import { escapeHtml
|
|
7
|
+
import { escapeHtml } from '../escape.js';
|
|
8
|
+
import { renderStaticAdminPage } from './static-admin-page.js';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Render the full HTML document for the HTTPS-required page.
|
|
@@ -12,9 +12,15 @@ export type {
|
|
|
12
12
|
EditData,
|
|
13
13
|
ContentEvent,
|
|
14
14
|
ContentRoutesDeps,
|
|
15
|
+
SaveFailure,
|
|
16
|
+
DeleteRefusal,
|
|
17
|
+
RenameFailure,
|
|
18
|
+
ContentFormFailure,
|
|
15
19
|
} from './content-routes.js';
|
|
16
20
|
export { createNavRoutes } from './nav-routes.js';
|
|
17
21
|
export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
|
|
22
|
+
export { parseAdminPath, type AdminView } from './admin-dispatch.js';
|
|
23
|
+
export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-admin.js';
|
|
18
24
|
export { healthLoad, type HealthData } from './health.js';
|
|
19
25
|
export type { RequestContext, CookieJar, HandleInput } from './types.js';
|
|
20
26
|
// Re-exported here, not from root, so the public ContentRoutesDeps consumer can name it.
|
|
@@ -5,7 +5,7 @@ import { redirect, error } from '@sveltejs/kit';
|
|
|
5
5
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
6
6
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
7
7
|
import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
|
|
8
|
-
import {
|
|
8
|
+
import { isConflict } from '../github/types.js';
|
|
9
9
|
import { log } from '../log/index.js';
|
|
10
10
|
import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
|
|
11
11
|
import type { CairnRuntime } from '../content/types.js';
|
|
@@ -29,7 +29,9 @@ export interface NavLoadData {
|
|
|
29
29
|
|
|
30
30
|
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
31
31
|
export interface NavRoutesDeps {
|
|
32
|
-
|
|
32
|
+
/** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
|
|
33
|
+
* A bare string works too; the routes await whatever comes back. */
|
|
34
|
+
mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** The signed-in editor the guard resolved, or a login redirect. */
|
|
@@ -39,11 +41,6 @@ function sessionOf(event: ContentEvent): Editor {
|
|
|
39
41
|
return editor;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
43
|
-
function isConflict(err: unknown): boolean {
|
|
44
|
-
return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
|
|
45
|
-
}
|
|
46
|
-
|
|
47
44
|
export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
|
|
48
45
|
const mintToken =
|
|
49
46
|
deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
@@ -80,12 +77,25 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
|
|
|
80
77
|
}
|
|
81
78
|
|
|
82
79
|
let tree: NavNode[] = [];
|
|
80
|
+
let raw: string | null = null;
|
|
83
81
|
try {
|
|
84
|
-
|
|
85
|
-
if (raw !== null) tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
|
|
82
|
+
raw = await readRaw(runtime.backend, config.configPath, token);
|
|
86
83
|
} catch {
|
|
87
|
-
//
|
|
88
|
-
|
|
84
|
+
// An unreadable config degrades to an empty tree; the first save writes a clean menu.
|
|
85
|
+
raw = null;
|
|
86
|
+
}
|
|
87
|
+
if (raw !== null) {
|
|
88
|
+
try {
|
|
89
|
+
tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// A malformed config keeps the same degrade (the nav page failing closed would be worse
|
|
92
|
+
// for the editor), but the swallow names the operator fault in the log.
|
|
93
|
+
log.error('config.invalid', {
|
|
94
|
+
conditionId: 'config.site-config-invalid',
|
|
95
|
+
error: String(err),
|
|
96
|
+
});
|
|
97
|
+
tree = [];
|
|
98
|
+
}
|
|
89
99
|
}
|
|
90
100
|
|
|
91
101
|
return {
|
|
@@ -2,15 +2,7 @@
|
|
|
2
2
|
// self-contained document with inlined Warm Stone tokens for both colour schemes and the system
|
|
3
3
|
// font stack, served raw before SvelteKit renders. The cairn glyph is the same public-domain
|
|
4
4
|
// Temaki mark the admin chrome uses. See docs/internal/admin-design-system.md.
|
|
5
|
-
|
|
6
|
-
/** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
|
|
7
|
-
export function escapeHtml(value: string): string {
|
|
8
|
-
return value
|
|
9
|
-
.replace(/&/g, '&')
|
|
10
|
-
.replace(/</g, '<')
|
|
11
|
-
.replace(/>/g, '>')
|
|
12
|
-
.replace(/"/g, '"');
|
|
13
|
-
}
|
|
5
|
+
import { escapeHtml } from '../escape.js';
|
|
14
6
|
|
|
15
7
|
// The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
|
|
16
8
|
const CAIRN_GLYPH =
|
|
@@ -16,16 +16,25 @@ export interface CookieJar {
|
|
|
16
16
|
delete(name: string, opts: { path: string }): void;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
/** The Cloudflare platform wrapper an event carries; `context` is the legacy alias for `ctx`. */
|
|
20
|
+
export interface PlatformContext<Env> {
|
|
21
|
+
env?: Env;
|
|
22
|
+
ctx?: { waitUntil(promise: Promise<unknown>): void };
|
|
23
|
+
context?: { waitUntil(promise: Promise<unknown>): void };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The structural core every engine event type extends, parameterized by the Worker env the
|
|
27
|
+
* surface reads. Each shared field is defined once here; the extensions add only what their
|
|
28
|
+
* surface needs (cookies, params, setHeaders). */
|
|
29
|
+
export interface EventBase<Env> {
|
|
20
30
|
url: URL;
|
|
21
31
|
request: Request;
|
|
22
|
-
cookies: CookieJar;
|
|
23
32
|
locals: { editor?: Editor | null };
|
|
24
|
-
platform?:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
33
|
+
platform?: PlatformContext<Env>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RequestContext extends EventBase<AuthEnv> {
|
|
37
|
+
cookies: CookieJar;
|
|
29
38
|
// Required so a site cannot silently drop the confirm page's Referrer-Policy header
|
|
30
39
|
// (spec 7.1). A real SvelteKit RequestEvent always supplies it.
|
|
31
40
|
setHeaders(headers: Record<string, string>): void;
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/** A page of items plus its navigation state. */
|
|
2
|
-
export interface Page<T> {
|
|
3
|
-
items: T[];
|
|
4
|
-
page: number;
|
|
5
|
-
perPage: number;
|
|
6
|
-
total: number;
|
|
7
|
-
totalPages: number;
|
|
8
|
-
hasPrev: boolean;
|
|
9
|
-
hasNext: boolean;
|
|
10
|
-
}
|
|
11
|
-
/** Slice `items` into the 1-based `page` of size `perPage`, clamping the page into bounds. */
|
|
12
|
-
export declare function paginate<T>(items: T[], page: number, perPage: number): Page<T>;
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
// cairn-cms: pagination helper (public-delivery design). Pure slice math; the template renders
|
|
2
|
-
// the controls. An out-of-range page clamps into bounds.
|
|
3
|
-
/** Slice `items` into the 1-based `page` of size `perPage`, clamping the page into bounds. */
|
|
4
|
-
export function paginate(items, page, perPage) {
|
|
5
|
-
const total = items.length;
|
|
6
|
-
// A non-positive page size would make totalPages Infinity, so clamp it to one.
|
|
7
|
-
const size = Math.max(1, Math.floor(perPage) || 1);
|
|
8
|
-
const totalPages = Math.max(1, Math.ceil(total / size));
|
|
9
|
-
const current = Math.min(Math.max(1, Math.floor(page) || 1), totalPages);
|
|
10
|
-
const start = (current - 1) * size;
|
|
11
|
-
return {
|
|
12
|
-
items: items.slice(start, start + size),
|
|
13
|
-
page: current,
|
|
14
|
-
perPage: size,
|
|
15
|
-
total,
|
|
16
|
-
totalPages,
|
|
17
|
-
hasPrev: current > 1,
|
|
18
|
-
hasNext: current < totalPages,
|
|
19
|
-
};
|
|
20
|
-
}
|
package/dist/render/index.d.ts
DELETED
package/dist/render/index.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// cairn-cms render engine: a directive-driven markdown to HTML pipeline whose
|
|
2
|
-
// component vocabulary is supplied by a site's component registry. The site owns the
|
|
3
|
-
// component builders, class names, icon set, and CSS; the engine owns the machinery.
|
|
4
|
-
export * from './registry.js';
|
|
5
|
-
export * from './glyph.js';
|
|
6
|
-
export * from './remark-directives.js';
|
|
7
|
-
export * from './rehype-dispatch.js';
|
|
8
|
-
export * from './pipeline.js';
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
// cairn-cms: pagination helper (public-delivery design). Pure slice math; the template renders
|
|
2
|
-
// the controls. An out-of-range page clamps into bounds.
|
|
3
|
-
|
|
4
|
-
/** A page of items plus its navigation state. */
|
|
5
|
-
export interface Page<T> {
|
|
6
|
-
items: T[];
|
|
7
|
-
page: number;
|
|
8
|
-
perPage: number;
|
|
9
|
-
total: number;
|
|
10
|
-
totalPages: number;
|
|
11
|
-
hasPrev: boolean;
|
|
12
|
-
hasNext: boolean;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Slice `items` into the 1-based `page` of size `perPage`, clamping the page into bounds. */
|
|
16
|
-
export function paginate<T>(items: T[], page: number, perPage: number): Page<T> {
|
|
17
|
-
const total = items.length;
|
|
18
|
-
// A non-positive page size would make totalPages Infinity, so clamp it to one.
|
|
19
|
-
const size = Math.max(1, Math.floor(perPage) || 1);
|
|
20
|
-
const totalPages = Math.max(1, Math.ceil(total / size));
|
|
21
|
-
const current = Math.min(Math.max(1, Math.floor(page) || 1), totalPages);
|
|
22
|
-
const start = (current - 1) * size;
|
|
23
|
-
return {
|
|
24
|
-
items: items.slice(start, start + size),
|
|
25
|
-
page: current,
|
|
26
|
-
perPage: size,
|
|
27
|
-
total,
|
|
28
|
-
totalPages,
|
|
29
|
-
hasPrev: current > 1,
|
|
30
|
-
hasNext: current < totalPages,
|
|
31
|
-
};
|
|
32
|
-
}
|
package/src/lib/render/index.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// cairn-cms render engine: a directive-driven markdown to HTML pipeline whose
|
|
2
|
-
// component vocabulary is supplied by a site's component registry. The site owns the
|
|
3
|
-
// component builders, class names, icon set, and CSS; the engine owns the machinery.
|
|
4
|
-
export * from './registry.js';
|
|
5
|
-
export * from './glyph.js';
|
|
6
|
-
export * from './remark-directives.js';
|
|
7
|
-
export * from './rehype-dispatch.js';
|
|
8
|
-
export * from './pipeline.js';
|