@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
@@ -1,11 +1,19 @@
1
+ // cairn-cms: the bridge from the adapter's backend config and the Worker's secret to the
2
+ // App signer's input. One tested place owns the join and the missing-secret failure, so the
3
+ // save action (Plan 05) stays thin and a misconfigured Worker fails by name, not with a deep
4
+ // TypeError. Mirrors requireDb/requireOrigin in env.ts.
5
+ import { CairnError } from '../diagnostics/index.js';
1
6
  /**
2
7
  * Assemble the `AppCredentials` the signer needs from the adapter's `backend` (app id,
3
- * installation) and the Worker's private-key secret. Throws when the secret is unset.
8
+ * installation) and the Worker's private-key secret. Throws a CairnError naming
9
+ * `github.app-unreachable` when the secret is unset, since the App cannot authenticate without it.
4
10
  */
5
11
  export function appCredentials(backend, env) {
6
12
  const privateKeyB64 = env.GITHUB_APP_PRIVATE_KEY_B64;
7
13
  if (!privateKeyB64) {
8
- throw new Error('GITHUB_APP_PRIVATE_KEY_B64 is not configured');
14
+ throw new CairnError('github.app-unreachable', {
15
+ message: 'GITHUB_APP_PRIVATE_KEY_B64 is not configured',
16
+ });
9
17
  }
10
18
  return { appId: backend.appId, installationId: backend.installationId, privateKeyB64 };
11
19
  }
@@ -32,3 +32,5 @@ export declare class CommitConflictError extends Error {
32
32
  readonly path: string;
33
33
  constructor(path: string);
34
34
  }
35
+ /** Match a commit conflict by class and by name (bundling can alias the class identity). */
36
+ export declare function isConflict(err: unknown): boolean;
@@ -16,3 +16,7 @@ export class CommitConflictError extends Error {
16
16
  this.name = 'CommitConflictError';
17
17
  }
18
18
  }
19
+ /** Match a commit conflict by class and by name (bundling can alias the class identity). */
20
+ export function isConflict(err) {
21
+ return err instanceof CommitConflictError || err?.name === 'CommitConflictError';
22
+ }
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { requireOrigin } from './env.js';
2
2
  export type { Role, Editor, AuthEnv } from './auth/types.js';
3
3
  export type { AuthBranding, MagicLinkMessage, SendMagicLink } from './email.js';
4
4
  export { buildMagicLinkMessage, cloudflareSend } from './email.js';
5
- export type { CairnAdapter, ConceptConfig, FrontmatterField, TextField, TextareaField, DateField, BooleanField, TagsField, FreeTagsField, ValidationResult, BackendConfig, SenderConfig, NavMenuConfig, AssetConfig, RoutingRule, ConceptDescriptor, ConceptUrlPolicy, CairnExtension, CairnRuntime, AdminPanel, FieldTypeDef, } from './content/types.js';
5
+ export type { CairnAdapter, ConceptConfig, FrontmatterField, TextField, TextareaField, DateField, BooleanField, TagsField, FreeTagsField, ValidationResult, BackendConfig, SenderConfig, NavMenuConfig, PreviewConfig, ResolvedPreview, AssetConfig, RoutingRule, ConceptDescriptor, ConceptUrlPolicy, CairnExtension, CairnRuntime, AdminPanel, FieldTypeDef, } from './content/types.js';
6
6
  export { CONCEPT_ROUTING, normalizeConcepts, findConcept } from './content/concepts.js';
7
7
  export { composeRuntime } from './content/compose.js';
8
8
  export type { ComposeInput } from './content/compose.js';
@@ -1 +1 @@
1
- export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected';
1
+ export type CairnLogEvent = 'auth.link.requested' | 'auth.link.send_failed' | 'auth.token.minted' | 'auth.token.confirmed' | 'auth.session.created' | 'auth.session.destroyed' | 'commit.succeeded' | 'commit.failed' | 'config.invalid' | 'entry.published' | 'entry.discarded' | 'publish.failed' | 'github.unreachable' | 'guard.rejected';
@@ -37,6 +37,8 @@ export interface SiteConfig {
37
37
  [key: string]: unknown;
38
38
  }
39
39
  export declare class SiteConfigError extends Error {
40
+ /** The registered diagnostic condition a malformed site config maps to (mirrors CairnError). */
41
+ readonly conditionId = "config.site-config-invalid";
40
42
  constructor(message: string);
41
43
  }
42
44
  /** Parse the YAML site-config text into a typed object. Throws SiteConfigError on a malformed root. */
@@ -60,6 +60,8 @@ export function validateNavTree(value, maxDepth) {
60
60
  return walk(value, 1);
61
61
  }
62
62
  export class SiteConfigError extends Error {
63
+ /** The registered diagnostic condition a malformed site config maps to (mirrors CairnError). */
64
+ conditionId = 'config.site-config-invalid';
63
65
  constructor(message) {
64
66
  super(message);
65
67
  this.name = 'SiteConfigError';
@@ -0,0 +1,28 @@
1
+ import type { ConceptDescriptor } from '../content/types.js';
2
+ /** The views the single-mount admin can render, discriminated for the dispatcher's switch. */
3
+ export type AdminView = {
4
+ view: 'index';
5
+ } | {
6
+ view: 'login';
7
+ } | {
8
+ view: 'confirm';
9
+ } | {
10
+ view: 'list';
11
+ concept: ConceptDescriptor;
12
+ } | {
13
+ view: 'edit';
14
+ concept: ConceptDescriptor;
15
+ id: string;
16
+ } | {
17
+ view: 'editors';
18
+ } | {
19
+ view: 'nav';
20
+ };
21
+ /**
22
+ * Parse a raw `URL.pathname` (the caller passes `event.url.pathname`, never a SvelteKit rest
23
+ * param) into the admin view it names. A single trailing slash is tolerated everywhere; empty
24
+ * internal segments are not. Each segment is percent-decoded individually, so an encoded slash
25
+ * stays inside its segment, where it can never match a concept id or pass `isValidId` and so
26
+ * falls through to null.
27
+ */
28
+ export declare function parseAdminPath(pathname: string, concepts: ConceptDescriptor[]): AdminView | null;
@@ -0,0 +1,62 @@
1
+ import { findConcept } from '../content/concepts.js';
2
+ import { isValidId } from '../content/ids.js';
3
+ /**
4
+ * Fixed first segments that never resolve as concepts. The engine only allows posts and pages
5
+ * today, so no collision is possible, but the parser does not depend on that: a reserved
6
+ * segment wins before concept lookup. `settings` has no view yet; AdminLayout already links
7
+ * the sidebar to /admin/settings, so the URL is spoken for.
8
+ */
9
+ const RESERVED_SEGMENTS = new Set(['login', 'auth', 'editors', 'nav', 'settings']);
10
+ /**
11
+ * Parse a raw `URL.pathname` (the caller passes `event.url.pathname`, never a SvelteKit rest
12
+ * param) into the admin view it names. A single trailing slash is tolerated everywhere; empty
13
+ * internal segments are not. Each segment is percent-decoded individually, so an encoded slash
14
+ * stays inside its segment, where it can never match a concept id or pass `isValidId` and so
15
+ * falls through to null.
16
+ */
17
+ export function parseAdminPath(pathname, concepts) {
18
+ if (pathname !== '/admin' && !pathname.startsWith('/admin/'))
19
+ return null;
20
+ let rest = pathname.slice('/admin'.length);
21
+ // Tolerate exactly one trailing slash; a doubled one leaves an empty segment behind.
22
+ if (rest.endsWith('/'))
23
+ rest = rest.slice(0, -1);
24
+ if (rest === '')
25
+ return { view: 'index' };
26
+ const rawSegments = rest.slice(1).split('/');
27
+ if (rawSegments.includes(''))
28
+ return null;
29
+ let segments;
30
+ try {
31
+ segments = rawSegments.map((segment) => decodeURIComponent(segment));
32
+ }
33
+ catch {
34
+ // Malformed percent encoding is an unrecognized shape, not a server error.
35
+ return null;
36
+ }
37
+ if (segments.length === 1) {
38
+ const [head] = segments;
39
+ if (head === 'login')
40
+ return { view: 'login' };
41
+ if (head === 'editors')
42
+ return { view: 'editors' };
43
+ if (head === 'nav')
44
+ return { view: 'nav' };
45
+ if (RESERVED_SEGMENTS.has(head))
46
+ return null;
47
+ const concept = findConcept(concepts, head);
48
+ return concept ? { view: 'list', concept } : null;
49
+ }
50
+ if (segments.length === 2) {
51
+ const [head, tail] = segments;
52
+ if (head === 'auth')
53
+ return tail === 'confirm' ? { view: 'confirm' } : null;
54
+ if (RESERVED_SEGMENTS.has(head))
55
+ return null;
56
+ const concept = findConcept(concepts, head);
57
+ if (!concept || !isValidId(tail))
58
+ return null;
59
+ return { view: 'edit', concept, id: tail };
60
+ }
61
+ return null;
62
+ }
@@ -0,0 +1,94 @@
1
+ import { type ContentRoutesDeps, type LayoutData, type ListData, type EditData } from './content-routes.js';
2
+ import { type NavLoadData } from './nav-routes.js';
3
+ import type { AuthBranding, SendMagicLink } from '../email.js';
4
+ import type { AuthEnv, Editor } from '../auth/types.js';
5
+ import type { GithubKeyEnv } from '../github/credentials.js';
6
+ import type { CairnRuntime } from '../content/types.js';
7
+ import type { CookieJar, EventBase } from './types.js';
8
+ /**
9
+ * The structural event the single-mount load reads: the union of what the wrapped loads need
10
+ * (ContentEvent minus params, which the dispatcher synthesizes, plus RequestContext's cookies
11
+ * and setHeaders). A real SvelteKit RequestEvent satisfies it.
12
+ */
13
+ export interface AdminEvent extends EventBase<GithubKeyEnv & AuthEnv> {
14
+ cookies: CookieJar;
15
+ setHeaders(headers: Record<string, string>): void;
16
+ }
17
+ /** Injectable dependencies. Branding defaults from the runtime's siteName and sender, so a
18
+ * site overrides it only to change the magic-link email identity; `send` and `mintToken`
19
+ * are the same seams the underlying factories take. */
20
+ export interface CairnAdminDeps {
21
+ branding?: AuthBranding;
22
+ send?: SendMagicLink;
23
+ mintToken?: ContentRoutesDeps['mintToken'];
24
+ }
25
+ /**
26
+ * One admin view's data, discriminated for the admin page component's switch. The public
27
+ * views (login, confirm) carry no layout; every authed view pairs the shared layout with its
28
+ * page data, the same shapes the per-surface loads have always returned.
29
+ */
30
+ export type AdminData = {
31
+ view: 'login';
32
+ page: {
33
+ siteName: string;
34
+ error: string | null;
35
+ csrf: string;
36
+ };
37
+ } | {
38
+ view: 'confirm';
39
+ page: {
40
+ token: string;
41
+ siteName: string;
42
+ error: string | null;
43
+ csrf: string;
44
+ };
45
+ } | {
46
+ view: 'list';
47
+ layout: LayoutData;
48
+ page: ListData;
49
+ } | {
50
+ view: 'edit';
51
+ layout: LayoutData;
52
+ page: EditData;
53
+ } | {
54
+ view: 'editors';
55
+ layout: LayoutData;
56
+ page: {
57
+ editors: Editor[];
58
+ self: string;
59
+ };
60
+ } | {
61
+ view: 'nav';
62
+ layout: LayoutData;
63
+ page: NavLoadData;
64
+ };
65
+ export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdminDeps): {
66
+ load: (event: AdminEvent) => Promise<AdminData>;
67
+ actions: {
68
+ request: (event: AdminEvent) => Promise<import("./auth-routes.js").RequestResult>;
69
+ confirm: (event: AdminEvent) => Promise<never>;
70
+ logout: (event: AdminEvent) => Promise<never>;
71
+ create: (event: AdminEvent) => Promise<never>;
72
+ save: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
73
+ publish: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
74
+ discard: (event: AdminEvent) => Promise<never>;
75
+ rename: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
76
+ delete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
77
+ publishAll: (event: AdminEvent) => Promise<never>;
78
+ addEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
79
+ error: string;
80
+ }> | {
81
+ ok: true;
82
+ }>;
83
+ removeEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
84
+ error: string;
85
+ }> | {
86
+ ok: true;
87
+ }>;
88
+ setRole: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
89
+ error: string;
90
+ }> | {
91
+ ok: true;
92
+ }>;
93
+ };
94
+ };
@@ -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';
5
- import type { CairnRuntime, FrontmatterField } from '../content/types.js';
6
- import type { Editor, Role } from '../auth/types.js';
4
+ import type { CookieJar, EventBase } from './types.js';
5
+ import type { CairnRuntime, FrontmatterField, ResolvedPreview } from '../content/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;
@@ -87,27 +87,51 @@ export interface EditData {
87
87
  publishedFlash: boolean;
88
88
  /** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
89
89
  discardedFlash: boolean;
90
+ /** The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
91
+ * when one exists, applied over the top-level values); null when the site sets none, which
92
+ * leaves the frame rendering unstyled markup behind a hint. */
93
+ preview: ResolvedPreview | null;
90
94
  }
91
95
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
92
- export interface ContentEvent {
93
- url: URL;
96
+ export interface ContentEvent extends EventBase<GithubKeyEnv> {
94
97
  params: Record<string, string>;
95
- request: Request;
96
- locals: {
97
- editor?: Editor | null;
98
- };
99
- platform?: {
100
- env?: GithubKeyEnv;
101
- };
102
98
  /** SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
103
99
  * token. Optional for non-route callers. */
104
100
  cookies?: CookieJar;
105
101
  }
106
102
  /** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
107
103
  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>;
104
+ /** Mint a GitHub App installation token from the Worker env. Defaults to the real signer.
105
+ * A bare string works too; the routes await whatever comes back. */
106
+ mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
107
+ }
108
+ /** A blocked save or publish: `fail(400)` when the body links to a target absent from main. */
109
+ export interface SaveFailure {
110
+ /** The one-line human summary every content action failure carries. */
111
+ error: string;
112
+ /** The cairn tokens that resolve to no entry, for the editor's fix-it banner. */
113
+ brokenLinks: string[];
114
+ /** The author's edited markdown, so the editor reseeds with the unsaved work. */
115
+ body: string;
116
+ }
117
+ /** A refused delete: `fail(409)` while other entries still link to this one. */
118
+ export interface DeleteRefusal {
119
+ /** The one-line human summary every content action failure carries. */
120
+ error: string;
121
+ /** The entries whose bodies link to the refused one, for the blockers list. */
122
+ inboundLinks: InboundLink[];
123
+ /** The refused entry's id, so a list view marks the right row. */
124
+ id: string;
125
+ }
126
+ /** A refused rename: `fail(400)` on a bad slug, `fail(409)` on a collision or pending edits. */
127
+ export interface RenameFailure {
128
+ /** The one-line human summary every content action failure carries. */
129
+ error: string;
110
130
  }
131
+ /** What a route's single `form` export presents to a view component: whichever content action
132
+ * last failed, merged with every field optional. `error` is always set on a failure; the richer
133
+ * keys identify which guard refused. */
134
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
111
135
  export declare function createContentRoutes(runtime: CairnRuntime, deps?: ContentRoutesDeps): {
112
136
  layoutLoad: (event: ContentEvent) => Promise<LayoutData>;
113
137
  indexRedirect: () => never;
@@ -121,5 +145,5 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
121
145
  deleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
122
146
  listDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
123
147
  renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
124
- mintToken: (env: GithubKeyEnv) => Promise<string>;
148
+ mintToken: (env: GithubKeyEnv) => string | Promise<string>;
125
149
  };