@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.
Files changed (162) hide show
  1. package/CHANGELOG.md +82 -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 +107 -25
  12. package/dist/components/EditPage.svelte.d.ts +8 -10
  13. package/dist/components/EditorToolbar.svelte +79 -8
  14. package/dist/components/EditorToolbar.svelte.d.ts +10 -2
  15. package/dist/components/LoginPage.svelte +2 -2
  16. package/dist/components/LoginPage.svelte.d.ts +1 -1
  17. package/dist/components/ManageEditors.svelte +4 -3
  18. package/dist/components/ManageEditors.svelte.d.ts +2 -1
  19. package/dist/components/MarkdownEditor.svelte +20 -2
  20. package/dist/components/cairn-admin.css +57 -9
  21. package/dist/components/editor-highlight.d.ts +1 -0
  22. package/dist/components/editor-highlight.js +31 -8
  23. package/dist/components/index.d.ts +1 -0
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/markdown-directives.d.ts +10 -0
  26. package/dist/components/markdown-directives.js +54 -1
  27. package/dist/components/markdown-format.d.ts +0 -8
  28. package/dist/components/markdown-format.js +0 -28
  29. package/dist/components/preview-doc.d.ts +27 -0
  30. package/dist/components/preview-doc.js +64 -0
  31. package/dist/content/compose.js +1 -0
  32. package/dist/content/links.d.ts +8 -0
  33. package/dist/content/links.js +28 -0
  34. package/dist/content/types.d.ts +35 -2
  35. package/dist/delivery/data.d.ts +3 -5
  36. package/dist/delivery/data.js +2 -3
  37. package/dist/delivery/feeds.js +1 -7
  38. package/dist/delivery/index.d.ts +2 -2
  39. package/dist/delivery/index.js +1 -1
  40. package/dist/delivery/manifest.d.ts +0 -5
  41. package/dist/delivery/manifest.js +5 -16
  42. package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
  43. package/dist/{sveltekit → delivery}/public-routes.js +7 -7
  44. package/dist/delivery/site-indexes.d.ts +3 -3
  45. package/dist/delivery/site-indexes.js +3 -3
  46. package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
  47. package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
  48. package/dist/delivery/sitemap.js +1 -3
  49. package/dist/delivery/xml.d.ts +2 -0
  50. package/dist/delivery/xml.js +11 -0
  51. package/dist/diagnostics/conditions.js +24 -0
  52. package/dist/doctor/bin.js +30 -12
  53. package/dist/doctor/check-floors.d.ts +15 -0
  54. package/dist/doctor/check-floors.js +107 -0
  55. package/dist/doctor/check-probe.d.ts +3 -0
  56. package/dist/doctor/check-probe.js +123 -0
  57. package/dist/doctor/checks-github.js +1 -1
  58. package/dist/doctor/checks-local.d.ts +1 -0
  59. package/dist/doctor/checks-local.js +28 -2
  60. package/dist/doctor/cloudflare-api.js +2 -2
  61. package/dist/doctor/index.d.ts +28 -3
  62. package/dist/doctor/index.js +47 -6
  63. package/dist/doctor/types.d.ts +2 -0
  64. package/dist/doctor/wrangler-config.d.ts +4 -0
  65. package/dist/doctor/wrangler-config.js +11 -0
  66. package/dist/email.js +4 -11
  67. package/dist/env.d.ts +3 -2
  68. package/dist/env.js +12 -6
  69. package/dist/escape.d.ts +2 -0
  70. package/dist/escape.js +11 -0
  71. package/dist/github/credentials.d.ts +2 -1
  72. package/dist/github/credentials.js +10 -2
  73. package/dist/github/types.d.ts +2 -0
  74. package/dist/github/types.js +4 -0
  75. package/dist/index.d.ts +1 -1
  76. package/dist/log/events.d.ts +1 -1
  77. package/dist/nav/site-config.d.ts +2 -0
  78. package/dist/nav/site-config.js +2 -0
  79. package/dist/sveltekit/admin-dispatch.d.ts +28 -0
  80. package/dist/sveltekit/admin-dispatch.js +62 -0
  81. package/dist/sveltekit/cairn-admin.d.ts +94 -0
  82. package/dist/sveltekit/cairn-admin.js +126 -0
  83. package/dist/sveltekit/condition-response.d.ts +1 -0
  84. package/dist/sveltekit/condition-response.js +25 -0
  85. package/dist/sveltekit/content-routes.d.ts +39 -15
  86. package/dist/sveltekit/content-routes.js +84 -50
  87. package/dist/sveltekit/guard.d.ts +8 -2
  88. package/dist/sveltekit/guard.js +18 -4
  89. package/dist/sveltekit/https-required-page.js +2 -1
  90. package/dist/sveltekit/index.d.ts +3 -1
  91. package/dist/sveltekit/index.js +2 -0
  92. package/dist/sveltekit/nav-routes.d.ts +3 -1
  93. package/dist/sveltekit/nav-routes.js +22 -19
  94. package/dist/sveltekit/static-admin-page.d.ts +0 -2
  95. package/dist/sveltekit/static-admin-page.js +1 -8
  96. package/dist/sveltekit/types.d.ts +18 -11
  97. package/dist/vite/index.d.ts +16 -0
  98. package/dist/vite/index.js +57 -13
  99. package/package.json +6 -2
  100. package/src/lib/ambient.ts +19 -0
  101. package/src/lib/components/AdminLayout.svelte +6 -8
  102. package/src/lib/components/CairnAdmin.svelte +67 -0
  103. package/src/lib/components/ConceptList.svelte +4 -5
  104. package/src/lib/components/ConfirmPage.svelte +1 -1
  105. package/src/lib/components/EditPage.svelte +107 -25
  106. package/src/lib/components/EditorToolbar.svelte +79 -8
  107. package/src/lib/components/LoginPage.svelte +2 -2
  108. package/src/lib/components/ManageEditors.svelte +4 -3
  109. package/src/lib/components/MarkdownEditor.svelte +20 -2
  110. package/src/lib/components/cairn-admin.css +59 -0
  111. package/src/lib/components/editor-highlight.ts +32 -7
  112. package/src/lib/components/index.ts +1 -0
  113. package/src/lib/components/markdown-directives.ts +51 -1
  114. package/src/lib/components/markdown-format.ts +0 -27
  115. package/src/lib/components/preview-doc.ts +82 -0
  116. package/src/lib/content/compose.ts +1 -0
  117. package/src/lib/content/links.ts +28 -0
  118. package/src/lib/content/types.ts +34 -2
  119. package/src/lib/delivery/data.ts +3 -5
  120. package/src/lib/delivery/feeds.ts +1 -8
  121. package/src/lib/delivery/index.ts +2 -2
  122. package/src/lib/delivery/manifest.ts +5 -18
  123. package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
  124. package/src/lib/delivery/site-indexes.ts +6 -6
  125. package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
  126. package/src/lib/delivery/sitemap.ts +1 -4
  127. package/src/lib/delivery/xml.ts +12 -0
  128. package/src/lib/diagnostics/conditions.ts +24 -0
  129. package/src/lib/doctor/bin.ts +35 -10
  130. package/src/lib/doctor/check-floors.ts +124 -0
  131. package/src/lib/doctor/check-probe.ts +138 -0
  132. package/src/lib/doctor/checks-github.ts +3 -1
  133. package/src/lib/doctor/checks-local.ts +28 -2
  134. package/src/lib/doctor/cloudflare-api.ts +4 -2
  135. package/src/lib/doctor/index.ts +67 -6
  136. package/src/lib/doctor/types.ts +2 -0
  137. package/src/lib/doctor/wrangler-config.ts +11 -0
  138. package/src/lib/email.ts +4 -11
  139. package/src/lib/env.ts +12 -6
  140. package/src/lib/escape.ts +12 -0
  141. package/src/lib/github/credentials.ts +6 -2
  142. package/src/lib/github/types.ts +5 -0
  143. package/src/lib/index.ts +2 -0
  144. package/src/lib/log/events.ts +1 -0
  145. package/src/lib/nav/site-config.ts +3 -0
  146. package/src/lib/sveltekit/admin-dispatch.ts +75 -0
  147. package/src/lib/sveltekit/cairn-admin.ts +177 -0
  148. package/src/lib/sveltekit/condition-response.ts +27 -1
  149. package/src/lib/sveltekit/content-routes.ts +131 -62
  150. package/src/lib/sveltekit/guard.ts +20 -5
  151. package/src/lib/sveltekit/https-required-page.ts +2 -1
  152. package/src/lib/sveltekit/index.ts +6 -0
  153. package/src/lib/sveltekit/nav-routes.ts +24 -21
  154. package/src/lib/sveltekit/static-admin-page.ts +1 -9
  155. package/src/lib/sveltekit/types.ts +16 -7
  156. package/src/lib/vite/index.ts +71 -17
  157. package/dist/delivery/paginate.d.ts +0 -12
  158. package/dist/delivery/paginate.js +0 -20
  159. package/dist/render/index.d.ts +0 -5
  160. package/dist/render/index.js +0 -8
  161. package/src/lib/delivery/paginate.ts +0 -32
  162. package/src/lib/render/index.ts +0 -8
@@ -0,0 +1,177 @@
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, type AdminView } from './admin-dispatch.js';
9
+ import { createAuthRoutes } from './auth-routes.js';
10
+ import {
11
+ createContentRoutes,
12
+ type ContentEvent,
13
+ type ContentRoutesDeps,
14
+ type LayoutData,
15
+ type ListData,
16
+ type EditData,
17
+ } from './content-routes.js';
18
+ import { createEditorRoutes } from './editors-routes.js';
19
+ import { createNavRoutes, type NavLoadData } from './nav-routes.js';
20
+ import type { AuthBranding, SendMagicLink } from '../email.js';
21
+ import type { AuthEnv, Editor } from '../auth/types.js';
22
+ import type { GithubKeyEnv } from '../github/credentials.js';
23
+ import type { CairnRuntime } from '../content/types.js';
24
+ import type { CookieJar, EventBase } from './types.js';
25
+
26
+ /**
27
+ * The structural event the single-mount load reads: the union of what the wrapped loads need
28
+ * (ContentEvent minus params, which the dispatcher synthesizes, plus RequestContext's cookies
29
+ * and setHeaders). A real SvelteKit RequestEvent satisfies it.
30
+ */
31
+ export interface AdminEvent extends EventBase<GithubKeyEnv & AuthEnv> {
32
+ cookies: CookieJar;
33
+ setHeaders(headers: Record<string, string>): void;
34
+ }
35
+
36
+ /** Injectable dependencies. Branding defaults from the runtime's siteName and sender, so a
37
+ * site overrides it only to change the magic-link email identity; `send` and `mintToken`
38
+ * are the same seams the underlying factories take. */
39
+ export interface CairnAdminDeps {
40
+ branding?: AuthBranding;
41
+ send?: SendMagicLink;
42
+ mintToken?: ContentRoutesDeps['mintToken'];
43
+ }
44
+
45
+ /**
46
+ * One admin view's data, discriminated for the admin page component's switch. The public
47
+ * views (login, confirm) carry no layout; every authed view pairs the shared layout with its
48
+ * page data, the same shapes the per-surface loads have always returned.
49
+ */
50
+ export type AdminData =
51
+ | { view: 'login'; page: { siteName: string; error: string | null; csrf: string } }
52
+ | { view: 'confirm'; page: { token: string; siteName: string; error: string | null; csrf: string } }
53
+ | { view: 'list'; layout: LayoutData; page: ListData }
54
+ | { view: 'edit'; layout: LayoutData; page: EditData }
55
+ | { view: 'editors'; layout: LayoutData; page: { editors: Editor[]; self: string } }
56
+ | { view: 'nav'; layout: LayoutData; page: NavLoadData };
57
+
58
+ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {}) {
59
+ // The runtime already composes the site name and the sender identity, so the magic-link
60
+ // branding needs no second copy of either unless a site overrides it.
61
+ const branding: AuthBranding = deps.branding ?? {
62
+ siteName: runtime.siteName,
63
+ from: runtime.sender.from,
64
+ replyTo: runtime.sender.replyTo,
65
+ };
66
+ const auth = createAuthRoutes({ branding, send: deps.send });
67
+ const content = createContentRoutes(runtime, { mintToken: deps.mintToken });
68
+ const editors = createEditorRoutes();
69
+ // The nav surface exists only when the site configures a menu; without one its view is a 404.
70
+ const nav = runtime.navMenu ? createNavRoutes(runtime, { mintToken: deps.mintToken }) : null;
71
+
72
+ /** Build the event a wrapped content load reads. The catch-all route carries only a rest
73
+ * param, so `concept` and `id` are synthesized from the parsed view. The override names
74
+ * each field explicitly rather than spreading: a real RequestEvent's fields can sit behind
75
+ * getters a bare spread copies poorly, and the structural ContentEvent contract needs only
76
+ * these. */
77
+ function contentEvent(event: AdminEvent, params: Record<string, string>): ContentEvent {
78
+ return {
79
+ url: event.url,
80
+ params,
81
+ request: event.request,
82
+ locals: event.locals,
83
+ platform: event.platform,
84
+ cookies: event.cookies,
85
+ };
86
+ }
87
+
88
+ /** Serve the admin view the pathname names, or a 404 for any shape the parser refuses.
89
+ * The authed views run the layout load and the view load concurrently; both mint a GitHub
90
+ * token, and the installation-token cache coalesces the mints into one signing. */
91
+ async function load(event: AdminEvent): Promise<AdminData> {
92
+ const view = parseAdminPath(event.url.pathname, runtime.concepts);
93
+ if (!view) throw error(404, 'Not found');
94
+ switch (view.view) {
95
+ case 'index':
96
+ return content.indexRedirect();
97
+ case 'login':
98
+ return { view: 'login', page: auth.loginLoad(event) };
99
+ case 'confirm':
100
+ return { view: 'confirm', page: auth.confirmLoad(event) };
101
+ case 'list': {
102
+ const delegated = contentEvent(event, { concept: view.concept.id });
103
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.listLoad(delegated)]);
104
+ return { view: 'list', layout, page };
105
+ }
106
+ case 'edit': {
107
+ const delegated = contentEvent(event, { concept: view.concept.id, id: view.id });
108
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.editLoad(delegated)]);
109
+ return { view: 'edit', layout, page };
110
+ }
111
+ case 'editors': {
112
+ // editorsLoad gates itself with requireOwner, so the dispatcher adds no second gate.
113
+ const [layout, page] = await Promise.all([
114
+ content.layoutLoad(contentEvent(event, {})),
115
+ editors.editorsLoad(event),
116
+ ]);
117
+ return { view: 'editors', layout, page };
118
+ }
119
+ case 'nav': {
120
+ if (!nav) throw error(404, 'Not found');
121
+ const delegated = contentEvent(event, {});
122
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), nav.navLoad(delegated)]);
123
+ return { view: 'nav', layout, page };
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Wrap a delegate in the parse-and-check every action shares: parse the pathname exactly
129
+ * as load does, 404 on a null parse or a view outside the allowed set, then hand the
130
+ * narrowed view to the delegate. */
131
+ function viewAction<V extends AdminView['view'], R>(
132
+ allowed: readonly V[],
133
+ delegate: (event: AdminEvent, view: Extract<AdminView, { view: V }>) => Promise<R>,
134
+ ): (event: AdminEvent) => Promise<R> {
135
+ return async (event) => {
136
+ const view = parseAdminPath(event.url.pathname, runtime.concepts);
137
+ if (!view || !(allowed as readonly string[]).includes(view.view)) throw error(404, 'Not found');
138
+ // The includes check above proves the membership the cast asserts.
139
+ return delegate(event, view as Extract<AdminView, { view: V }>);
140
+ };
141
+ }
142
+
143
+ // The topbar posts publishAll from every authed admin page; login and confirm may not.
144
+ const authedViews = ['list', 'edit', 'editors', 'nav'] as const;
145
+ // An editor signs out from wherever they are, so logout accepts any parsed view.
146
+ const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav'] as const;
147
+
148
+ /** The full admin action vocabulary, one named async function per action, so a site's
149
+ * catch-all route exports `admin.actions` directly. Each wrapper stays thin: parse,
150
+ * validate the view, synthesize the params the wrapped action reads, delegate. The
151
+ * editor actions gate themselves with requireOwner, so no second gate is added here. */
152
+ const actions = {
153
+ request: viewAction(['login'], (event) => auth.requestAction(event)),
154
+ confirm: viewAction(['confirm'], (event) => auth.confirmAction(event)),
155
+ logout: viewAction(anyView, (event) => auth.logoutAction(event)),
156
+ create: viewAction(['list'], (event, view) => content.createAction(contentEvent(event, { concept: view.concept.id }))),
157
+ save: viewAction(['edit', 'nav'], (event, view) => {
158
+ if (view.view === 'edit') return content.saveAction(contentEvent(event, { concept: view.concept.id, id: view.id }));
159
+ if (!nav) throw error(404, 'Not found');
160
+ return nav.navSave(contentEvent(event, {}));
161
+ }),
162
+ publish: viewAction(['edit'], (event, view) => content.publishAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
163
+ discard: viewAction(['edit'], (event, view) => content.discardAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
164
+ rename: viewAction(['edit'], (event, view) => content.renameAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
165
+ delete: viewAction(['edit', 'list'], (event, view) =>
166
+ view.view === 'edit'
167
+ ? content.deleteAction(contentEvent(event, { concept: view.concept.id, id: view.id }))
168
+ : content.listDeleteAction(contentEvent(event, { concept: view.concept.id })),
169
+ ),
170
+ publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
171
+ addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
172
+ removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
173
+ setRole: viewAction(['editors'], (event) => editors.setRoleAction(event)),
174
+ };
175
+
176
+ return { load, actions };
177
+ }
@@ -4,15 +4,38 @@
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 { condition } from '../diagnostics/index.js';
7
+ import { escapeHtml } from '../escape.js';
8
+ import { renderStaticAdminPage } from './static-admin-page.js';
9
+ import { condition, type CairnCondition } from '../diagnostics/index.js';
8
10
 
9
11
  /** The guard.rejected reasons, each mapped to its registered condition id. */
10
12
  export const REASON_CONDITION = {
11
13
  https: 'edge.https-not-forced',
12
14
  csrf: 'auth.csrf-token-invalid',
13
15
  origin: 'auth.csrf-origin-mismatch',
16
+ bindings: 'config.bindings-missing',
14
17
  } as const;
15
18
 
19
+ /**
20
+ * A branded page for an operator fault, built straight from the registered condition's fields so
21
+ * the served copy, the doctor's report, and the readiness checklist say the same thing.
22
+ */
23
+ function conditionFaultPage(cond: CairnCondition): string {
24
+ const inner = `
25
+ <span class="eyebrow">
26
+ <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>
27
+ Site setup required
28
+ </span>
29
+ <h1>${escapeHtml(cond.title)}</h1>
30
+ <p>${escapeHtml(cond.why)}</p>
31
+
32
+ <div class="fix">
33
+ <h2>If you run this site</h2>
34
+ <p>${escapeHtml(cond.remediation)}</p>
35
+ </div>`;
36
+ return renderStaticAdminPage({ title: `${cond.title} · Cairn`, innerHtml: inner });
37
+ }
38
+
16
39
  export type GuardReason = keyof typeof REASON_CONDITION;
17
40
 
18
41
  /** Render the Response the guard serves for a rejection, by its condition id. */
@@ -32,6 +55,9 @@ export function renderConditionResponse(id: string, ctx: { url?: URL } = {}): Re
32
55
  status: 403,
33
56
  headers: { 'Content-Type': 'text/plain; charset=utf-8' },
34
57
  });
58
+ case REASON_CONDITION.bindings:
59
+ // An operator fault, not a request fault: the Worker deployed without its bindings.
60
+ return brandedAdminPage(500, conditionFaultPage(condition(id)));
35
61
  default:
36
62
  throw new Error(`no runtime renderer for condition: ${id}`);
37
63
  }
@@ -4,21 +4,21 @@
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 { 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
- import type { CookieJar } from './types.js';
21
- import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
19
+ import { requireSession } from './guard.js';
20
+ import type { CookieJar, EventBase } from './types.js';
21
+ import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
22
22
  import type { Editor, Role } from '../auth/types.js';
23
23
 
24
24
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
@@ -101,15 +101,15 @@ export interface EditData {
101
101
  publishedFlash: boolean;
102
102
  /** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
103
103
  discardedFlash: boolean;
104
+ /** The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
105
+ * when one exists, applied over the top-level values); null when the site sets none, which
106
+ * leaves the frame rendering unstyled markup behind a hint. */
107
+ preview: ResolvedPreview | null;
104
108
  }
105
109
 
106
110
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
107
- export interface ContentEvent {
108
- url: URL;
111
+ export interface ContentEvent extends EventBase<GithubKeyEnv> {
109
112
  params: Record<string, string>;
110
- request: Request;
111
- locals: { editor?: Editor | null };
112
- platform?: { env?: GithubKeyEnv };
113
113
  /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
114
114
  * token. Optional for non-route callers. */
115
115
  cookies?: CookieJar;
@@ -117,15 +117,53 @@ export interface ContentEvent {
117
117
 
118
118
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
119
119
  export interface ContentRoutesDeps {
120
- /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer. */
121
- mintToken?: (env: GithubKeyEnv) => Promise<string>;
120
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
121
+ * A bare string works too; the routes await whatever comes back. */
122
+ mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
122
123
  }
123
124
 
124
- /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
125
- function sessionOf(event: ContentEvent): Editor {
126
- const editor = event.locals.editor;
127
- if (!editor) throw redirect(303, '/admin/login');
128
- return editor;
125
+ /** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
126
+ export interface SaveFailure {
127
+ /** The one-line human summary every content action failure carries. */
128
+ error: string;
129
+ /** The cairn tokens that resolve to no entry, for the editor's fix-it banner. */
130
+ brokenLinks: string[];
131
+ /** The author's edited markdown, so the editor reseeds with the unsaved work. */
132
+ body: string;
133
+ }
134
+
135
+ /** A refused delete: `fail(409)` while other entries still link to this one. */
136
+ export interface DeleteRefusal {
137
+ /** The one-line human summary every content action failure carries. */
138
+ error: string;
139
+ /** The entries whose bodies link to the refused one, for the blockers list. */
140
+ inboundLinks: InboundLink[];
141
+ /** The refused entry's id, so a list view marks the right row. */
142
+ id: string;
143
+ }
144
+
145
+ /** A refused rename: `fail(400)` on a bad slug, `fail(409)` on a collision or pending edits. */
146
+ export interface RenameFailure {
147
+ /** The one-line human summary every content action failure carries. */
148
+ error: string;
149
+ }
150
+
151
+ /** What a route's single `form` export presents to a view component: whichever content action
152
+ * last failed, merged with every field optional. `error` is always set on a failure; the richer
153
+ * keys identify which guard refused. */
154
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
155
+
156
+ /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
157
+ * nullish coalescing so an override key that is present but undefined keeps the top-level value.
158
+ * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
159
+ function resolvePreview(preview: PreviewConfig | undefined, conceptId: string): ResolvedPreview | null {
160
+ if (!preview) return null;
161
+ const override = preview.byConcept?.[conceptId];
162
+ return {
163
+ stylesheets: preview.stylesheets,
164
+ bodyClass: override?.bodyClass ?? preview.bodyClass,
165
+ containerClass: override?.containerClass ?? preview.containerClass,
166
+ };
129
167
  }
130
168
 
131
169
  /** Look up the concept named by the `[concept]` route param, or a 404. */
@@ -161,7 +199,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
161
199
  /** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
162
200
  * and the pending entries behind the topbar's publish-all action. */
163
201
  async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
164
- const editor = sessionOf(event);
202
+ const editor = requireSession(event);
165
203
  const cookieTheme = event.cookies?.get('cairn-admin-theme');
166
204
  const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
167
205
  const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
@@ -223,11 +261,39 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
223
261
  }
224
262
  }
225
263
 
226
- /** List a concept's entries with their publish status. Main's files carry `edited` when a
227
- * pending ref exists, else `published`; a ref with no main file appends a `new` row read from
228
- * its branch. A listing failure degrades to an inline error, not a thrown 500. */
264
+ /** Read an entry's list row from its pending branch, so a pending title or draft change shows
265
+ * in the list instead of reading as a lost save. summarize degrades a failed or empty read to
266
+ * an id-only row, so a ghost ref still lists. */
267
+ function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], token: string): Promise<EntrySummary> {
268
+ return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, status, {
269
+ ...runtime.backend,
270
+ branch: pendingBranch(concept.id, id),
271
+ });
272
+ }
273
+
274
+ /** The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
275
+ * and read each one for its row, with edited and new rows reading branch-first. */
276
+ async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, token: string): Promise<EntrySummary[]> {
277
+ const files = await listMarkdown(runtime.backend, concept.dir, token);
278
+ const entries = await Promise.all(
279
+ files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', token) : summarize(f, token, 'published'))),
280
+ );
281
+ // A ref with no main file is a never-published entry; its row reads from its branch.
282
+ const listed = new Set(files.map((f) => f.id));
283
+ const newRows = await Promise.all(
284
+ [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
285
+ );
286
+ return [...entries, ...newRows];
287
+ }
288
+
289
+ /** List a concept's entries with their publish status. Published rows project straight from
290
+ * main's manifest, which publish, delete, and rename keep atomically in sync with main, so
291
+ * the listing costs one manifest read plus one branch read per pending entry rather than one
292
+ * read per file. A manifest row with a pending ref is `edited` and reads branch-first; a ref
293
+ * with no manifest row appends a `new` row read from its branch. A listing failure degrades
294
+ * to an inline error, not a thrown 500. */
229
295
  async function listLoad(event: ContentEvent): Promise<ListData> {
230
- sessionOf(event);
296
+ requireSession(event);
231
297
  const concept = conceptOf(runtime, event.params);
232
298
  const formError = event.url.searchParams.get('error');
233
299
  const publishedAllRaw = event.url.searchParams.get('publishedAll');
@@ -240,8 +306,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
240
306
  return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
241
307
  }
242
308
  try {
243
- const [files, refs] = await Promise.all([
244
- listMarkdown(runtime.backend, concept.dir, token),
309
+ const [manifestRaw, refs] = await Promise.all([
310
+ readRaw(runtime.backend, runtime.manifestPath, token),
245
311
  listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
246
312
  ]);
247
313
  const pendingIds = new Set(
@@ -250,27 +316,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
250
316
  return entry && entry.concept.id === concept.id ? [entry.id] : [];
251
317
  }),
252
318
  );
253
- // An edited row reads branch-first like a new row, so a pending title or draft change
254
- // shows in the list instead of reading as a lost save.
319
+ // A repo with no committed manifest yet (a fresh site before its first publish) falls back
320
+ // to the crawl; a manifest that parses but is empty is trusted as-is.
321
+ if (manifestRaw === null) {
322
+ return { ...base, entries: await crawlEntries(concept, pendingIds, token), error: null };
323
+ }
324
+ // Newest id first, the same order the crawl's file listing produced.
325
+ const rows = parseManifest(manifestRaw)
326
+ .entries.filter((e) => e.concept === concept.id)
327
+ .sort((a, b) => b.id.localeCompare(a.id));
255
328
  const entries = await Promise.all(
256
- files.map((f) =>
257
- pendingIds.has(f.id)
258
- ? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
259
- : summarize(f, token, 'published'),
329
+ rows.map((e) =>
330
+ pendingIds.has(e.id)
331
+ ? pendingRow(concept, e.id, 'edited', token)
332
+ : { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const },
260
333
  ),
261
334
  );
262
- // A ref with no main file is a never-published entry; its row reads from its branch, and
263
- // summarize already degrades a failed read to an id-only row.
264
- const listed = new Set(files.map((f) => f.id));
335
+ const listed = new Set(rows.map((e) => e.id));
265
336
  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
- ),
337
+ [...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', token)),
274
338
  );
275
339
  return { ...base, entries: [...entries, ...newRows], error: null };
276
340
  } catch {
@@ -280,7 +344,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
280
344
 
281
345
  /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
282
346
  async function createAction(event: ContentEvent): Promise<never> {
283
- sessionOf(event);
347
+ requireSession(event);
284
348
  const concept = conceptOf(runtime, event.params);
285
349
  const form = await event.request.formData();
286
350
  const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
@@ -325,7 +389,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
325
389
 
326
390
  /** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
327
391
  async function editLoad(event: ContentEvent): Promise<EditData> {
328
- sessionOf(event);
392
+ requireSession(event);
329
393
  const concept = conceptOf(runtime, event.params);
330
394
  const id = event.params.id ?? '';
331
395
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -387,14 +451,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
387
451
  published,
388
452
  publishedFlash: event.url.searchParams.get('published') === '1',
389
453
  discardedFlash: event.url.searchParams.get('discarded') === '1',
454
+ preview: resolvePreview(runtime.preview, concept.id),
390
455
  };
391
456
  }
392
457
 
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
458
  /** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
399
459
  * reason; any other error is unexpected and logs at error with the stringified cause. Publish
400
460
  * failures carry the same shape under their own event name. */
@@ -492,7 +552,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
492
552
  else if (target.draft) draftLinks.push(formatCairnToken(ref));
493
553
  }
494
554
  if (absent.length) {
495
- return fail(400, { brokenLinks: absent, body });
555
+ const noun = absent.length === 1 ? 'page' : 'pages';
556
+ return fail(400, {
557
+ error: `This page links to ${absent.length} missing ${noun}.`,
558
+ brokenLinks: absent,
559
+ body,
560
+ } satisfies SaveFailure);
496
561
  }
497
562
 
498
563
  // Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
@@ -525,7 +590,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
525
590
  /** Save an edit: validate, then commit to the entry's pending branch with the session editor
526
591
  * as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
527
592
  async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
528
- const editor = sessionOf(event);
593
+ const editor = requireSession(event);
529
594
  const concept = conceptOf(runtime, event.params);
530
595
  const id = event.params.id ?? '';
531
596
  // Confine the commit path to the concept dir, built from a validated id (the App token can
@@ -546,7 +611,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
546
611
  * The branch is deleted only when its head still matches the commit this action made; a
547
612
  * concurrent save moved it, so the entry stays pending and the next publish picks it up. */
548
613
  async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
549
- const editor = sessionOf(event);
614
+ const editor = requireSession(event);
550
615
  const concept = conceptOf(runtime, event.params);
551
616
  const id = event.params.id ?? '';
552
617
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -585,7 +650,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
585
650
  * Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
586
651
  * concept param is ignored and the redirect lands on the first configured concept. */
587
652
  async function publishAllAction(event: ContentEvent): Promise<never> {
588
- const editor = sessionOf(event);
653
+ const editor = requireSession(event);
589
654
  const first = runtime.concepts[0];
590
655
  if (!first) throw error(404, 'No content types configured');
591
656
  const token = await mintToken(event.platform?.env ?? {});
@@ -672,7 +737,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
672
737
  /** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
673
738
  * the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
674
739
  async function discardAction(event: ContentEvent): Promise<never> {
675
- const editor = sessionOf(event);
740
+ const editor = requireSession(event);
676
741
  const concept = conceptOf(runtime, event.params);
677
742
  const id = event.params.id ?? '';
678
743
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -705,7 +770,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
705
770
  const manifest = await readManifest(token);
706
771
  const inbound = inboundLinks(manifest, concept.id, id);
707
772
  if (inbound.length) {
708
- return fail(409, { inboundLinks: inbound, id });
773
+ return fail(409, {
774
+ error: `Cannot delete ${id}: ${inbound.length} ${inbound.length === 1 ? 'page links' : 'pages link'} to it.`,
775
+ inboundLinks: inbound,
776
+ id,
777
+ } satisfies DeleteRefusal);
709
778
  }
710
779
 
711
780
  // When the entry was never published (absent from main), the branch delete is the whole
@@ -749,7 +818,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
749
818
 
750
819
  /** Delete an entry from its editor. The id comes from the route param. */
751
820
  async function deleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
752
- const editor = sessionOf(event);
821
+ const editor = requireSession(event);
753
822
  const concept = conceptOf(runtime, event.params);
754
823
  const id = event.params.id ?? '';
755
824
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -758,7 +827,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
758
827
 
759
828
  /** Delete an entry from the concept list. The id comes from the form body. */
760
829
  async function listDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
761
- const editor = sessionOf(event);
830
+ const editor = requireSession(event);
762
831
  const concept = conceptOf(runtime, event.params);
763
832
  const form = await event.request.formData();
764
833
  const id = String(form.get('id') ?? '');
@@ -771,7 +840,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
771
840
  * are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
772
841
  * caught by the build's fail-closed backstop. */
773
842
  async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
774
- const editor = sessionOf(event);
843
+ const editor = requireSession(event);
775
844
  const concept = conceptOf(runtime, event.params);
776
845
  const id = event.params.id ?? '';
777
846
  if (!isValidId(id)) throw error(400, 'Invalid entry id');
@@ -780,20 +849,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
780
849
  // Pending edits on the branch are keyed to the old id; renaming underneath them would strand
781
850
  // them, so refuse until the editor publishes or discards.
782
851
  if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
783
- return fail(409, { renameError: 'This entry has unpublished edits. Publish or discard them, then rename.' });
852
+ return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' } satisfies RenameFailure);
784
853
  }
785
854
 
786
855
  const form = await event.request.formData();
787
856
  const newSlug = String(form.get('slug') ?? '').trim();
788
857
  if (!isValidId(newSlug)) {
789
- return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
858
+ return fail(400, { error: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' } satisfies RenameFailure);
790
859
  }
791
860
  const datePrefix = concept.routing.dated ? concept.datePrefix : null;
792
861
  if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
793
- return fail(400, { renameError: 'Leave the date out of the slug.' });
862
+ return fail(400, { error: 'Leave the date out of the slug.' } satisfies RenameFailure);
794
863
  }
795
864
  if (newSlug === slugFromId(id, datePrefix)) {
796
- return fail(400, { renameError: 'That is already the slug.' });
865
+ return fail(400, { error: 'That is already the slug.' } satisfies RenameFailure);
797
866
  }
798
867
  const newId = renameId(id, newSlug, datePrefix);
799
868
  const oldPath = `${concept.dir}/${filenameFromId(id)}`;
@@ -804,7 +873,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
804
873
  // concurrent-rename race where another editor renamed onto this path between load and submit.
805
874
  const clobber = await readRaw(runtime.backend, newPath, token);
806
875
  if (clobber !== null) {
807
- return fail(409, { renameError: 'An entry with that slug already exists.' });
876
+ return fail(409, { error: 'An entry with that slug already exists.' } satisfies RenameFailure);
808
877
  }
809
878
 
810
879
  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 && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
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
  }
@@ -80,8 +93,10 @@ export function createAuthGuard() {
80
93
  };
81
94
  }
82
95
 
83
- /** For a protected load/action: the session the guard already resolved, or a login redirect. */
84
- export function requireSession(event: RequestContext): Editor {
96
+ /** For a protected load/action: the session the guard already resolved, or a login redirect.
97
+ * The parameter is the minimal structural need (just `locals`), so every engine event shape
98
+ * (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
99
+ export function requireSession(event: { locals: { editor?: Editor | null } }): Editor {
85
100
  const editor = event.locals.editor;
86
101
  if (!editor) throw redirect(303, '/admin/login');
87
102
  return editor;