@glw907/cairn-cms 0.41.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 +42 -0
- package/README.md +2 -2
- 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 +4 -5
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +13 -9
- package/dist/components/EditPage.svelte.d.ts +4 -9
- 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/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/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/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 +59 -33
- 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 +5 -1
- 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 +4 -5
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +13 -9
- 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/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/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/types.ts +5 -0
- package/src/lib/log/events.ts +1 -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 +102 -45
- 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;
|
|
@@ -223,9 +250,37 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
223
250
|
}
|
|
224
251
|
}
|
|
225
252
|
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
*
|
|
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. */
|
|
229
284
|
async function listLoad(event: ContentEvent): Promise<ListData> {
|
|
230
285
|
sessionOf(event);
|
|
231
286
|
const concept = conceptOf(runtime, event.params);
|
|
@@ -240,8 +295,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
240
295
|
return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
|
|
241
296
|
}
|
|
242
297
|
try {
|
|
243
|
-
const [
|
|
244
|
-
|
|
298
|
+
const [manifestRaw, refs] = await Promise.all([
|
|
299
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
245
300
|
listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
|
|
246
301
|
]);
|
|
247
302
|
const pendingIds = new Set(
|
|
@@ -250,27 +305,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
250
305
|
return entry && entry.concept.id === concept.id ? [entry.id] : [];
|
|
251
306
|
}),
|
|
252
307
|
);
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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));
|
|
255
317
|
const entries = await Promise.all(
|
|
256
|
-
|
|
257
|
-
pendingIds.has(
|
|
258
|
-
?
|
|
259
|
-
:
|
|
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 },
|
|
260
322
|
),
|
|
261
323
|
);
|
|
262
|
-
|
|
263
|
-
// summarize already degrades a failed read to an id-only row.
|
|
264
|
-
const listed = new Set(files.map((f) => f.id));
|
|
324
|
+
const listed = new Set(rows.map((e) => e.id));
|
|
265
325
|
const newRows = await Promise.all(
|
|
266
|
-
[...pendingIds]
|
|
267
|
-
.filter((id) => !listed.has(id))
|
|
268
|
-
.map((id) =>
|
|
269
|
-
summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
|
|
270
|
-
...runtime.backend,
|
|
271
|
-
branch: pendingBranch(concept.id, id),
|
|
272
|
-
}),
|
|
273
|
-
),
|
|
326
|
+
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
|
|
274
327
|
);
|
|
275
328
|
return { ...base, entries: [...entries, ...newRows], error: null };
|
|
276
329
|
} catch {
|
|
@@ -390,11 +443,6 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
390
443
|
};
|
|
391
444
|
}
|
|
392
445
|
|
|
393
|
-
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
394
|
-
function isConflict(err: unknown): boolean {
|
|
395
|
-
return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
|
|
396
|
-
}
|
|
397
|
-
|
|
398
446
|
/** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
399
447
|
* reason; any other error is unexpected and logs at error with the stringified cause. Publish
|
|
400
448
|
* failures carry the same shape under their own event name. */
|
|
@@ -492,7 +540,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
492
540
|
else if (target.draft) draftLinks.push(formatCairnToken(ref));
|
|
493
541
|
}
|
|
494
542
|
if (absent.length) {
|
|
495
|
-
|
|
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);
|
|
496
549
|
}
|
|
497
550
|
|
|
498
551
|
// Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
|
|
@@ -705,7 +758,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
705
758
|
const manifest = await readManifest(token);
|
|
706
759
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
707
760
|
if (inbound.length) {
|
|
708
|
-
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);
|
|
709
766
|
}
|
|
710
767
|
|
|
711
768
|
// When the entry was never published (absent from main), the branch delete is the whole
|
|
@@ -780,20 +837,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
780
837
|
// Pending edits on the branch are keyed to the old id; renaming underneath them would strand
|
|
781
838
|
// them, so refuse until the editor publishes or discards.
|
|
782
839
|
if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
|
|
783
|
-
return fail(409, {
|
|
840
|
+
return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' } satisfies RenameFailure);
|
|
784
841
|
}
|
|
785
842
|
|
|
786
843
|
const form = await event.request.formData();
|
|
787
844
|
const newSlug = String(form.get('slug') ?? '').trim();
|
|
788
845
|
if (!isValidId(newSlug)) {
|
|
789
|
-
return fail(400, {
|
|
846
|
+
return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' } satisfies RenameFailure);
|
|
790
847
|
}
|
|
791
848
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
792
849
|
if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
|
|
793
|
-
return fail(400, {
|
|
850
|
+
return fail(400, { error: 'Leave the date out of the slug.' } satisfies RenameFailure);
|
|
794
851
|
}
|
|
795
852
|
if (newSlug === slugFromId(id, datePrefix)) {
|
|
796
|
-
return fail(400, {
|
|
853
|
+
return fail(400, { error: 'That is already the slug.' } satisfies RenameFailure);
|
|
797
854
|
}
|
|
798
855
|
const newId = renameId(id, newSlug, datePrefix);
|
|
799
856
|
const oldPath = `${concept.dir}/${filenameFromId(id)}`;
|
|
@@ -804,7 +861,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
804
861
|
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
805
862
|
const clobber = await readRaw(runtime.backend, newPath, token);
|
|
806
863
|
if (clobber !== null) {
|
|
807
|
-
return fail(409, {
|
|
864
|
+
return fail(409, { error: 'An entry with that slug already exists.' } satisfies RenameFailure);
|
|
808
865
|
}
|
|
809
866
|
|
|
810
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';
|