@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +2 -2
  3. package/dist/ambient.d.ts +9 -0
  4. package/dist/ambient.js +1 -0
  5. package/dist/components/AdminLayout.svelte +6 -8
  6. package/dist/components/CairnAdmin.svelte +67 -0
  7. package/dist/components/CairnAdmin.svelte.d.ts +35 -0
  8. package/dist/components/ConceptList.svelte +4 -5
  9. package/dist/components/ConceptList.svelte.d.ts +4 -8
  10. package/dist/components/ConfirmPage.svelte +1 -1
  11. package/dist/components/EditPage.svelte +13 -9
  12. package/dist/components/EditPage.svelte.d.ts +4 -9
  13. package/dist/components/LoginPage.svelte +2 -2
  14. package/dist/components/LoginPage.svelte.d.ts +1 -1
  15. package/dist/components/ManageEditors.svelte +4 -3
  16. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  17. package/dist/components/index.d.ts +1 -0
  18. package/dist/components/index.js +1 -0
  19. package/dist/components/markdown-format.d.ts +0 -8
  20. package/dist/components/markdown-format.js +0 -28
  21. package/dist/content/links.d.ts +8 -0
  22. package/dist/content/links.js +28 -0
  23. package/dist/content/types.d.ts +2 -2
  24. package/dist/delivery/data.d.ts +3 -5
  25. package/dist/delivery/data.js +2 -3
  26. package/dist/delivery/feeds.js +1 -7
  27. package/dist/delivery/index.d.ts +2 -2
  28. package/dist/delivery/index.js +1 -1
  29. package/dist/delivery/manifest.d.ts +0 -5
  30. package/dist/delivery/manifest.js +5 -16
  31. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  32. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  33. package/dist/delivery/site-indexes.d.ts +3 -3
  34. package/dist/delivery/site-indexes.js +3 -3
  35. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  36. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  37. package/dist/delivery/sitemap.js +1 -3
  38. package/dist/delivery/xml.d.ts +2 -0
  39. package/dist/delivery/xml.js +11 -0
  40. package/dist/email.js +4 -11
  41. package/dist/env.d.ts +1 -1
  42. package/dist/env.js +3 -2
  43. package/dist/escape.d.ts +2 -0
  44. package/dist/escape.js +11 -0
  45. package/dist/github/credentials.d.ts +2 -1
  46. package/dist/github/credentials.js +10 -2
  47. package/dist/github/types.d.ts +2 -0
  48. package/dist/github/types.js +4 -0
  49. package/dist/log/events.d.ts +1 -1
  50. package/dist/nav/site-config.d.ts +2 -0
  51. package/dist/nav/site-config.js +2 -0
  52. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  53. package/dist/sveltekit/admin-dispatch.js +62 -0
  54. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  55. package/dist/sveltekit/cairn-admin.js +126 -0
  56. package/dist/sveltekit/condition-response.d.ts +1 -0
  57. package/dist/sveltekit/condition-response.js +25 -0
  58. package/dist/sveltekit/content-routes.d.ts +34 -14
  59. package/dist/sveltekit/content-routes.js +59 -33
  60. package/dist/sveltekit/guard.js +15 -3
  61. package/dist/sveltekit/https-required-page.js +2 -1
  62. package/dist/sveltekit/index.d.ts +3 -1
  63. package/dist/sveltekit/index.js +2 -0
  64. package/dist/sveltekit/nav-routes.d.ts +3 -1
  65. package/dist/sveltekit/nav-routes.js +19 -10
  66. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  67. package/dist/sveltekit/static-admin-page.js +1 -8
  68. package/dist/sveltekit/types.d.ts +18 -11
  69. package/package.json +5 -1
  70. package/src/lib/ambient.ts +19 -0
  71. package/src/lib/components/AdminLayout.svelte +6 -8
  72. package/src/lib/components/CairnAdmin.svelte +67 -0
  73. package/src/lib/components/ConceptList.svelte +4 -5
  74. package/src/lib/components/ConfirmPage.svelte +1 -1
  75. package/src/lib/components/EditPage.svelte +13 -9
  76. package/src/lib/components/LoginPage.svelte +2 -2
  77. package/src/lib/components/ManageEditors.svelte +4 -3
  78. package/src/lib/components/index.ts +1 -0
  79. package/src/lib/components/markdown-format.ts +0 -27
  80. package/src/lib/content/links.ts +28 -0
  81. package/src/lib/content/types.ts +2 -2
  82. package/src/lib/delivery/data.ts +3 -5
  83. package/src/lib/delivery/feeds.ts +1 -8
  84. package/src/lib/delivery/index.ts +2 -2
  85. package/src/lib/delivery/manifest.ts +5 -18
  86. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  87. package/src/lib/delivery/site-indexes.ts +6 -6
  88. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  89. package/src/lib/delivery/sitemap.ts +1 -4
  90. package/src/lib/delivery/xml.ts +12 -0
  91. package/src/lib/email.ts +4 -11
  92. package/src/lib/env.ts +3 -2
  93. package/src/lib/escape.ts +12 -0
  94. package/src/lib/github/credentials.ts +6 -2
  95. package/src/lib/github/types.ts +5 -0
  96. package/src/lib/log/events.ts +1 -0
  97. package/src/lib/nav/site-config.ts +3 -0
  98. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  99. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  100. package/src/lib/sveltekit/condition-response.ts +27 -1
  101. package/src/lib/sveltekit/content-routes.ts +102 -45
  102. package/src/lib/sveltekit/guard.ts +16 -3
  103. package/src/lib/sveltekit/https-required-page.ts +2 -1
  104. package/src/lib/sveltekit/index.ts +6 -0
  105. package/src/lib/sveltekit/nav-routes.ts +21 -11
  106. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  107. package/src/lib/sveltekit/types.ts +16 -7
  108. package/dist/delivery/paginate.d.ts +0 -12
  109. package/dist/delivery/paginate.js +0 -20
  110. package/dist/render/index.d.ts +0 -5
  111. package/dist/render/index.js +0 -8
  112. package/src/lib/delivery/paginate.ts +0 -32
  113. package/src/lib/render/index.ts +0 -8
@@ -0,0 +1,126 @@
1
+ // The single-mount admin facade. One factory closes over the composed runtime, instantiates
2
+ // the existing per-surface route factories (auth, content, editors, nav), and serves every
3
+ // admin view through the one load and one actions record a site's catch-all /admin/[...path]
4
+ // route exports. The path authority is admin-dispatch's parseAdminPath; this module only maps
5
+ // each view to the wrapped load it delegates to, and each named action validates that the
6
+ // parsed view supports it before delegating to the same wrapped factories.
7
+ import { error } from '@sveltejs/kit';
8
+ import { parseAdminPath } from './admin-dispatch.js';
9
+ import { createAuthRoutes } from './auth-routes.js';
10
+ import { createContentRoutes, } from './content-routes.js';
11
+ import { createEditorRoutes } from './editors-routes.js';
12
+ import { createNavRoutes } from './nav-routes.js';
13
+ export function createCairnAdmin(runtime, deps = {}) {
14
+ // The runtime already composes the site name and the sender identity, so the magic-link
15
+ // branding needs no second copy of either unless a site overrides it.
16
+ const branding = deps.branding ?? {
17
+ siteName: runtime.siteName,
18
+ from: runtime.sender.from,
19
+ replyTo: runtime.sender.replyTo,
20
+ };
21
+ const auth = createAuthRoutes({ branding, send: deps.send });
22
+ const content = createContentRoutes(runtime, { mintToken: deps.mintToken });
23
+ const editors = createEditorRoutes();
24
+ // The nav surface exists only when the site configures a menu; without one its view is a 404.
25
+ const nav = runtime.navMenu ? createNavRoutes(runtime, { mintToken: deps.mintToken }) : null;
26
+ /** Build the event a wrapped content load reads. The catch-all route carries only a rest
27
+ * param, so `concept` and `id` are synthesized from the parsed view. The override names
28
+ * each field explicitly rather than spreading: a real RequestEvent's fields can sit behind
29
+ * getters a bare spread copies poorly, and the structural ContentEvent contract needs only
30
+ * these. */
31
+ function contentEvent(event, params) {
32
+ return {
33
+ url: event.url,
34
+ params,
35
+ request: event.request,
36
+ locals: event.locals,
37
+ platform: event.platform,
38
+ cookies: event.cookies,
39
+ };
40
+ }
41
+ /** Serve the admin view the pathname names, or a 404 for any shape the parser refuses.
42
+ * The authed views run the layout load and the view load concurrently; both mint a GitHub
43
+ * token, and the installation-token cache coalesces the mints into one signing. */
44
+ async function load(event) {
45
+ const view = parseAdminPath(event.url.pathname, runtime.concepts);
46
+ if (!view)
47
+ throw error(404, 'Not found');
48
+ switch (view.view) {
49
+ case 'index':
50
+ return content.indexRedirect();
51
+ case 'login':
52
+ return { view: 'login', page: auth.loginLoad(event) };
53
+ case 'confirm':
54
+ return { view: 'confirm', page: auth.confirmLoad(event) };
55
+ case 'list': {
56
+ const delegated = contentEvent(event, { concept: view.concept.id });
57
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.listLoad(delegated)]);
58
+ return { view: 'list', layout, page };
59
+ }
60
+ case 'edit': {
61
+ const delegated = contentEvent(event, { concept: view.concept.id, id: view.id });
62
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.editLoad(delegated)]);
63
+ return { view: 'edit', layout, page };
64
+ }
65
+ case 'editors': {
66
+ // editorsLoad gates itself with requireOwner, so the dispatcher adds no second gate.
67
+ const [layout, page] = await Promise.all([
68
+ content.layoutLoad(contentEvent(event, {})),
69
+ editors.editorsLoad(event),
70
+ ]);
71
+ return { view: 'editors', layout, page };
72
+ }
73
+ case 'nav': {
74
+ if (!nav)
75
+ throw error(404, 'Not found');
76
+ const delegated = contentEvent(event, {});
77
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), nav.navLoad(delegated)]);
78
+ return { view: 'nav', layout, page };
79
+ }
80
+ }
81
+ }
82
+ /** Wrap a delegate in the parse-and-check every action shares: parse the pathname exactly
83
+ * as load does, 404 on a null parse or a view outside the allowed set, then hand the
84
+ * narrowed view to the delegate. */
85
+ function viewAction(allowed, delegate) {
86
+ return async (event) => {
87
+ const view = parseAdminPath(event.url.pathname, runtime.concepts);
88
+ if (!view || !allowed.includes(view.view))
89
+ throw error(404, 'Not found');
90
+ // The includes check above proves the membership the cast asserts.
91
+ return delegate(event, view);
92
+ };
93
+ }
94
+ // The topbar posts publishAll from every authed admin page; login and confirm may not.
95
+ const authedViews = ['list', 'edit', 'editors', 'nav'];
96
+ // An editor signs out from wherever they are, so logout accepts any parsed view.
97
+ const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav'];
98
+ /** The full admin action vocabulary, one named async function per action, so a site's
99
+ * catch-all route exports `admin.actions` directly. Each wrapper stays thin: parse,
100
+ * validate the view, synthesize the params the wrapped action reads, delegate. The
101
+ * editor actions gate themselves with requireOwner, so no second gate is added here. */
102
+ const actions = {
103
+ request: viewAction(['login'], (event) => auth.requestAction(event)),
104
+ confirm: viewAction(['confirm'], (event) => auth.confirmAction(event)),
105
+ logout: viewAction(anyView, (event) => auth.logoutAction(event)),
106
+ create: viewAction(['list'], (event, view) => content.createAction(contentEvent(event, { concept: view.concept.id }))),
107
+ save: viewAction(['edit', 'nav'], (event, view) => {
108
+ if (view.view === 'edit')
109
+ return content.saveAction(contentEvent(event, { concept: view.concept.id, id: view.id }));
110
+ if (!nav)
111
+ throw error(404, 'Not found');
112
+ return nav.navSave(contentEvent(event, {}));
113
+ }),
114
+ publish: viewAction(['edit'], (event, view) => content.publishAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
115
+ discard: viewAction(['edit'], (event, view) => content.discardAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
116
+ rename: viewAction(['edit'], (event, view) => content.renameAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
117
+ delete: viewAction(['edit', 'list'], (event, view) => view.view === 'edit'
118
+ ? content.deleteAction(contentEvent(event, { concept: view.concept.id, id: view.id }))
119
+ : content.listDeleteAction(contentEvent(event, { concept: view.concept.id }))),
120
+ publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
121
+ addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
122
+ removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
123
+ setRole: viewAction(['editors'], (event) => editors.setRoleAction(event)),
124
+ };
125
+ return { load, actions };
126
+ }
@@ -3,6 +3,7 @@ export declare const REASON_CONDITION: {
3
3
  readonly https: "edge.https-not-forced";
4
4
  readonly csrf: "auth.csrf-token-invalid";
5
5
  readonly origin: "auth.csrf-origin-mismatch";
6
+ readonly bindings: "config.bindings-missing";
6
7
  };
7
8
  export type GuardReason = keyof typeof REASON_CONDITION;
8
9
  /** Render the Response the guard serves for a rejection, by its condition id. */
@@ -4,13 +4,35 @@
4
4
  import { brandedAdminPage } from './admin-response.js';
5
5
  import { httpsRequiredPage } from './https-required-page.js';
6
6
  import { csrfRequiredPage } from './csrf-required-page.js';
7
+ import { escapeHtml } from '../escape.js';
8
+ import { renderStaticAdminPage } from './static-admin-page.js';
7
9
  import { condition } from '../diagnostics/index.js';
8
10
  /** The guard.rejected reasons, each mapped to its registered condition id. */
9
11
  export const REASON_CONDITION = {
10
12
  https: 'edge.https-not-forced',
11
13
  csrf: 'auth.csrf-token-invalid',
12
14
  origin: 'auth.csrf-origin-mismatch',
15
+ bindings: 'config.bindings-missing',
13
16
  };
17
+ /**
18
+ * A branded page for an operator fault, built straight from the registered condition's fields so
19
+ * the served copy, the doctor's report, and the readiness checklist say the same thing.
20
+ */
21
+ function conditionFaultPage(cond) {
22
+ const inner = `
23
+ <span class="eyebrow">
24
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
25
+ Site setup required
26
+ </span>
27
+ <h1>${escapeHtml(cond.title)}</h1>
28
+ <p>${escapeHtml(cond.why)}</p>
29
+
30
+ <div class="fix">
31
+ <h2>If you run this site</h2>
32
+ <p>${escapeHtml(cond.remediation)}</p>
33
+ </div>`;
34
+ return renderStaticAdminPage({ title: `${cond.title} · Cairn`, innerHtml: inner });
35
+ }
14
36
  /** Render the Response the guard serves for a rejection, by its condition id. */
15
37
  export function renderConditionResponse(id, ctx = {}) {
16
38
  // Assert the id is registered before rendering, keeping the renderer in 1:1 with the registry.
@@ -28,6 +50,9 @@ export function renderConditionResponse(id, ctx = {}) {
28
50
  status: 403,
29
51
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
30
52
  });
53
+ case REASON_CONDITION.bindings:
54
+ // An operator fault, not a request fault: the Worker deployed without its bindings.
55
+ return brandedAdminPage(500, conditionFaultPage(condition(id)));
31
56
  default:
32
57
  throw new Error(`no runtime renderer for condition: ${id}`);
33
58
  }
@@ -1,9 +1,9 @@
1
1
  import { fail } from '@sveltejs/kit';
2
2
  import { type GithubKeyEnv } from '../github/credentials.js';
3
3
  import { type LinkTarget, type InboundLink } from '../content/manifest.js';
4
- import type { CookieJar } from './types.js';
4
+ import type { CookieJar, EventBase } from './types.js';
5
5
  import type { CairnRuntime, FrontmatterField } from '../content/types.js';
6
- import type { Editor, Role } from '../auth/types.js';
6
+ import type { Role } from '../auth/types.js';
7
7
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
8
8
  export interface NavConcept {
9
9
  id: string;
@@ -89,25 +89,45 @@ export interface EditData {
89
89
  discardedFlash: boolean;
90
90
  }
91
91
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
92
- export interface ContentEvent {
93
- url: URL;
92
+ export interface ContentEvent extends EventBase<GithubKeyEnv> {
94
93
  params: Record<string, string>;
95
- request: Request;
96
- locals: {
97
- editor?: Editor | null;
98
- };
99
- platform?: {
100
- env?: GithubKeyEnv;
101
- };
102
94
  /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
103
95
  * token. Optional for non-route callers. */
104
96
  cookies?: CookieJar;
105
97
  }
106
98
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
107
99
  export interface ContentRoutesDeps {
108
- /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer. */
109
- mintToken?: (env: GithubKeyEnv) => Promise<string>;
100
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
101
+ * A bare string works too; the routes await whatever comes back. */
102
+ mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
103
+ }
104
+ /** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
105
+ export interface SaveFailure {
106
+ /** The one-line human summary every content action failure carries. */
107
+ error: string;
108
+ /** The cairn tokens that resolve to no entry, for the editor's fix-it banner. */
109
+ brokenLinks: string[];
110
+ /** The author's edited markdown, so the editor reseeds with the unsaved work. */
111
+ body: string;
112
+ }
113
+ /** A refused delete: `fail(409)` while other entries still link to this one. */
114
+ export interface DeleteRefusal {
115
+ /** The one-line human summary every content action failure carries. */
116
+ error: string;
117
+ /** The entries whose bodies link to the refused one, for the blockers list. */
118
+ inboundLinks: InboundLink[];
119
+ /** The refused entry's id, so a list view marks the right row. */
120
+ id: string;
121
+ }
122
+ /** A refused rename: `fail(400)` on a bad slug, `fail(409)` on a collision or pending edits. */
123
+ export interface RenameFailure {
124
+ /** The one-line human summary every content action failure carries. */
125
+ error: string;
110
126
  }
127
+ /** What a route's single `form` export presents to a view component: whichever content action
128
+ * last failed, merged with every field optional. `error` is always set on a failure; the richer
129
+ * keys identify which guard refused. */
130
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
111
131
  export declare function createContentRoutes(runtime: CairnRuntime, deps?: ContentRoutesDeps): {
112
132
  layoutLoad: (event: ContentEvent) => Promise<LayoutData>;
113
133
  indexRedirect: () => never;
@@ -121,5 +141,5 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
121
141
  deleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
122
142
  listDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
123
143
  renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
124
- mintToken: (env: GithubKeyEnv) => Promise<string>;
144
+ mintToken: (env: GithubKeyEnv) => string | Promise<string>;
125
145
  };
@@ -4,17 +4,16 @@
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 { CommitConflictError } from '../github/types.js';
16
+ import { isConflict } from '../github/types.js';
18
17
  import { log } from '../log/index.js';
19
18
  import { issueCsrfToken } from './csrf.js';
20
19
  /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
@@ -112,9 +111,31 @@ export function createContentRoutes(runtime, deps = {}) {
112
111
  return { id: file.id, title: file.id, date: null, draft: false, status };
113
112
  }
114
113
  }
115
- /** List a concept's entries with their publish status. Main's files carry `edited` when a
116
- * pending ref exists, else `published`; a ref with no main file appends a `new` row read from
117
- * its branch. A listing failure degrades to an inline error, not a thrown 500. */
114
+ /** Read an entry's list row from its pending branch, so a pending title or draft change shows
115
+ * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
116
+ * an id-only row, so a ghost ref still lists. */
117
+ function pendingRow(concept, id, status, token) {
118
+ return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
119
+ ...runtime.backend,
120
+ branch: pendingBranch(concept.id, id),
121
+ });
122
+ }
123
+ /** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
124
+ * and read each one for its row, with edited and new rows reading branch-first. */
125
+ async function crawlEntries(concept, pendingIds, token) {
126
+ const files = await listMarkdown(runtime.backend, concept.dir, token);
127
+ const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))));
128
+ // A ref with no main file is a never-published entry; its row reads from its branch.
129
+ const listed = new Set(files.map((f) => f.id));
130
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
131
+ return [...entries, ...newRows];
132
+ }
133
+ /** List a concept's entries with their publish status. Published rows project straight from
134
+ * main's manifest, which publish, delete, and rename keep atomically in sync with main, so
135
+ * the listing costs one manifest read plus one branch read per pending entry rather than one
136
+ * read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
137
+ * with no manifest row appends a `new` row read from its branch. A listing failure degrades
138
+ * to an inline error, not a thrown 500. */
118
139
  async function listLoad(event) {
119
140
  sessionOf(event);
120
141
  const concept = conceptOf(runtime, event.params);
@@ -130,28 +151,28 @@ export function createContentRoutes(runtime, deps = {}) {
130
151
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
131
152
  }
132
153
  try {
133
- const [files, refs] = await Promise.all([
134
- listMarkdown(runtime.backend, concept.dir, token),
154
+ const [manifestRaw, refs] = await Promise.all([
155
+ readRaw(runtime.backend, runtime.manifestPath, token),
135
156
  listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
136
157
  ]);
137
158
  const pendingIds = new Set(refs.flatMap((name) => {
138
159
  const entry = pendingEntryOf(name);
139
160
  return entry && entry.concept.id === concept.id ? [entry.id] : [];
140
161
  }));
141
- // An edited row reads branch-first like a new row, so a pending title or draft change
142
- // shows in the list instead of reading as a lost save.
143
- const entries = await Promise.all(files.map((f) => pendingIds.has(f.id)
144
- ? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
145
- : summarize(f, token, 'published')));
146
- // A ref with no main file is a never-published entry; its row reads from its branch, and
147
- // summarize already degrades a failed read to an id-only row.
148
- const listed = new Set(files.map((f) => f.id));
149
- const newRows = await Promise.all([...pendingIds]
150
- .filter((id) => !listed.has(id))
151
- .map((id) => summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
152
- ...runtime.backend,
153
- branch: pendingBranch(concept.id, id),
154
- })));
162
+ // A repo with no committed manifest yet (a fresh site before its first publish) falls back
163
+ // to the crawl; a manifest that parses but is empty is trusted as-is.
164
+ if (manifestRaw === null) {
165
+ return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
166
+ }
167
+ // Newest id first, the same order the crawl's file listing produced.
168
+ const rows = parseManifest(manifestRaw)
169
+ .entries.filter((e) => e.concept === concept.id)
170
+ .sort((a, b) => b.id.localeCompare(a.id));
171
+ const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
172
+ ? pendingRow(concept, e.id, 'edited', token)
173
+ : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' }));
174
+ const listed = new Set(rows.map((e) => e.id));
175
+ const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)));
155
176
  return { ...base, entries: [...entries, ...newRows], error: null };
156
177
  }
157
178
  catch {
@@ -270,10 +291,6 @@ export function createContentRoutes(runtime, deps = {}) {
270
291
  discardedFlash: event.url.searchParams.get('discarded') === '1',
271
292
  };
272
293
  }
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
294
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
278
295
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
279
296
  * failures carry the same shape under their own event name. */
@@ -338,7 +355,12 @@ export function createContentRoutes(runtime, deps = {}) {
338
355
  draftLinks.push(formatCairnToken(ref));
339
356
  }
340
357
  if (absent.length) {
341
- return fail(400, { brokenLinks: absent, body });
358
+ const noun = absent.length === 1 ? 'page' : 'pages';
359
+ return fail(400, {
360
+ error: `This page links to ${absent.length} missing ${noun}.`,
361
+ brokenLinks: absent,
362
+ body,
363
+ });
342
364
  }
343
365
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
344
366
  // commit only the entry file there. Main stays untouched until publish, so the branch differs
@@ -525,7 +547,11 @@ export function createContentRoutes(runtime, deps = {}) {
525
547
  const manifest = await readManifest(token);
526
548
  const inbound = inboundLinks(manifest, concept.id, id);
527
549
  if (inbound.length) {
528
- return fail(409, { inboundLinks: inbound, id });
550
+ return fail(409, {
551
+ error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
552
+ inboundLinks: inbound,
553
+ id,
554
+ });
529
555
  }
530
556
  // When the entry was never published (absent from main), the branch delete is the whole
531
557
  // operation; main has nothing to commit, so the only honest log record is the discard of
@@ -593,19 +619,19 @@ export function createContentRoutes(runtime, deps = {}) {
593
619
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
594
620
  // them, so refuse until the editor publishes or discards.
595
621
  if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
596
- return fail(409, { renameError: 'This entry has unpublished edits. Publish or discard them, then rename.' });
622
+ return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' });
597
623
  }
598
624
  const form = await event.request.formData();
599
625
  const newSlug = String(form.get('slug') ?? '').trim();
600
626
  if (!isValidId(newSlug)) {
601
- return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
627
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
602
628
  }
603
629
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
604
630
  if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
605
- return fail(400, { renameError: 'Leave the date out of the slug.' });
631
+ return fail(400, { error: 'Leave the date out of the slug.' });
606
632
  }
607
633
  if (newSlug === slugFromId(id, datePrefix)) {
608
- return fail(400, { renameError: 'That is already the slug.' });
634
+ return fail(400, { error: 'That is already the slug.' });
609
635
  }
610
636
  const newId = renameId(id, newSlug, datePrefix);
611
637
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
@@ -615,7 +641,7 @@ export function createContentRoutes(runtime, deps = {}) {
615
641
  // concurrent-rename race where another editor renamed onto this path between load and submit.
616
642
  const clobber = await readRaw(runtime.backend, newPath, token);
617
643
  if (clobber !== null) {
618
- return fail(409, { renameError: 'An entry with that slug already exists.' });
644
+ return fail(409, { error: 'An entry with that slug already exists.' });
619
645
  }
620
646
  const [entryRaw, manifest] = await Promise.all([
621
647
  readRaw(runtime.backend, oldPath, token),
@@ -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 && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
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;
@@ -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, renderStaticAdminPage } from './static-admin-page.js';
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';
@@ -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
- mintToken?: (env: GithubKeyEnv) => Promise<string>;
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,7 +5,7 @@ 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 { CommitConflictError } from '../github/types.js';
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
11
  /** The signed-in editor the guard resolved, or a login redirect. */
@@ -15,10 +15,6 @@ function sessionOf(event) {
15
15
  throw redirect(303, '/admin/login');
16
16
  return editor;
17
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
- }
22
18
  export function createNavRoutes(runtime, deps = {}) {
23
19
  const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
24
20
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
@@ -51,14 +47,27 @@ export function createNavRoutes(runtime, deps = {}) {
51
47
  return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
52
48
  }
53
49
  let tree = [];
50
+ let raw = null;
54
51
  try {
55
- const raw = await readRaw(runtime.backend, config.configPath, token);
56
- if (raw !== null)
57
- tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
52
+ raw = await readRaw(runtime.backend, config.configPath, token);
58
53
  }
59
54
  catch {
60
- // A malformed or unreadable config degrades to an empty tree; the first save writes a clean menu.
61
- tree = [];
55
+ // An unreadable config degrades to an empty tree; the first save writes a clean menu.
56
+ raw = null;
57
+ }
58
+ if (raw !== null) {
59
+ try {
60
+ tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
61
+ }
62
+ catch (err) {
63
+ // A malformed config keeps the same degrade (the nav page failing closed would be worse
64
+ // for the editor), but the swallow names the operator fault in the log.
65
+ log.error('config.invalid', {
66
+ conditionId: 'config.site-config-invalid',
67
+ error: String(err),
68
+ });
69
+ tree = [];
70
+ }
62
71
  }
63
72
  return {
64
73
  menu,
@@ -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
- /** Escape a string for safe interpolation into HTML text and double-quoted attributes. */
6
- export function escapeHtml(value) {
7
- return value
8
- .replace(/&/g, '&amp;')
9
- .replace(/</g, '&lt;')
10
- .replace(/>/g, '&gt;')
11
- .replace(/"/g, '&quot;');
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
- export interface RequestContext {
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
- env?: AuthEnv;
25
- ctx?: {
26
- waitUntil(promise: Promise<unknown>): void;
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 {