@glw907/cairn-cms 0.5.1 → 0.6.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/crypto.d.ts +13 -0
- package/dist/auth/crypto.d.ts.map +1 -0
- package/dist/auth/crypto.js +31 -0
- package/dist/auth/store.d.ts +41 -0
- package/dist/auth/store.d.ts.map +1 -0
- package/dist/auth/store.js +115 -0
- package/dist/auth/types.d.ts +25 -0
- package/dist/auth/types.d.ts.map +1 -0
- package/dist/auth/types.js +1 -0
- package/dist/components/AdminLayout.svelte +58 -164
- package/dist/components/AdminLayout.svelte.d.ts +14 -18
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/ComponentPalette.svelte +36 -20
- package/dist/components/ComponentPalette.svelte.d.ts +11 -4
- package/dist/components/ComponentPalette.svelte.d.ts.map +1 -1
- package/dist/components/ConceptList.svelte +81 -0
- package/dist/components/ConceptList.svelte.d.ts +13 -0
- package/dist/components/ConceptList.svelte.d.ts.map +1 -0
- package/dist/components/ConfirmPage.svelte +23 -20
- package/dist/components/ConfirmPage.svelte.d.ts +6 -0
- package/dist/components/ConfirmPage.svelte.d.ts.map +1 -1
- package/dist/components/EditPage.svelte +155 -136
- package/dist/components/EditPage.svelte.d.ts +16 -8
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/LoginPage.svelte +42 -52
- package/dist/components/LoginPage.svelte.d.ts +12 -0
- package/dist/components/LoginPage.svelte.d.ts.map +1 -1
- package/dist/components/ManageEditors.svelte +81 -0
- package/dist/components/ManageEditors.svelte.d.ts +23 -0
- package/dist/components/ManageEditors.svelte.d.ts.map +1 -0
- package/dist/components/MarkdownEditor.svelte +81 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +20 -0
- package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -0
- package/dist/components/NavTree.svelte +73 -63
- package/dist/components/NavTree.svelte.d.ts +13 -4
- package/dist/components/NavTree.svelte.d.ts.map +1 -1
- package/dist/components/cairn-admin.css +42 -0
- package/dist/components/index.d.ts +3 -2
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +5 -4
- package/dist/content/compose.d.ts +7 -0
- package/dist/content/compose.d.ts.map +1 -0
- package/dist/content/compose.js +32 -0
- package/dist/content/concepts.d.ts +17 -0
- package/dist/content/concepts.d.ts.map +1 -0
- package/dist/content/concepts.js +41 -0
- package/dist/content/frontmatter.d.ts +18 -0
- package/dist/content/frontmatter.d.ts.map +1 -0
- package/dist/content/frontmatter.js +58 -0
- package/dist/content/ids.d.ts +17 -0
- package/dist/content/ids.d.ts.map +1 -0
- package/dist/content/ids.js +33 -0
- package/dist/content/types.d.ts +210 -0
- package/dist/content/types.d.ts.map +1 -0
- package/dist/content/types.js +1 -0
- package/dist/content/validate.d.ts +13 -0
- package/dist/content/validate.d.ts.map +1 -0
- package/dist/content/validate.js +45 -0
- package/dist/email.d.ts +25 -12
- package/dist/email.d.ts.map +1 -1
- package/dist/email.js +24 -24
- package/dist/env.d.ts +24 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +29 -0
- package/dist/github/credentials.d.ts +12 -0
- package/dist/github/credentials.d.ts.map +1 -0
- package/dist/github/credentials.js +11 -0
- package/dist/github/repo.d.ts +49 -0
- package/dist/github/repo.d.ts.map +1 -0
- package/dist/github/repo.js +123 -0
- package/dist/github/signing.d.ts +17 -0
- package/dist/github/signing.d.ts.map +1 -0
- package/dist/github/signing.js +79 -0
- package/dist/github/types.d.ts +35 -0
- package/dist/github/types.d.ts.map +1 -0
- package/dist/github/types.js +19 -0
- package/dist/index.d.ts +27 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -10
- package/dist/{nav.d.ts → nav/site-config.d.ts} +16 -24
- package/dist/nav/site-config.d.ts.map +1 -0
- package/dist/{nav.js → nav/site-config.js} +27 -13
- package/dist/render/glyph.d.ts +1 -1
- package/dist/render/glyph.d.ts.map +1 -1
- package/dist/render/index.d.ts +5 -5
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +6 -6
- package/dist/render/pipeline.d.ts +7 -6
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +5 -5
- package/dist/render/registry.d.ts +10 -6
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/registry.js +8 -6
- package/dist/render/rehype-dispatch.d.ts +8 -7
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.js +16 -14
- package/dist/render/remark-directives.d.ts +1 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/sanitize.d.ts +8 -0
- package/dist/render/sanitize.d.ts.map +1 -0
- package/dist/render/sanitize.js +26 -0
- package/dist/sveltekit/auth-routes.d.ts +23 -0
- package/dist/sveltekit/auth-routes.d.ts.map +1 -0
- package/dist/sveltekit/auth-routes.js +85 -0
- package/dist/sveltekit/content-routes.d.ts +80 -0
- package/dist/sveltekit/content-routes.d.ts.map +1 -0
- package/dist/sveltekit/content-routes.js +183 -0
- package/dist/sveltekit/editors-routes.d.ts +24 -0
- package/dist/sveltekit/editors-routes.d.ts.map +1 -0
- package/dist/sveltekit/editors-routes.js +73 -0
- package/dist/sveltekit/guard.d.ts +9 -0
- package/dist/sveltekit/guard.d.ts.map +1 -0
- package/dist/sveltekit/guard.js +43 -0
- package/dist/sveltekit/health.d.ts +19 -0
- package/dist/sveltekit/health.d.ts.map +1 -0
- package/dist/sveltekit/health.js +12 -0
- package/dist/sveltekit/index.d.ts +9 -173
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +8 -348
- package/dist/sveltekit/nav-routes.d.ts +30 -0
- package/dist/sveltekit/nav-routes.d.ts.map +1 -0
- package/dist/sveltekit/nav-routes.js +103 -0
- package/dist/sveltekit/types.d.ts +32 -0
- package/dist/sveltekit/types.d.ts.map +1 -0
- package/dist/sveltekit/types.js +1 -0
- package/package.json +33 -57
- package/src/lib/auth/crypto.ts +37 -0
- package/src/lib/auth/store.ts +158 -0
- package/src/lib/auth/types.ts +27 -0
- package/src/lib/components/AdminLayout.svelte +58 -164
- package/src/lib/components/ComponentPalette.svelte +36 -20
- package/src/lib/components/ConceptList.svelte +81 -0
- package/src/lib/components/ConfirmPage.svelte +23 -20
- package/src/lib/components/EditPage.svelte +155 -136
- package/src/lib/components/LoginPage.svelte +42 -52
- package/src/lib/components/ManageEditors.svelte +81 -0
- package/src/lib/components/MarkdownEditor.svelte +81 -0
- package/src/lib/components/NavTree.svelte +73 -63
- package/src/lib/components/cairn-admin.css +42 -0
- package/src/lib/components/index.ts +5 -4
- package/src/lib/content/compose.ts +39 -0
- package/src/lib/content/concepts.ts +57 -0
- package/src/lib/content/frontmatter.ts +71 -0
- package/src/lib/content/ids.ts +38 -0
- package/src/lib/content/types.ts +235 -0
- package/src/lib/content/validate.ts +51 -0
- package/src/lib/email.ts +52 -38
- package/src/lib/env.ts +32 -0
- package/src/lib/github/credentials.ts +27 -0
- package/src/lib/github/repo.ts +138 -0
- package/src/lib/github/signing.ts +97 -0
- package/src/lib/github/types.ts +46 -0
- package/src/lib/index.ts +86 -10
- package/src/lib/{nav.ts → nav/site-config.ts} +31 -24
- package/src/lib/render/glyph.ts +6 -6
- package/src/lib/render/index.ts +6 -6
- package/src/lib/render/pipeline.ts +23 -22
- package/src/lib/render/registry.ts +35 -26
- package/src/lib/render/rehype-dispatch.ts +58 -56
- package/src/lib/render/remark-directives.ts +46 -46
- package/src/lib/render/sanitize.ts +27 -0
- package/src/lib/sveltekit/auth-routes.ts +107 -0
- package/src/lib/sveltekit/content-routes.ts +261 -0
- package/src/lib/sveltekit/editors-routes.ts +82 -0
- package/src/lib/sveltekit/guard.ts +47 -0
- package/src/lib/sveltekit/health.ts +24 -0
- package/src/lib/sveltekit/index.ts +19 -512
- package/src/lib/sveltekit/nav-routes.ts +139 -0
- package/src/lib/sveltekit/types.ts +33 -0
- package/dist/adapter.d.ts +0 -93
- package/dist/adapter.d.ts.map +0 -1
- package/dist/adapter.js +0 -30
- package/dist/auth/admins.d.ts +0 -33
- package/dist/auth/admins.d.ts.map +0 -1
- package/dist/auth/admins.js +0 -90
- package/dist/auth/capabilities.d.ts +0 -7
- package/dist/auth/capabilities.d.ts.map +0 -1
- package/dist/auth/capabilities.js +0 -26
- package/dist/auth/config.d.ts +0 -2097
- package/dist/auth/config.d.ts.map +0 -1
- package/dist/auth/config.js +0 -78
- package/dist/auth/guard.d.ts +0 -34
- package/dist/auth/guard.d.ts.map +0 -1
- package/dist/auth/guard.js +0 -47
- package/dist/auth/index.d.ts +0 -5
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -7
- package/dist/auth/schema.d.ts +0 -750
- package/dist/auth/schema.d.ts.map +0 -1
- package/dist/auth/schema.js +0 -93
- package/dist/carta.d.ts +0 -39
- package/dist/carta.d.ts.map +0 -1
- package/dist/carta.js +0 -30
- package/dist/components/CollectionList.svelte +0 -96
- package/dist/components/CollectionList.svelte.d.ts +0 -8
- package/dist/components/CollectionList.svelte.d.ts.map +0 -1
- package/dist/components/ManageAdmins.svelte +0 -84
- package/dist/components/ManageAdmins.svelte.d.ts +0 -10
- package/dist/components/ManageAdmins.svelte.d.ts.map +0 -1
- package/dist/content.d.ts +0 -3
- package/dist/content.d.ts.map +0 -1
- package/dist/content.js +0 -10
- package/dist/editor.d.ts +0 -25
- package/dist/editor.d.ts.map +0 -1
- package/dist/editor.js +0 -20
- package/dist/frontmatter.d.ts +0 -3
- package/dist/frontmatter.d.ts.map +0 -1
- package/dist/frontmatter.js +0 -16
- package/dist/github.d.ts +0 -72
- package/dist/github.d.ts.map +0 -1
- package/dist/github.js +0 -171
- package/dist/nav.d.ts.map +0 -1
- package/dist/slug.d.ts +0 -7
- package/dist/slug.d.ts.map +0 -1
- package/dist/slug.js +0 -15
- package/dist/utils.d.ts +0 -3
- package/dist/utils.d.ts.map +0 -1
- package/dist/utils.js +0 -11
- package/src/lib/adapter.ts +0 -144
- package/src/lib/auth/admins.ts +0 -106
- package/src/lib/auth/capabilities.ts +0 -35
- package/src/lib/auth/config.ts +0 -108
- package/src/lib/auth/guard.ts +0 -60
- package/src/lib/auth/index.ts +0 -7
- package/src/lib/auth/schema.ts +0 -112
- package/src/lib/carta.ts +0 -59
- package/src/lib/components/CollectionList.svelte +0 -96
- package/src/lib/components/ManageAdmins.svelte +0 -84
- package/src/lib/content.ts +0 -11
- package/src/lib/editor.ts +0 -38
- package/src/lib/frontmatter.ts +0 -17
- package/src/lib/github.ts +0 -220
- package/src/lib/slug.ts +0 -16
- package/src/lib/utils.ts +0 -12
|
@@ -1,512 +1,19 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
signingSelfTest,
|
|
21
|
-
CommitConflictError,
|
|
22
|
-
type RepoFile,
|
|
23
|
-
} from '../github';
|
|
24
|
-
import { serializeMarkdown } from '../content';
|
|
25
|
-
import { findCollection, frontmatterFromForm, type CairnAdapter, type CairnField } from '../adapter';
|
|
26
|
-
import { validateNavTree, extractMenu, parseSiteConfig, setMenu, type NavNode } from '../nav';
|
|
27
|
-
|
|
28
|
-
/** The `platform.env` bindings the content routes read. All optional; the handlers guard. */
|
|
29
|
-
export interface AdminEnv {
|
|
30
|
-
GITHUB_APP_ID?: string;
|
|
31
|
-
GITHUB_APP_INSTALLATION_ID?: string;
|
|
32
|
-
GITHUB_APP_PRIVATE_KEY_B64?: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface PlatformEvent {
|
|
36
|
-
platform?: { env?: AdminEnv };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Mint a GitHub App installation token for *reads* when the App is configured, else undefined
|
|
41
|
-
* (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
|
|
42
|
-
* reads share GitHub's 60/hr-per-IP budget across Cloudflare's egress IPs, so they 403 in prod.
|
|
43
|
-
* A mint failure degrades gracefully to anonymous rather than 500ing. Unlike the commit path,
|
|
44
|
-
* where a missing App is fatal, a read can still succeed unauthenticated.
|
|
45
|
-
*/
|
|
46
|
-
async function readToken(env: AdminEnv | undefined): Promise<string | undefined> {
|
|
47
|
-
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
48
|
-
return undefined;
|
|
49
|
-
}
|
|
50
|
-
try {
|
|
51
|
-
return await installationToken({
|
|
52
|
-
appId: env.GITHUB_APP_ID,
|
|
53
|
-
installationId: env.GITHUB_APP_INSTALLATION_ID,
|
|
54
|
-
privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
|
|
55
|
-
});
|
|
56
|
-
} catch (err) {
|
|
57
|
-
console.error('read token mint failed; falling back to anonymous read:', err);
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ── /admin layout ──────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
/** A collection reduced to what the sidebar nav needs (no plugin graph crosses to the client). */
|
|
65
|
-
export interface NavCollection {
|
|
66
|
-
type: string;
|
|
67
|
-
label: string;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export interface AdminLayoutData {
|
|
71
|
-
user: CairnUser | null;
|
|
72
|
-
siteName: string;
|
|
73
|
-
pathname: string;
|
|
74
|
-
collections: NavCollection[];
|
|
75
|
-
/** Managed menus (name+label only) so the shell can show a Navigation entry. */
|
|
76
|
-
navMenus: { name: string; label: string }[];
|
|
77
|
-
/** Whether the viewer may manage navigation (gates the Navigation nav entry). */
|
|
78
|
-
canManageNav: boolean;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Branding, session, and collection nav for every admin page. `siteName` and the collection
|
|
83
|
-
* list flow from the adapter without pulling its plugin graph into client bundles (the import
|
|
84
|
-
* stays server-side in the layout load; only `{type,label}` crosses). `pathname` lets the
|
|
85
|
-
* shared shell highlight the active nav item without a `$app/*` import (those kit virtual
|
|
86
|
-
* modules have no types outside a kit app); reading `event.url` also opts the layout load into
|
|
87
|
-
* rerunning on navigation, keeping the active class correct.
|
|
88
|
-
*/
|
|
89
|
-
export function adminLayoutLoad(
|
|
90
|
-
event: { locals: { user: CairnUser | null }; url: URL },
|
|
91
|
-
adapter: CairnAdapter,
|
|
92
|
-
): AdminLayoutData {
|
|
93
|
-
return {
|
|
94
|
-
user: event.locals.user,
|
|
95
|
-
siteName: adapter.siteName,
|
|
96
|
-
pathname: event.url.pathname,
|
|
97
|
-
collections: adapter.collections.map(({ type, label }) => ({ type, label })),
|
|
98
|
-
navMenus: adapter.navMenu ? [{ name: adapter.navMenu.menuName, label: adapter.navMenu.label }] : [],
|
|
99
|
-
canManageNav: can(event.locals.user, 'nav:manage'),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The `/admin` index has no content of its own now that each collection is its own page; send
|
|
105
|
-
* the editor straight to the first collection's entries list (a Sveltia-style landing).
|
|
106
|
-
*/
|
|
107
|
-
export function adminIndexRedirect(adapter: CairnAdapter): never {
|
|
108
|
-
const first = adapter.collections[0];
|
|
109
|
-
if (!first) throw error(404, 'No collections configured');
|
|
110
|
-
throw redirect(307, `/admin/${first.type}`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// ── /admin/[collection] (entries list) ─────────────────────────────────────
|
|
114
|
-
|
|
115
|
-
/** One entry row: id (filename stem), display title, optional date, draft flag. */
|
|
116
|
-
export interface CollectionEntry {
|
|
117
|
-
id: string;
|
|
118
|
-
path: string;
|
|
119
|
-
title: string;
|
|
120
|
-
date: string | null;
|
|
121
|
-
draft: boolean;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface CollectionListData {
|
|
125
|
-
type: string;
|
|
126
|
-
label: string;
|
|
127
|
-
kind: 'page' | 'story';
|
|
128
|
-
entries: CollectionEntry[];
|
|
129
|
-
/** Set when the directory listing itself failed (rate limit, network). */
|
|
130
|
-
error?: string;
|
|
131
|
-
/** A create-flow error bounced back via `?error=` (an invalid or taken slug). */
|
|
132
|
-
formError: string | null;
|
|
133
|
-
/** Whether the viewer may create an entry in this collection (page-create is owner-only). */
|
|
134
|
-
canCreate: boolean;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Coerce a frontmatter `date` (gray-matter may parse YAML dates to `Date`) to `YYYY-MM-DD`. */
|
|
138
|
-
function entryDate(value: unknown): string | null {
|
|
139
|
-
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
|
140
|
-
if (typeof value === 'string') return value;
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* List one collection's entries, reading each file's frontmatter for the display title, date,
|
|
146
|
-
* and draft badge. Reads run in parallel; a single failed read degrades that row to the slug
|
|
147
|
-
* (rather than failing the page), and a failed directory listing returns an inline `error`.
|
|
148
|
-
* Collections are small here; the 1,000-entry / Git-Trees sharding concern is risk #11, deferred.
|
|
149
|
-
*/
|
|
150
|
-
export async function collectionListLoad(
|
|
151
|
-
event: PlatformEvent & { params: { collection: string }; url: URL; locals: { user: CairnUser | null } },
|
|
152
|
-
adapter: CairnAdapter,
|
|
153
|
-
): Promise<CollectionListData> {
|
|
154
|
-
const collection = findCollection(adapter, event.params.collection);
|
|
155
|
-
if (!collection) throw error(404, 'Unknown collection');
|
|
156
|
-
|
|
157
|
-
const kind = collection.kind ?? 'story';
|
|
158
|
-
const canCreate = can(event.locals.user, kind === 'page' ? 'page:create' : 'story:create');
|
|
159
|
-
const formError = event.url.searchParams.get('error');
|
|
160
|
-
const token = await readToken(event.platform?.env);
|
|
161
|
-
|
|
162
|
-
let files: RepoFile[];
|
|
163
|
-
try {
|
|
164
|
-
files = await listMarkdown(adapter.backend, collection.dir, token);
|
|
165
|
-
} catch (err) {
|
|
166
|
-
return {
|
|
167
|
-
type: collection.type,
|
|
168
|
-
label: collection.label,
|
|
169
|
-
kind,
|
|
170
|
-
entries: [],
|
|
171
|
-
error: err instanceof Error ? err.message : 'Failed to load',
|
|
172
|
-
formError,
|
|
173
|
-
canCreate,
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const entries = await Promise.all(
|
|
178
|
-
files.map(async (file): Promise<CollectionEntry> => {
|
|
179
|
-
const fallback: CollectionEntry = {
|
|
180
|
-
id: file.id,
|
|
181
|
-
path: file.path,
|
|
182
|
-
title: file.id,
|
|
183
|
-
date: null,
|
|
184
|
-
draft: false,
|
|
185
|
-
};
|
|
186
|
-
try {
|
|
187
|
-
const raw = await readRaw(adapter.backend, file.path, token);
|
|
188
|
-
if (raw === null) return fallback;
|
|
189
|
-
const { data } = matter(raw);
|
|
190
|
-
return {
|
|
191
|
-
id: file.id,
|
|
192
|
-
path: file.path,
|
|
193
|
-
title: typeof data.title === 'string' ? data.title : file.id,
|
|
194
|
-
date: entryDate(data.date),
|
|
195
|
-
draft: data.draft === true,
|
|
196
|
-
};
|
|
197
|
-
} catch {
|
|
198
|
-
return fallback;
|
|
199
|
-
}
|
|
200
|
-
}),
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
type: collection.type,
|
|
205
|
-
label: collection.label,
|
|
206
|
-
kind,
|
|
207
|
-
entries,
|
|
208
|
-
formError,
|
|
209
|
-
canCreate,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ── /admin/[collection]?/create (POST) ─────────────────────────────────────
|
|
214
|
-
|
|
215
|
-
/** A safe filename stem: starts and ends with a lowercase alphanumeric, hyphens allowed within. */
|
|
216
|
-
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* The "New entry" form action. Validates the requested slug, rejects one that already exists,
|
|
220
|
-
* then redirects into the editor in create mode (`?new=1`, where `editLoad` serves a blank
|
|
221
|
-
* document and `saveCommit`'s create path commits a new file). cairn is filename-based, so the
|
|
222
|
-
* slug is the filename stem the author types; a title-driven auto-slug is a later (Pass K) concern.
|
|
223
|
-
*/
|
|
224
|
-
export async function createEntry(
|
|
225
|
-
event: PlatformEvent & {
|
|
226
|
-
params: { collection: string };
|
|
227
|
-
locals: { user: CairnUser | null };
|
|
228
|
-
request: Request;
|
|
229
|
-
},
|
|
230
|
-
adapter: CairnAdapter,
|
|
231
|
-
): Promise<never> {
|
|
232
|
-
const collection = findCollection(adapter, event.params.collection);
|
|
233
|
-
if (!collection) throw error(404, 'Unknown collection');
|
|
234
|
-
const kind = collection.kind ?? 'story';
|
|
235
|
-
requireCapability(event.locals.user, kind === 'page' ? 'page:create' : 'story:create');
|
|
236
|
-
|
|
237
|
-
const form = await event.request.formData();
|
|
238
|
-
const id = String(form.get('id') ?? '').trim();
|
|
239
|
-
const back = (message: string) =>
|
|
240
|
-
redirect(303, `/admin/${collection.type}?error=${encodeURIComponent(message)}`);
|
|
241
|
-
|
|
242
|
-
if (!SLUG_RE.test(id)) {
|
|
243
|
-
throw back('Enter a slug using lowercase letters, numbers, and hyphens (for example 2026-05-my-entry).');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const token = await readToken(event.platform?.env);
|
|
247
|
-
const existing = await readRaw(adapter.backend, `${collection.dir}/${id}.md`, token);
|
|
248
|
-
if (existing !== null) throw back(`An entry named "${id}" already exists.`);
|
|
249
|
-
const date = String(form.get('date') ?? '').trim();
|
|
250
|
-
const dateSuffix = kind === 'story' && date ? `&date=${encodeURIComponent(date)}` : '';
|
|
251
|
-
|
|
252
|
-
throw redirect(303, `/admin/edit/${collection.type}/${id}?new=1${dateSuffix}`);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ── /admin/edit/[type]/[id] ─────────────────────────────────────────────────
|
|
256
|
-
|
|
257
|
-
export interface EditData {
|
|
258
|
-
type: string;
|
|
259
|
-
id: string;
|
|
260
|
-
label: string;
|
|
261
|
-
kind: 'page' | 'story';
|
|
262
|
-
fields: CairnField[];
|
|
263
|
-
path: string;
|
|
264
|
-
body: string;
|
|
265
|
-
frontmatter: Record<string, unknown>;
|
|
266
|
-
title: string;
|
|
267
|
-
saved: boolean;
|
|
268
|
-
error: string | null;
|
|
269
|
-
/** True when editing a not-yet-committed new entry (reached via `?new=1`). */
|
|
270
|
-
isNew: boolean;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export async function editLoad(
|
|
274
|
-
event: PlatformEvent & { params: { type: string; id: string }; url: URL },
|
|
275
|
-
adapter: CairnAdapter,
|
|
276
|
-
): Promise<EditData> {
|
|
277
|
-
const collection = findCollection(adapter, event.params.type);
|
|
278
|
-
if (!collection) throw error(404, 'Unknown collection');
|
|
279
|
-
|
|
280
|
-
const token = await readToken(event.platform?.env);
|
|
281
|
-
const path = `${collection.dir}/${event.params.id}.md`;
|
|
282
|
-
const raw = await readRaw(adapter.backend, path, token);
|
|
283
|
-
const isNew = event.url.searchParams.get('new') === '1';
|
|
284
|
-
|
|
285
|
-
// A missing file is a 404 normally, but in create mode (`?new=1`) it's a blank new document.
|
|
286
|
-
if (raw === null && !isNew) throw error(404, 'Content not found');
|
|
287
|
-
|
|
288
|
-
// Split frontmatter from body server-side; the editor form binds to the frontmatter and the
|
|
289
|
-
// Carta editor to the body, and /admin/save reassembles them on commit. A new document starts
|
|
290
|
-
// empty so the author fills the fields from scratch.
|
|
291
|
-
const { data: frontmatter, content: body } =
|
|
292
|
-
raw === null ? { data: {} as Record<string, unknown>, content: '' } : matter(raw);
|
|
293
|
-
|
|
294
|
-
const seedDate = event.url.searchParams.get('date');
|
|
295
|
-
if (isNew && seedDate && frontmatter.date === undefined) {
|
|
296
|
-
frontmatter.date = seedDate;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return {
|
|
300
|
-
type: event.params.type,
|
|
301
|
-
id: event.params.id,
|
|
302
|
-
label: collection.label,
|
|
303
|
-
kind: collection.kind ?? 'story',
|
|
304
|
-
fields: collection.fields,
|
|
305
|
-
path,
|
|
306
|
-
body,
|
|
307
|
-
frontmatter,
|
|
308
|
-
title: typeof frontmatter.title === 'string' ? frontmatter.title : event.params.id,
|
|
309
|
-
saved: event.url.searchParams.get('saved') === '1',
|
|
310
|
-
error: event.url.searchParams.get('error'),
|
|
311
|
-
isNew,
|
|
312
|
-
};
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// ── /admin/save (POST) ──────────────────────────────────────────────────────
|
|
316
|
-
|
|
317
|
-
export async function saveCommit(
|
|
318
|
-
event: PlatformEvent & { request: Request; locals: { user: CairnUser | null } },
|
|
319
|
-
adapter: CairnAdapter,
|
|
320
|
-
): Promise<never> {
|
|
321
|
-
const user = event.locals.user;
|
|
322
|
-
if (!user) throw error(401, 'Not signed in');
|
|
323
|
-
|
|
324
|
-
const env = event.platform?.env;
|
|
325
|
-
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
326
|
-
throw error(500, 'GitHub App is not configured');
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const form = await event.request.formData();
|
|
330
|
-
const type = String(form.get('type') ?? '');
|
|
331
|
-
const id = String(form.get('id') ?? '');
|
|
332
|
-
const body = String(form.get('body') ?? '');
|
|
333
|
-
const newSuffix = form.get('new') === '1' ? '&new=1' : '';
|
|
334
|
-
const collection = findCollection(adapter, type);
|
|
335
|
-
if (!collection || !id) throw error(400, 'Bad request');
|
|
336
|
-
|
|
337
|
-
// Build frontmatter from the posted fields and validate against the collection's schema; a
|
|
338
|
-
// bad field bounces back to the editor with the validator's message rather than 500ing.
|
|
339
|
-
let frontmatter: object;
|
|
340
|
-
try {
|
|
341
|
-
frontmatter = collection.validate(frontmatterFromForm(collection, form), `${id}.md`);
|
|
342
|
-
} catch (err) {
|
|
343
|
-
const message = err instanceof Error ? err.message : 'Invalid frontmatter';
|
|
344
|
-
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}${newSuffix}`);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const markdown = serializeMarkdown(frontmatter, body);
|
|
348
|
-
const token = await installationToken({
|
|
349
|
-
appId: env.GITHUB_APP_ID,
|
|
350
|
-
installationId: env.GITHUB_APP_INSTALLATION_ID,
|
|
351
|
-
privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
try {
|
|
355
|
-
await commitFile(
|
|
356
|
-
adapter.backend,
|
|
357
|
-
`${collection.dir}/${id}.md`,
|
|
358
|
-
markdown,
|
|
359
|
-
{ message: `Update ${collection.label.toLowerCase()}: ${id}`, author: { name: user.name, email: user.email } },
|
|
360
|
-
token,
|
|
361
|
-
);
|
|
362
|
-
} catch (err) {
|
|
363
|
-
// Concurrent-edit 409 (C3): fail safe. Bounce back with a reload prompt; the editor reloads
|
|
364
|
-
// the current version and reapplies. Any other error is unexpected, so rethrow.
|
|
365
|
-
if (err instanceof CommitConflictError) {
|
|
366
|
-
const message = 'This file changed since you opened it. Reload and reapply your edits.';
|
|
367
|
-
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}${newSuffix}`);
|
|
368
|
-
}
|
|
369
|
-
throw err;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// ── /admin/nav (navigation tree) ───────────────────────────────────────────
|
|
376
|
-
|
|
377
|
-
/** A page the picker can insert: its display label and the URL the nav item points at. */
|
|
378
|
-
export interface NavPageOption {
|
|
379
|
-
label: string;
|
|
380
|
-
url: string;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
export interface NavLoadData {
|
|
384
|
-
menu: { name: string; label: string; maxDepth: number };
|
|
385
|
-
tree: NavNode[];
|
|
386
|
-
pages: NavPageOption[];
|
|
387
|
-
saved: boolean;
|
|
388
|
-
error: string | null;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/** List page-collection entries for the picker (one directory listing per page collection). */
|
|
392
|
-
async function navPageOptions(adapter: CairnAdapter, env: AdminEnv | undefined): Promise<NavPageOption[]> {
|
|
393
|
-
const token = await readToken(env);
|
|
394
|
-
const pageCollections = adapter.collections.filter((c) => (c.kind ?? 'story') === 'page');
|
|
395
|
-
const lists = await Promise.all(
|
|
396
|
-
pageCollections.map(async (c) => {
|
|
397
|
-
try {
|
|
398
|
-
const files = await listMarkdown(adapter.backend, c.dir, token);
|
|
399
|
-
return files.map((f): NavPageOption => ({ label: f.id, url: `/${f.id}` }));
|
|
400
|
-
} catch {
|
|
401
|
-
return [];
|
|
402
|
-
}
|
|
403
|
-
}),
|
|
404
|
-
);
|
|
405
|
-
return lists.flat();
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
export async function navLoad(
|
|
409
|
-
event: PlatformEvent & { locals: { user: CairnUser | null }; url: URL },
|
|
410
|
-
adapter: CairnAdapter,
|
|
411
|
-
): Promise<NavLoadData> {
|
|
412
|
-
requireCapability(event.locals.user, 'nav:manage');
|
|
413
|
-
const config = adapter.navMenu;
|
|
414
|
-
if (!config) throw error(404, 'No navigation menu configured');
|
|
415
|
-
const maxDepth = config.maxDepth ?? 2;
|
|
416
|
-
const menu = { name: config.menuName, label: config.label, maxDepth };
|
|
417
|
-
|
|
418
|
-
// Read the menu from the committed YAML. A missing/unparsable file degrades to an empty tree so
|
|
419
|
-
// the editor still loads (a first edit then creates the menu); only the read itself is best-effort.
|
|
420
|
-
const token = await readToken(event.platform?.env);
|
|
421
|
-
let tree: NavNode[] = [];
|
|
422
|
-
try {
|
|
423
|
-
const raw = await readRaw(adapter.backend, config.configPath, token);
|
|
424
|
-
if (raw !== null) tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
|
|
425
|
-
} catch (err) {
|
|
426
|
-
console.error(`cairn nav: failed to read "${config.configPath}":`, err);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
return {
|
|
430
|
-
menu,
|
|
431
|
-
tree,
|
|
432
|
-
pages: await navPageOptions(adapter, event.platform?.env),
|
|
433
|
-
saved: event.url.searchParams.get('saved') === '1',
|
|
434
|
-
error: event.url.searchParams.get('error'),
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
export async function navSave(
|
|
439
|
-
event: PlatformEvent & { locals: { user: CairnUser | null }; request: Request },
|
|
440
|
-
adapter: CairnAdapter,
|
|
441
|
-
): Promise<never> {
|
|
442
|
-
const user = requireCapability(event.locals.user, 'nav:manage');
|
|
443
|
-
const config = adapter.navMenu;
|
|
444
|
-
if (!config) throw error(404, 'No navigation menu configured');
|
|
445
|
-
const maxDepth = config.maxDepth ?? 2;
|
|
446
|
-
|
|
447
|
-
const env = event.platform?.env;
|
|
448
|
-
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
449
|
-
throw error(500, 'GitHub App is not configured');
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
const form = await event.request.formData();
|
|
453
|
-
let tree: NavNode[];
|
|
454
|
-
try {
|
|
455
|
-
tree = validateNavTree(JSON.parse(String(form.get('tree') ?? '[]')), maxDepth);
|
|
456
|
-
} catch (err) {
|
|
457
|
-
const message = err instanceof Error ? err.message : 'Invalid navigation';
|
|
458
|
-
throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
const token = await installationToken({
|
|
462
|
-
appId: env.GITHUB_APP_ID,
|
|
463
|
-
installationId: env.GITHUB_APP_INSTALLATION_ID,
|
|
464
|
-
privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
// Read-modify-commit: replace only this menu in the current file, preserving the rest.
|
|
468
|
-
const raw = await readRaw(adapter.backend, config.configPath, token);
|
|
469
|
-
if (raw === null) throw error(404, `Site config not found at ${config.configPath}`);
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
await commitFile(
|
|
473
|
-
adapter.backend,
|
|
474
|
-
config.configPath,
|
|
475
|
-
setMenu(raw, config.menuName, tree),
|
|
476
|
-
{ message: `Update ${config.label.toLowerCase()}`, author: { name: user.name, email: user.email } },
|
|
477
|
-
token,
|
|
478
|
-
);
|
|
479
|
-
} catch (err) {
|
|
480
|
-
// Concurrent-edit 409 (C3): fail safe, same as the content save path.
|
|
481
|
-
if (err instanceof CommitConflictError) {
|
|
482
|
-
const message = 'The site config changed since you opened it. Reload and reapply your edits.';
|
|
483
|
-
throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
|
|
484
|
-
}
|
|
485
|
-
throw err;
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
throw redirect(303, '/admin/nav?saved=1');
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// ── /admin/healthz (GET) ──────────────────────────────────────────────────────
|
|
492
|
-
|
|
493
|
-
export interface HealthData {
|
|
494
|
-
ok: boolean;
|
|
495
|
-
checks: { githubAppSigning: { ok: boolean; detail?: string } };
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* Deploy-time health check (M2): signs a dummy App JWT to prove the GitHub App key loads and
|
|
500
|
-
* the PKCS#1→PKCS#8 conversion still works, before an editor hits it on save. Behind the
|
|
501
|
-
* `/admin` guard (signed-in editors only); returns ok/fail with no secret in the body.
|
|
502
|
-
*/
|
|
503
|
-
export async function healthLoad(event: PlatformEvent): Promise<HealthData> {
|
|
504
|
-
const env = event.platform?.env;
|
|
505
|
-
let githubAppSigning: { ok: boolean; detail?: string };
|
|
506
|
-
if (env?.GITHUB_APP_ID && env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
507
|
-
githubAppSigning = await signingSelfTest(env.GITHUB_APP_ID, env.GITHUB_APP_PRIVATE_KEY_B64);
|
|
508
|
-
} else {
|
|
509
|
-
githubAppSigning = { ok: false, detail: 'GitHub App not configured' };
|
|
510
|
-
}
|
|
511
|
-
return { ok: githubAppSigning.ok, checks: { githubAppSigning } };
|
|
512
|
-
}
|
|
1
|
+
// SvelteKit server logic consumed by site route shims: the guard plus the auth, editor,
|
|
2
|
+
// content, and health route factories and functions.
|
|
3
|
+
export { createAuthGuard, requireSession, requireOwner } from './guard.js';
|
|
4
|
+
export { createAuthRoutes, type AuthRoutesConfig } from './auth-routes.js';
|
|
5
|
+
export { createEditorRoutes } from './editors-routes.js';
|
|
6
|
+
export { createContentRoutes } from './content-routes.js';
|
|
7
|
+
export type {
|
|
8
|
+
NavConcept,
|
|
9
|
+
LayoutData,
|
|
10
|
+
EntrySummary,
|
|
11
|
+
ListData,
|
|
12
|
+
EditData,
|
|
13
|
+
ContentEvent,
|
|
14
|
+
ContentRoutesDeps,
|
|
15
|
+
} from './content-routes.js';
|
|
16
|
+
export { createNavRoutes } from './nav-routes.js';
|
|
17
|
+
export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
|
|
18
|
+
export { healthLoad, type HealthData } from './health.js';
|
|
19
|
+
export type { RequestContext, CookieJar, HandleInput } from './types.js';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The admin nav-editing routes: the load and save a site's /admin/nav shim calls. A factory closes
|
|
2
|
+
// over the composed runtime and the GitHub token mint, mirroring createContentRoutes, so the read
|
|
3
|
+
// and commit paths are unit-testable against a fetch double with an injected token.
|
|
4
|
+
import { redirect, error } from '@sveltejs/kit';
|
|
5
|
+
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
6
|
+
import { installationToken } from '../github/signing.js';
|
|
7
|
+
import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
|
|
8
|
+
import { CommitConflictError } from '../github/types.js';
|
|
9
|
+
import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
|
|
10
|
+
import type { CairnRuntime } from '../content/types.js';
|
|
11
|
+
import type { ContentEvent } from './content-routes.js';
|
|
12
|
+
import type { Editor } from '../auth/types.js';
|
|
13
|
+
|
|
14
|
+
/** One page option for the URL picker datalist. */
|
|
15
|
+
export interface NavPageOption {
|
|
16
|
+
label: string;
|
|
17
|
+
url: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The nav editor's load data: the menu meta, the current tree, page options, and flags. */
|
|
21
|
+
export interface NavLoadData {
|
|
22
|
+
menu: { name: string; label: string; maxDepth: number };
|
|
23
|
+
tree: NavNode[];
|
|
24
|
+
pages: NavPageOption[];
|
|
25
|
+
saved: boolean;
|
|
26
|
+
error: string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Injectable dependencies; tests stub the token mint to avoid signing a real key. */
|
|
30
|
+
export interface NavRoutesDeps {
|
|
31
|
+
mintToken?: (env: GithubKeyEnv) => Promise<string>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The signed-in editor the guard resolved, or a login redirect. */
|
|
35
|
+
function sessionOf(event: ContentEvent): Editor {
|
|
36
|
+
const editor = event.locals.editor;
|
|
37
|
+
if (!editor) throw redirect(303, '/admin/login');
|
|
38
|
+
return editor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
42
|
+
function isConflict(err: unknown): boolean {
|
|
43
|
+
return err instanceof CommitConflictError || (err as { name?: string } | null)?.name === 'CommitConflictError';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
|
|
47
|
+
const mintToken =
|
|
48
|
+
deps.mintToken ?? ((env: GithubKeyEnv) => installationToken(appCredentials(runtime.backend, env)));
|
|
49
|
+
|
|
50
|
+
/** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
|
|
51
|
+
async function pageOptions(token: string): Promise<NavPageOption[]> {
|
|
52
|
+
const pageConcepts = runtime.concepts.filter((c) => c.routing.routable && !c.routing.dated);
|
|
53
|
+
const lists = await Promise.all(
|
|
54
|
+
pageConcepts.map(async (c) => {
|
|
55
|
+
try {
|
|
56
|
+
const files = await listMarkdown(runtime.backend, c.dir, token);
|
|
57
|
+
return files.map((f): NavPageOption => ({ label: f.id, url: `/${f.id}` }));
|
|
58
|
+
} catch {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
return lists.flat();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
|
|
67
|
+
async function navLoad(event: ContentEvent): Promise<NavLoadData> {
|
|
68
|
+
sessionOf(event);
|
|
69
|
+
const config = runtime.navMenu;
|
|
70
|
+
if (!config) throw error(404, 'No navigation menu configured');
|
|
71
|
+
const maxDepth = config.maxDepth ?? 2;
|
|
72
|
+
const menu = { name: config.menuName, label: config.label, maxDepth };
|
|
73
|
+
|
|
74
|
+
let token: string;
|
|
75
|
+
try {
|
|
76
|
+
token = await mintToken(event.platform?.env ?? {});
|
|
77
|
+
} catch {
|
|
78
|
+
return { menu, tree: [], pages: [], saved: false, error: 'Could not authenticate with GitHub.' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let tree: NavNode[] = [];
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readRaw(runtime.backend, config.configPath, token);
|
|
84
|
+
if (raw !== null) tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
|
|
85
|
+
} catch {
|
|
86
|
+
// A malformed or unreadable config degrades to an empty tree; the first save writes a clean menu.
|
|
87
|
+
tree = [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
menu,
|
|
92
|
+
tree,
|
|
93
|
+
pages: await pageOptions(token),
|
|
94
|
+
saved: event.url.searchParams.get('saved') === '1',
|
|
95
|
+
error: event.url.searchParams.get('error'),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
|
|
100
|
+
async function navSave(event: ContentEvent): Promise<never> {
|
|
101
|
+
const editor = sessionOf(event);
|
|
102
|
+
const config = runtime.navMenu;
|
|
103
|
+
if (!config) throw error(404, 'No navigation menu configured');
|
|
104
|
+
const maxDepth = config.maxDepth ?? 2;
|
|
105
|
+
|
|
106
|
+
const form = await event.request.formData();
|
|
107
|
+
let tree: NavNode[];
|
|
108
|
+
try {
|
|
109
|
+
tree = validateNavTree(JSON.parse(String(form.get('tree') ?? '[]')), maxDepth);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : 'Invalid navigation';
|
|
112
|
+
throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
116
|
+
const raw = await readRaw(runtime.backend, config.configPath, token);
|
|
117
|
+
if (raw === null) throw error(404, 'Site config not found');
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await commitFile(
|
|
121
|
+
runtime.backend,
|
|
122
|
+
config.configPath,
|
|
123
|
+
setMenu(raw, config.menuName, tree),
|
|
124
|
+
{ message: `Update ${config.label.toLowerCase()}`, author: { name: editor.displayName, email: editor.email } },
|
|
125
|
+
token,
|
|
126
|
+
);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (isConflict(err)) {
|
|
129
|
+
const message = 'The site config changed since you opened it. Reload and reapply your edits.';
|
|
130
|
+
throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
throw redirect(303, '/admin/nav?saved=1');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { navLoad, navSave };
|
|
139
|
+
}
|