@glw907/cairn-cms 0.41.0 → 0.51.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/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 +107 -25
- package/dist/components/EditPage.svelte.d.ts +8 -10
- package/dist/components/EditorToolbar.svelte +79 -8
- package/dist/components/EditorToolbar.svelte.d.ts +10 -2
- 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/MarkdownEditor.svelte +20 -2
- package/dist/components/cairn-admin.css +57 -9
- package/dist/components/editor-highlight.d.ts +1 -0
- package/dist/components/editor-highlight.js +31 -8
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +10 -0
- package/dist/components/markdown-directives.js +54 -1
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- package/dist/components/preview-doc.d.ts +27 -0
- package/dist/components/preview-doc.js +64 -0
- package/dist/content/compose.js +1 -0
- package/dist/content/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +35 -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.js +24 -0
- package/dist/doctor/bin.js +30 -12
- package/dist/doctor/check-floors.d.ts +15 -0
- package/dist/doctor/check-floors.js +107 -0
- package/dist/doctor/check-probe.d.ts +3 -0
- package/dist/doctor/check-probe.js +123 -0
- package/dist/doctor/checks-github.js +1 -1
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +28 -2
- package/dist/doctor/cloudflare-api.js +2 -2
- package/dist/doctor/index.d.ts +28 -3
- package/dist/doctor/index.js +47 -6
- package/dist/doctor/types.d.ts +2 -0
- package/dist/doctor/wrangler-config.d.ts +4 -0
- package/dist/doctor/wrangler-config.js +11 -0
- package/dist/email.js +4 -11
- package/dist/env.d.ts +3 -2
- package/dist/env.js +12 -6
- 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/index.d.ts +1 -1
- 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 +39 -15
- package/dist/sveltekit/content-routes.js +84 -50
- package/dist/sveltekit/guard.d.ts +8 -2
- package/dist/sveltekit/guard.js +18 -4
- 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 +22 -19
- 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/dist/vite/index.d.ts +16 -0
- package/dist/vite/index.js +57 -13
- package/package.json +6 -2
- 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 +107 -25
- package/src/lib/components/EditorToolbar.svelte +79 -8
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- package/src/lib/components/MarkdownEditor.svelte +20 -2
- package/src/lib/components/cairn-admin.css +59 -0
- package/src/lib/components/editor-highlight.ts +32 -7
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +51 -1
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/components/preview-doc.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +34 -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 +24 -0
- package/src/lib/doctor/bin.ts +35 -10
- package/src/lib/doctor/check-floors.ts +124 -0
- package/src/lib/doctor/check-probe.ts +138 -0
- package/src/lib/doctor/checks-github.ts +3 -1
- package/src/lib/doctor/checks-local.ts +28 -2
- package/src/lib/doctor/cloudflare-api.ts +4 -2
- package/src/lib/doctor/index.ts +67 -6
- package/src/lib/doctor/types.ts +2 -0
- package/src/lib/doctor/wrangler-config.ts +11 -0
- package/src/lib/email.ts +4 -11
- package/src/lib/env.ts +12 -6
- 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/index.ts +2 -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 +131 -62
- package/src/lib/sveltekit/guard.ts +20 -5
- 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 +24 -21
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- package/src/lib/vite/index.ts +71 -17
- 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,25 +4,31 @@
|
|
|
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 } from '../github/credentials.js';
|
|
12
11
|
import { listMarkdown, readRaw, commitFiles } 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 } 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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
import { requireSession } from './guard.js';
|
|
20
|
+
/** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
|
|
21
|
+
* nullish coalescing so an override key that is present but undefined keeps the top-level value.
|
|
22
|
+
* Stylesheets are always shared, and the `byConcept` map never reaches the client. */
|
|
23
|
+
function resolvePreview(preview, conceptId) {
|
|
24
|
+
if (!preview)
|
|
25
|
+
return null;
|
|
26
|
+
const override = preview.byConcept?.[conceptId];
|
|
27
|
+
return {
|
|
28
|
+
stylesheets: preview.stylesheets,
|
|
29
|
+
bodyClass: override?.bodyClass ?? preview.bodyClass,
|
|
30
|
+
containerClass: override?.containerClass ?? preview.containerClass,
|
|
31
|
+
};
|
|
26
32
|
}
|
|
27
33
|
/** Look up the concept named by the `[concept]` route param, or a 404. */
|
|
28
34
|
function conceptOf(runtime, params) {
|
|
@@ -54,7 +60,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
54
60
|
/** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
55
61
|
* and the pending entries behind the topbar's publish-all action. */
|
|
56
62
|
async function layoutLoad(event) {
|
|
57
|
-
const editor =
|
|
63
|
+
const editor = requireSession(event);
|
|
58
64
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
59
65
|
const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
60
66
|
const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
|
|
@@ -112,11 +118,33 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
112
118
|
return { id: file.id, title: file.id, date: null, draft: false, status };
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
|
-
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
121
|
+
/** Read an entry's list row from its pending branch, so a pending title or draft change shows
|
|
122
|
+
* in the list instead of reading as a lost save. summarize degrades a failed or empty read to
|
|
123
|
+
* an id-only row, so a ghost ref still lists. */
|
|
124
|
+
function pendingRow(concept, id, status, token) {
|
|
125
|
+
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
|
|
126
|
+
...runtime.backend,
|
|
127
|
+
branch: pendingBranch(concept.id, id),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
|
|
131
|
+
* and read each one for its row, with edited and new rows reading branch-first. */
|
|
132
|
+
async function crawlEntries(concept, pendingIds, token) {
|
|
133
|
+
const files = await listMarkdown(runtime.backend, concept.dir, token);
|
|
134
|
+
const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))));
|
|
135
|
+
// A ref with no main file is a never-published entry; its row reads from its branch.
|
|
136
|
+
const listed = new Set(files.map((f) => f.id));
|
|
137
|
+
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
|
|
138
|
+
return [...entries, ...newRows];
|
|
139
|
+
}
|
|
140
|
+
/** List a concept's entries with their publish status. Published rows project straight from
|
|
141
|
+
* main's manifest, which publish, delete, and rename keep atomically in sync with main, so
|
|
142
|
+
* the listing costs one manifest read plus one branch read per pending entry rather than one
|
|
143
|
+
* read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
|
|
144
|
+
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
145
|
+
* to an inline error, not a thrown 500. */
|
|
118
146
|
async function listLoad(event) {
|
|
119
|
-
|
|
147
|
+
requireSession(event);
|
|
120
148
|
const concept = conceptOf(runtime, event.params);
|
|
121
149
|
const formError = event.url.searchParams.get('error');
|
|
122
150
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
@@ -130,28 +158,28 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
130
158
|
return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
|
|
131
159
|
}
|
|
132
160
|
try {
|
|
133
|
-
const [
|
|
134
|
-
|
|
161
|
+
const [manifestRaw, refs] = await Promise.all([
|
|
162
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
135
163
|
listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
|
|
136
164
|
]);
|
|
137
165
|
const pendingIds = new Set(refs.flatMap((name) => {
|
|
138
166
|
const entry = pendingEntryOf(name);
|
|
139
167
|
return entry && entry.concept.id === concept.id ? [entry.id] : [];
|
|
140
168
|
}));
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
169
|
+
// A repo with no committed manifest yet (a fresh site before its first publish) falls back
|
|
170
|
+
// to the crawl; a manifest that parses but is empty is trusted as-is.
|
|
171
|
+
if (manifestRaw === null) {
|
|
172
|
+
return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
|
|
173
|
+
}
|
|
174
|
+
// Newest id first, the same order the crawl's file listing produced.
|
|
175
|
+
const rows = parseManifest(manifestRaw)
|
|
176
|
+
.entries.filter((e) => e.concept === concept.id)
|
|
177
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
178
|
+
const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
|
|
179
|
+
? pendingRow(concept, e.id, 'edited', token)
|
|
180
|
+
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' }));
|
|
181
|
+
const listed = new Set(rows.map((e) => e.id));
|
|
182
|
+
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
|
|
155
183
|
return { ...base, entries: [...entries, ...newRows], error: null };
|
|
156
184
|
}
|
|
157
185
|
catch {
|
|
@@ -160,7 +188,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
160
188
|
}
|
|
161
189
|
/** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
|
|
162
190
|
async function createAction(event) {
|
|
163
|
-
|
|
191
|
+
requireSession(event);
|
|
164
192
|
const concept = conceptOf(runtime, event.params);
|
|
165
193
|
const form = await event.request.formData();
|
|
166
194
|
const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
|
|
@@ -207,7 +235,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
207
235
|
}
|
|
208
236
|
/** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
|
|
209
237
|
async function editLoad(event) {
|
|
210
|
-
|
|
238
|
+
requireSession(event);
|
|
211
239
|
const concept = conceptOf(runtime, event.params);
|
|
212
240
|
const id = event.params.id ?? '';
|
|
213
241
|
if (!isValidId(id))
|
|
@@ -268,12 +296,9 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
268
296
|
published,
|
|
269
297
|
publishedFlash: event.url.searchParams.get('published') === '1',
|
|
270
298
|
discardedFlash: event.url.searchParams.get('discarded') === '1',
|
|
299
|
+
preview: resolvePreview(runtime.preview, concept.id),
|
|
271
300
|
};
|
|
272
301
|
}
|
|
273
|
-
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
274
|
-
function isConflict(err) {
|
|
275
|
-
return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
|
|
276
|
-
}
|
|
277
302
|
/** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
278
303
|
* reason; any other error is unexpected and logs at error with the stringified cause. Publish
|
|
279
304
|
* failures carry the same shape under their own event name. */
|
|
@@ -338,7 +363,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
338
363
|
draftLinks.push(formatCairnToken(ref));
|
|
339
364
|
}
|
|
340
365
|
if (absent.length) {
|
|
341
|
-
|
|
366
|
+
const noun = absent.length === 1 ? 'page' : 'pages';
|
|
367
|
+
return fail(400, {
|
|
368
|
+
error: `This page links to ${absent.length} missing ${noun}.`,
|
|
369
|
+
brokenLinks: absent,
|
|
370
|
+
body,
|
|
371
|
+
});
|
|
342
372
|
}
|
|
343
373
|
// Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
|
|
344
374
|
// commit only the entry file there. Main stays untouched until publish, so the branch differs
|
|
@@ -364,7 +394,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
364
394
|
/** Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
365
395
|
* as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
|
|
366
396
|
async function saveAction(event) {
|
|
367
|
-
const editor =
|
|
397
|
+
const editor = requireSession(event);
|
|
368
398
|
const concept = conceptOf(runtime, event.params);
|
|
369
399
|
const id = event.params.id ?? '';
|
|
370
400
|
// Confine the commit path to the concept dir, built from a validated id (the App token can
|
|
@@ -386,7 +416,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
386
416
|
* The branch is deleted only when its head still matches the commit this action made; a
|
|
387
417
|
* concurrent save moved it, so the entry stays pending and the next publish picks it up. */
|
|
388
418
|
async function publishAction(event) {
|
|
389
|
-
const editor =
|
|
419
|
+
const editor = requireSession(event);
|
|
390
420
|
const concept = conceptOf(runtime, event.params);
|
|
391
421
|
const id = event.params.id ?? '';
|
|
392
422
|
if (!isValidId(id))
|
|
@@ -420,7 +450,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
420
450
|
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
421
451
|
* concept param is ignored and the redirect lands on the first configured concept. */
|
|
422
452
|
async function publishAllAction(event) {
|
|
423
|
-
const editor =
|
|
453
|
+
const editor = requireSession(event);
|
|
424
454
|
const first = runtime.concepts[0];
|
|
425
455
|
if (!first)
|
|
426
456
|
throw error(404, 'No content types configured');
|
|
@@ -499,7 +529,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
499
529
|
/** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
500
530
|
* the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
|
|
501
531
|
async function discardAction(event) {
|
|
502
|
-
const editor =
|
|
532
|
+
const editor = requireSession(event);
|
|
503
533
|
const concept = conceptOf(runtime, event.params);
|
|
504
534
|
const id = event.params.id ?? '';
|
|
505
535
|
if (!isValidId(id))
|
|
@@ -525,7 +555,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
525
555
|
const manifest = await readManifest(token);
|
|
526
556
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
527
557
|
if (inbound.length) {
|
|
528
|
-
return fail(409, {
|
|
558
|
+
return fail(409, {
|
|
559
|
+
error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
|
|
560
|
+
inboundLinks: inbound,
|
|
561
|
+
id,
|
|
562
|
+
});
|
|
529
563
|
}
|
|
530
564
|
// When the entry was never published (absent from main), the branch delete is the whole
|
|
531
565
|
// operation; main has nothing to commit, so the only honest log record is the discard of
|
|
@@ -562,7 +596,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
562
596
|
}
|
|
563
597
|
/** Delete an entry from its editor. The id comes from the route param. */
|
|
564
598
|
async function deleteAction(event) {
|
|
565
|
-
const editor =
|
|
599
|
+
const editor = requireSession(event);
|
|
566
600
|
const concept = conceptOf(runtime, event.params);
|
|
567
601
|
const id = event.params.id ?? '';
|
|
568
602
|
if (!isValidId(id))
|
|
@@ -571,7 +605,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
571
605
|
}
|
|
572
606
|
/** Delete an entry from the concept list. The id comes from the form body. */
|
|
573
607
|
async function listDeleteAction(event) {
|
|
574
|
-
const editor =
|
|
608
|
+
const editor = requireSession(event);
|
|
575
609
|
const concept = conceptOf(runtime, event.params);
|
|
576
610
|
const form = await event.request.formData();
|
|
577
611
|
const id = String(form.get('id') ?? '');
|
|
@@ -584,7 +618,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
584
618
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
585
619
|
* caught by the build's fail-closed backstop. */
|
|
586
620
|
async function renameAction(event) {
|
|
587
|
-
const editor =
|
|
621
|
+
const editor = requireSession(event);
|
|
588
622
|
const concept = conceptOf(runtime, event.params);
|
|
589
623
|
const id = event.params.id ?? '';
|
|
590
624
|
if (!isValidId(id))
|
|
@@ -593,19 +627,19 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
593
627
|
// Pending edits on the branch are keyed to the old id; renaming underneath them would strand
|
|
594
628
|
// them, so refuse until the editor publishes or discards.
|
|
595
629
|
if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
|
|
596
|
-
return fail(409, {
|
|
630
|
+
return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' });
|
|
597
631
|
}
|
|
598
632
|
const form = await event.request.formData();
|
|
599
633
|
const newSlug = String(form.get('slug') ?? '').trim();
|
|
600
634
|
if (!isValidId(newSlug)) {
|
|
601
|
-
return fail(400, {
|
|
635
|
+
return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
|
|
602
636
|
}
|
|
603
637
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
604
638
|
if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
|
|
605
|
-
return fail(400, {
|
|
639
|
+
return fail(400, { error: 'Leave the date out of the slug.' });
|
|
606
640
|
}
|
|
607
641
|
if (newSlug === slugFromId(id, datePrefix)) {
|
|
608
|
-
return fail(400, {
|
|
642
|
+
return fail(400, { error: 'That is already the slug.' });
|
|
609
643
|
}
|
|
610
644
|
const newId = renameId(id, newSlug, datePrefix);
|
|
611
645
|
const oldPath = `${concept.dir}/${filenameFromId(id)}`;
|
|
@@ -615,7 +649,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
615
649
|
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
616
650
|
const clobber = await readRaw(runtime.backend, newPath, token);
|
|
617
651
|
if (clobber !== null) {
|
|
618
|
-
return fail(409, {
|
|
652
|
+
return fail(409, { error: 'An entry with that slug already exists.' });
|
|
619
653
|
}
|
|
620
654
|
const [entryRaw, manifest] = await Promise.all([
|
|
621
655
|
readRaw(runtime.backend, oldPath, token),
|
|
@@ -2,7 +2,13 @@ import type { Editor } from '../auth/types.js';
|
|
|
2
2
|
import type { HandleInput, RequestContext } from './types.js';
|
|
3
3
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
4
4
|
export declare function createAuthGuard(): ({ event, resolve }: HandleInput) => Promise<Response>;
|
|
5
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
6
|
-
|
|
5
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
6
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
7
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
8
|
+
export declare function requireSession(event: {
|
|
9
|
+
locals: {
|
|
10
|
+
editor?: Editor | null;
|
|
11
|
+
};
|
|
12
|
+
}): Editor;
|
|
7
13
|
/** For the management surface: a signed-in owner, or 403 for an editor. */
|
|
8
14
|
export declare function requireOwner(event: RequestContext): Editor;
|
package/dist/sveltekit/guard.js
CHANGED
|
@@ -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
|
/** The login page and the auth endpoints are public; everything else under /admin is gated. */
|
|
12
12
|
function isPublicAdminPath(pathname) {
|
|
@@ -49,6 +49,19 @@ export function createAuthGuard() {
|
|
|
49
49
|
log.warn('guard.rejected', { reason: 'https', path: pathname });
|
|
50
50
|
return renderConditionResponse('edge.https-not-forced', { url: event.url });
|
|
51
51
|
}
|
|
52
|
+
// No auth store binding means no admin path can work: the gated views cannot resolve a
|
|
53
|
+
// session, and a login or confirm POST would die in its action with a raw 500. That is an
|
|
54
|
+
// operator fault, not a sign-in problem, so name the condition on every admin path, the
|
|
55
|
+
// public ones included, instead of rendering a login form that can never succeed.
|
|
56
|
+
const env = event.platform?.env ?? {};
|
|
57
|
+
if (!env.AUTH_DB) {
|
|
58
|
+
log.error('guard.rejected', {
|
|
59
|
+
reason: 'bindings',
|
|
60
|
+
conditionId: REASON_CONDITION.bindings,
|
|
61
|
+
path: pathname,
|
|
62
|
+
});
|
|
63
|
+
return renderConditionResponse(REASON_CONDITION.bindings);
|
|
64
|
+
}
|
|
52
65
|
// Rule 1 - admin: every unsafe form POST carries a valid double-submit token, else the branded
|
|
53
66
|
// 403 before resolve() runs. This covers the public login/auth posts too.
|
|
54
67
|
if (isUnsafeFormRequest(event.request) && !(await validateCsrfToken(event))) {
|
|
@@ -56,9 +69,8 @@ export function createAuthGuard() {
|
|
|
56
69
|
return renderConditionResponse('auth.csrf-token-invalid');
|
|
57
70
|
}
|
|
58
71
|
if (!isPublicAdminPath(pathname)) {
|
|
59
|
-
const env = event.platform?.env ?? {};
|
|
60
72
|
const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
|
|
61
|
-
const editor = id
|
|
73
|
+
const editor = id ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
|
|
62
74
|
if (!editor)
|
|
63
75
|
throw redirect(303, '/admin/login');
|
|
64
76
|
event.locals.editor = editor;
|
|
@@ -68,7 +80,9 @@ export function createAuthGuard() {
|
|
|
68
80
|
return response;
|
|
69
81
|
};
|
|
70
82
|
}
|
|
71
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
83
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
84
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
85
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
72
86
|
export function requireSession(event) {
|
|
73
87
|
const editor = event.locals.editor;
|
|
74
88
|
if (!editor)
|
|
@@ -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
|
* Render the full HTML document for the HTTPS-required page.
|
|
10
11
|
* @param httpsUrl The same request rebuilt over https, offered as the one-click recovery link.
|
|
@@ -2,9 +2,11 @@ export { createAuthGuard, requireSession, requireOwner } from './guard.js';
|
|
|
2
2
|
export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './auth-routes.js';
|
|
3
3
|
export { createEditorRoutes } from './editors-routes.js';
|
|
4
4
|
export { createContentRoutes } from './content-routes.js';
|
|
5
|
-
export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, ContentEvent, ContentRoutesDeps, } from './content-routes.js';
|
|
5
|
+
export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, ContentFormFailure, } from './content-routes.js';
|
|
6
6
|
export { createNavRoutes } from './nav-routes.js';
|
|
7
7
|
export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
|
|
8
|
+
export { parseAdminPath, type AdminView } from './admin-dispatch.js';
|
|
9
|
+
export { createCairnAdmin, type CairnAdminDeps, type AdminData } from './cairn-admin.js';
|
|
8
10
|
export { healthLoad, type HealthData } from './health.js';
|
|
9
11
|
export type { RequestContext, CookieJar, HandleInput } from './types.js';
|
|
10
12
|
export type { GithubKeyEnv } from '../github/credentials.js';
|
package/dist/sveltekit/index.js
CHANGED
|
@@ -5,4 +5,6 @@ export { createAuthRoutes } from './auth-routes.js';
|
|
|
5
5
|
export { createEditorRoutes } from './editors-routes.js';
|
|
6
6
|
export { createContentRoutes } from './content-routes.js';
|
|
7
7
|
export { createNavRoutes } from './nav-routes.js';
|
|
8
|
+
export { parseAdminPath } from './admin-dispatch.js';
|
|
9
|
+
export { createCairnAdmin } from './cairn-admin.js';
|
|
8
10
|
export { healthLoad } from './health.js';
|
|
@@ -21,7 +21,9 @@ export interface NavLoadData {
|
|
|
21
21
|
}
|
|
22
22
|
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
23
23
|
export interface NavRoutesDeps {
|
|
24
|
-
|
|
24
|
+
/** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
|
|
25
|
+
* A bare string works too; the routes await whatever comes back. */
|
|
26
|
+
mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
|
|
25
27
|
}
|
|
26
28
|
export declare function createNavRoutes(runtime: CairnRuntime, deps?: NavRoutesDeps): {
|
|
27
29
|
navLoad: (event: ContentEvent) => Promise<NavLoadData>;
|
|
@@ -5,20 +5,10 @@ import { redirect, error } from '@sveltejs/kit';
|
|
|
5
5
|
import { appCredentials } 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 } from '../nav/site-config.js';
|
|
11
|
-
|
|
12
|
-
function sessionOf(event) {
|
|
13
|
-
const editor = event.locals.editor;
|
|
14
|
-
if (!editor)
|
|
15
|
-
throw redirect(303, '/admin/login');
|
|
16
|
-
return editor;
|
|
17
|
-
}
|
|
18
|
-
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
19
|
-
function isConflict(err) {
|
|
20
|
-
return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
|
|
21
|
-
}
|
|
11
|
+
import { requireSession } from './guard.js';
|
|
22
12
|
export function createNavRoutes(runtime, deps = {}) {
|
|
23
13
|
const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
24
14
|
/** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
|
|
@@ -37,7 +27,7 @@ export function createNavRoutes(runtime, deps = {}) {
|
|
|
37
27
|
}
|
|
38
28
|
/** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
|
|
39
29
|
async function navLoad(event) {
|
|
40
|
-
|
|
30
|
+
requireSession(event);
|
|
41
31
|
const config = runtime.navMenu;
|
|
42
32
|
if (!config)
|
|
43
33
|
throw error(404, 'No navigation menu configured');
|
|
@@ -51,14 +41,27 @@ export function createNavRoutes(runtime, deps = {}) {
|
|
|
51
41
|
return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
|
|
52
42
|
}
|
|
53
43
|
let tree = [];
|
|
44
|
+
let raw = null;
|
|
54
45
|
try {
|
|
55
|
-
|
|
56
|
-
if (raw !== null)
|
|
57
|
-
tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
|
|
46
|
+
raw = await readRaw(runtime.backend, config.configPath, token);
|
|
58
47
|
}
|
|
59
48
|
catch {
|
|
60
|
-
//
|
|
61
|
-
|
|
49
|
+
// An unreadable config degrades to an empty tree; the first save writes a clean menu.
|
|
50
|
+
raw = null;
|
|
51
|
+
}
|
|
52
|
+
if (raw !== null) {
|
|
53
|
+
try {
|
|
54
|
+
tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// A malformed config keeps the same degrade (the nav page failing closed would be worse
|
|
58
|
+
// for the editor), but the swallow names the operator fault in the log.
|
|
59
|
+
log.error('config.invalid', {
|
|
60
|
+
conditionId: 'config.site-config-invalid',
|
|
61
|
+
error: String(err),
|
|
62
|
+
});
|
|
63
|
+
tree = [];
|
|
64
|
+
}
|
|
62
65
|
}
|
|
63
66
|
return {
|
|
64
67
|
menu,
|
|
@@ -70,7 +73,7 @@ export function createNavRoutes(runtime, deps = {}) {
|
|
|
70
73
|
}
|
|
71
74
|
/** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
|
|
72
75
|
async function navSave(event) {
|
|
73
|
-
const editor =
|
|
76
|
+
const editor = requireSession(event);
|
|
74
77
|
const config = runtime.navMenu;
|
|
75
78
|
if (!config)
|
|
76
79
|
throw error(404, 'No navigation menu configured');
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
/** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
|
|
2
|
-
export declare function escapeHtml(value: string): string;
|
|
3
1
|
/**
|
|
4
2
|
* Render a full self-contained admin page document. The caller supplies trusted inner HTML
|
|
5
3
|
* (eyebrow, heading, copy, CTA); the helper owns the head, the inlined style, the brand tile,
|
|
@@ -2,14 +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
|
-
export function escapeHtml(value) {
|
|
7
|
-
return value
|
|
8
|
-
.replace(/&/g, '&')
|
|
9
|
-
.replace(/</g, '<')
|
|
10
|
-
.replace(/>/g, '>')
|
|
11
|
-
.replace(/"/g, '"');
|
|
12
|
-
}
|
|
5
|
+
import { escapeHtml } from '../escape.js';
|
|
13
6
|
// The cairn stone-stack glyph (Temaki, CC0), drawn in currentColor like CairnLogo.svelte.
|
|
14
7
|
const CAIRN_GLYPH = '<path d="M6.28 14C5.56 14 1 13.89 1 12.91C1 11.46 2.16 11.07 3.2 10.81C4.36 10.51 13.18 9.77 ' +
|
|
15
8
|
'13.76 10.07C14.46 10.43 13.52 12.49 12.44 12.77C11.28 13.07 10.21 14 8.48 14C7.05 14 9.69 14 ' +
|
|
@@ -13,22 +13,29 @@ export interface CookieJar {
|
|
|
13
13
|
path: string;
|
|
14
14
|
}): void;
|
|
15
15
|
}
|
|
16
|
-
|
|
16
|
+
/** The Cloudflare platform wrapper an event carries; `context` is the legacy alias for `ctx`. */
|
|
17
|
+
export interface PlatformContext<Env> {
|
|
18
|
+
env?: Env;
|
|
19
|
+
ctx?: {
|
|
20
|
+
waitUntil(promise: Promise<unknown>): void;
|
|
21
|
+
};
|
|
22
|
+
context?: {
|
|
23
|
+
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> {
|
|
17
30
|
url: URL;
|
|
18
31
|
request: Request;
|
|
19
|
-
cookies: CookieJar;
|
|
20
32
|
locals: {
|
|
21
33
|
editor?: Editor | null;
|
|
22
34
|
};
|
|
23
|
-
platform?:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
};
|
|
28
|
-
context?: {
|
|
29
|
-
waitUntil(promise: Promise<unknown>): void;
|
|
30
|
-
};
|
|
31
|
-
};
|
|
35
|
+
platform?: PlatformContext<Env>;
|
|
36
|
+
}
|
|
37
|
+
export interface RequestContext extends EventBase<AuthEnv> {
|
|
38
|
+
cookies: CookieJar;
|
|
32
39
|
setHeaders(headers: Record<string, string>): void;
|
|
33
40
|
}
|
|
34
41
|
export interface HandleInput {
|
package/dist/vite/index.d.ts
CHANGED
|
@@ -30,3 +30,19 @@ export declare function cairnManifest(opts: CairnManifestOptions): Plugin;
|
|
|
30
30
|
* resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
|
|
31
31
|
* so the write logic is testable apart from the CLI shell. */
|
|
32
32
|
export declare function writeManifest(cwd?: string): Promise<void>;
|
|
33
|
+
/** The repo and sender facts cairn-doctor derives off the consumer's adapter. */
|
|
34
|
+
export interface AdapterFacts {
|
|
35
|
+
/** `cairn.backend.owner`. */
|
|
36
|
+
owner?: string;
|
|
37
|
+
/** `cairn.backend.repo`. */
|
|
38
|
+
repo?: string;
|
|
39
|
+
/** `cairn.sender.from`. */
|
|
40
|
+
from?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
|
|
43
|
+
* through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
|
|
44
|
+
* cairn-doctor calls this to fill inputs the operator did not pass. Derivation is best-effort:
|
|
45
|
+
* any failure (no Vite config, no cairnManifest plugin, a config module that throws) returns
|
|
46
|
+
* null, so the doctor degrades to flags instead of crashing. This runs only on the bin path,
|
|
47
|
+
* never in a Worker. */
|
|
48
|
+
export declare function readAdapterFacts(cwd?: string): Promise<AdapterFacts | null>;
|