@glw907/cairn-cms 0.5.0 → 0.5.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/adapter.d.ts +24 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/auth/capabilities.d.ts +7 -0
- package/dist/auth/capabilities.d.ts.map +1 -0
- package/dist/auth/capabilities.js +26 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/components/AdminLayout.svelte +72 -16
- package/dist/components/AdminLayout.svelte.d.ts +9 -0
- package/dist/components/AdminLayout.svelte.d.ts.map +1 -1
- package/dist/components/CollectionList.svelte +96 -0
- package/dist/components/CollectionList.svelte.d.ts +8 -0
- package/dist/components/CollectionList.svelte.d.ts.map +1 -0
- package/dist/components/ComponentPalette.svelte +34 -0
- package/dist/components/ComponentPalette.svelte.d.ts +9 -0
- package/dist/components/ComponentPalette.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +66 -28
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/NavTree.svelte +128 -0
- package/dist/components/NavTree.svelte.d.ts +8 -0
- package/dist/components/NavTree.svelte.d.ts.map +1 -0
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -1
- package/dist/editor.d.ts +25 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +20 -0
- package/dist/frontmatter.d.ts +3 -0
- package/dist/frontmatter.d.ts.map +1 -0
- package/dist/frontmatter.js +16 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/nav.d.ts +58 -0
- package/dist/nav.d.ts.map +1 -0
- package/dist/nav.js +86 -0
- package/dist/slug.d.ts +7 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +15 -0
- package/dist/sveltekit/index.d.ts +102 -12
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +219 -20
- package/package.json +7 -2
- package/src/lib/adapter.ts +25 -0
- package/src/lib/auth/capabilities.ts +35 -0
- package/src/lib/auth/index.ts +1 -0
- package/src/lib/components/AdminLayout.svelte +72 -16
- package/src/lib/components/CollectionList.svelte +96 -0
- package/src/lib/components/ComponentPalette.svelte +34 -0
- package/src/lib/components/EditPage.svelte +66 -28
- package/src/lib/components/NavTree.svelte +128 -0
- package/src/lib/components/index.ts +3 -1
- package/src/lib/editor.ts +38 -0
- package/src/lib/frontmatter.ts +17 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/nav.ts +117 -0
- package/src/lib/slug.ts +16 -0
- package/src/lib/sveltekit/index.ts +303 -26
- package/dist/components/AdminList.svelte +0 -33
- package/dist/components/AdminList.svelte.d.ts +0 -10
- package/dist/components/AdminList.svelte.d.ts.map +0 -1
- package/src/lib/components/AdminList.svelte +0 -33
package/dist/sveltekit/index.js
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
// logic lives under `@glw907/cairn-cms/auth`; this module is content-only (list/edit/save).
|
|
11
11
|
import { redirect, error } from '@sveltejs/kit';
|
|
12
12
|
import matter from 'gray-matter';
|
|
13
|
+
import { can, requireCapability } from '../auth/capabilities';
|
|
13
14
|
import { listMarkdown, readRaw, commitFile, installationToken, signingSelfTest, CommitConflictError, } from '../github';
|
|
14
15
|
import { serializeMarkdown } from '../content';
|
|
15
16
|
import { findCollection, frontmatterFromForm } from '../adapter';
|
|
17
|
+
import { validateNavTree, extractMenu, parseSiteConfig, setMenu } from '../nav';
|
|
16
18
|
/**
|
|
17
19
|
* Mint a GitHub App installation token for *reads* when the App is configured, else undefined
|
|
18
20
|
* (reads then fall back to anonymous). Authenticated reads get the 5000/hr limit; anonymous
|
|
@@ -37,28 +39,132 @@ async function readToken(env) {
|
|
|
37
39
|
}
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
|
-
* Branding
|
|
41
|
-
* its plugin graph into client bundles
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
42
|
+
* Branding, session, and collection nav for every admin page. `siteName` and the collection
|
|
43
|
+
* list flow from the adapter without pulling its plugin graph into client bundles (the import
|
|
44
|
+
* stays server-side in the layout load; only `{type,label}` crosses). `pathname` lets the
|
|
45
|
+
* shared shell highlight the active nav item without a `$app/*` import (those kit virtual
|
|
46
|
+
* modules have no types outside a kit app); reading `event.url` also opts the layout load into
|
|
47
|
+
* rerunning on navigation, keeping the active class correct.
|
|
45
48
|
*/
|
|
46
49
|
export function adminLayoutLoad(event, adapter) {
|
|
47
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
user: event.locals.user,
|
|
52
|
+
siteName: adapter.siteName,
|
|
53
|
+
pathname: event.url.pathname,
|
|
54
|
+
collections: adapter.collections.map(({ type, label }) => ({ type, label })),
|
|
55
|
+
navMenus: adapter.navMenu ? [{ name: adapter.navMenu.menuName, label: adapter.navMenu.label }] : [],
|
|
56
|
+
canManageNav: can(event.locals.user, 'nav:manage'),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The `/admin` index has no content of its own now that each collection is its own page; send
|
|
61
|
+
* the editor straight to the first collection's entries list (a Sveltia-style landing).
|
|
62
|
+
*/
|
|
63
|
+
export function adminIndexRedirect(adapter) {
|
|
64
|
+
const first = adapter.collections[0];
|
|
65
|
+
if (!first)
|
|
66
|
+
throw error(404, 'No collections configured');
|
|
67
|
+
throw redirect(307, `/admin/${first.type}`);
|
|
48
68
|
}
|
|
49
|
-
/**
|
|
50
|
-
|
|
69
|
+
/** Coerce a frontmatter `date` (gray-matter may parse YAML dates to `Date`) to `YYYY-MM-DD`. */
|
|
70
|
+
function entryDate(value) {
|
|
71
|
+
if (value instanceof Date)
|
|
72
|
+
return value.toISOString().slice(0, 10);
|
|
73
|
+
if (typeof value === 'string')
|
|
74
|
+
return value;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* List one collection's entries, reading each file's frontmatter for the display title, date,
|
|
79
|
+
* and draft badge. Reads run in parallel; a single failed read degrades that row to the slug
|
|
80
|
+
* (rather than failing the page), and a failed directory listing returns an inline `error`.
|
|
81
|
+
* Collections are small here; the 1,000-entry / Git-Trees sharding concern is risk #11, deferred.
|
|
82
|
+
*/
|
|
83
|
+
export async function collectionListLoad(event, adapter) {
|
|
84
|
+
const collection = findCollection(adapter, event.params.collection);
|
|
85
|
+
if (!collection)
|
|
86
|
+
throw error(404, 'Unknown collection');
|
|
87
|
+
const kind = collection.kind ?? 'story';
|
|
88
|
+
const canCreate = can(event.locals.user, kind === 'page' ? 'page:create' : 'story:create');
|
|
89
|
+
const formError = event.url.searchParams.get('error');
|
|
51
90
|
const token = await readToken(event.platform?.env);
|
|
52
|
-
|
|
91
|
+
let files;
|
|
92
|
+
try {
|
|
93
|
+
files = await listMarkdown(adapter.backend, collection.dir, token);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
type: collection.type,
|
|
98
|
+
label: collection.label,
|
|
99
|
+
kind,
|
|
100
|
+
entries: [],
|
|
101
|
+
error: err instanceof Error ? err.message : 'Failed to load',
|
|
102
|
+
formError,
|
|
103
|
+
canCreate,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const entries = await Promise.all(files.map(async (file) => {
|
|
107
|
+
const fallback = {
|
|
108
|
+
id: file.id,
|
|
109
|
+
path: file.path,
|
|
110
|
+
title: file.id,
|
|
111
|
+
date: null,
|
|
112
|
+
draft: false,
|
|
113
|
+
};
|
|
53
114
|
try {
|
|
54
|
-
|
|
115
|
+
const raw = await readRaw(adapter.backend, file.path, token);
|
|
116
|
+
if (raw === null)
|
|
117
|
+
return fallback;
|
|
118
|
+
const { data } = matter(raw);
|
|
119
|
+
return {
|
|
120
|
+
id: file.id,
|
|
121
|
+
path: file.path,
|
|
122
|
+
title: typeof data.title === 'string' ? data.title : file.id,
|
|
123
|
+
date: entryDate(data.date),
|
|
124
|
+
draft: data.draft === true,
|
|
125
|
+
};
|
|
55
126
|
}
|
|
56
|
-
catch
|
|
57
|
-
|
|
58
|
-
return { type, label, files: [], error: err instanceof Error ? err.message : 'Failed to load' };
|
|
127
|
+
catch {
|
|
128
|
+
return fallback;
|
|
59
129
|
}
|
|
60
130
|
}));
|
|
61
|
-
return {
|
|
131
|
+
return {
|
|
132
|
+
type: collection.type,
|
|
133
|
+
label: collection.label,
|
|
134
|
+
kind,
|
|
135
|
+
entries,
|
|
136
|
+
formError,
|
|
137
|
+
canCreate,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// ── /admin/[collection]?/create (POST) ─────────────────────────────────────
|
|
141
|
+
/** A safe filename stem: starts and ends with a lowercase alphanumeric, hyphens allowed within. */
|
|
142
|
+
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
|
143
|
+
/**
|
|
144
|
+
* The "New entry" form action. Validates the requested slug, rejects one that already exists,
|
|
145
|
+
* then redirects into the editor in create mode (`?new=1`, where `editLoad` serves a blank
|
|
146
|
+
* document and `saveCommit`'s create path commits a new file). cairn is filename-based, so the
|
|
147
|
+
* slug is the filename stem the author types; a title-driven auto-slug is a later (Pass K) concern.
|
|
148
|
+
*/
|
|
149
|
+
export async function createEntry(event, adapter) {
|
|
150
|
+
const collection = findCollection(adapter, event.params.collection);
|
|
151
|
+
if (!collection)
|
|
152
|
+
throw error(404, 'Unknown collection');
|
|
153
|
+
const kind = collection.kind ?? 'story';
|
|
154
|
+
requireCapability(event.locals.user, kind === 'page' ? 'page:create' : 'story:create');
|
|
155
|
+
const form = await event.request.formData();
|
|
156
|
+
const id = String(form.get('id') ?? '').trim();
|
|
157
|
+
const back = (message) => redirect(303, `/admin/${collection.type}?error=${encodeURIComponent(message)}`);
|
|
158
|
+
if (!SLUG_RE.test(id)) {
|
|
159
|
+
throw back('Enter a slug using lowercase letters, numbers, and hyphens (for example 2026-05-my-entry).');
|
|
160
|
+
}
|
|
161
|
+
const token = await readToken(event.platform?.env);
|
|
162
|
+
const existing = await readRaw(adapter.backend, `${collection.dir}/${id}.md`, token);
|
|
163
|
+
if (existing !== null)
|
|
164
|
+
throw back(`An entry named "${id}" already exists.`);
|
|
165
|
+
const date = String(form.get('date') ?? '').trim();
|
|
166
|
+
const dateSuffix = kind === 'story' && date ? `&date=${encodeURIComponent(date)}` : '';
|
|
167
|
+
throw redirect(303, `/admin/edit/${collection.type}/${id}?new=1${dateSuffix}`);
|
|
62
168
|
}
|
|
63
169
|
export async function editLoad(event, adapter) {
|
|
64
170
|
const collection = findCollection(adapter, event.params.type);
|
|
@@ -67,15 +173,23 @@ export async function editLoad(event, adapter) {
|
|
|
67
173
|
const token = await readToken(event.platform?.env);
|
|
68
174
|
const path = `${collection.dir}/${event.params.id}.md`;
|
|
69
175
|
const raw = await readRaw(adapter.backend, path, token);
|
|
70
|
-
|
|
176
|
+
const isNew = event.url.searchParams.get('new') === '1';
|
|
177
|
+
// A missing file is a 404 normally, but in create mode (`?new=1`) it's a blank new document.
|
|
178
|
+
if (raw === null && !isNew)
|
|
71
179
|
throw error(404, 'Content not found');
|
|
72
|
-
// Split frontmatter from body server-side; the editor form binds to the frontmatter and
|
|
73
|
-
//
|
|
74
|
-
|
|
180
|
+
// Split frontmatter from body server-side; the editor form binds to the frontmatter and the
|
|
181
|
+
// Carta editor to the body, and /admin/save reassembles them on commit. A new document starts
|
|
182
|
+
// empty so the author fills the fields from scratch.
|
|
183
|
+
const { data: frontmatter, content: body } = raw === null ? { data: {}, content: '' } : matter(raw);
|
|
184
|
+
const seedDate = event.url.searchParams.get('date');
|
|
185
|
+
if (isNew && seedDate && frontmatter.date === undefined) {
|
|
186
|
+
frontmatter.date = seedDate;
|
|
187
|
+
}
|
|
75
188
|
return {
|
|
76
189
|
type: event.params.type,
|
|
77
190
|
id: event.params.id,
|
|
78
191
|
label: collection.label,
|
|
192
|
+
kind: collection.kind ?? 'story',
|
|
79
193
|
fields: collection.fields,
|
|
80
194
|
path,
|
|
81
195
|
body,
|
|
@@ -83,6 +197,7 @@ export async function editLoad(event, adapter) {
|
|
|
83
197
|
title: typeof frontmatter.title === 'string' ? frontmatter.title : event.params.id,
|
|
84
198
|
saved: event.url.searchParams.get('saved') === '1',
|
|
85
199
|
error: event.url.searchParams.get('error'),
|
|
200
|
+
isNew,
|
|
86
201
|
};
|
|
87
202
|
}
|
|
88
203
|
// ── /admin/save (POST) ──────────────────────────────────────────────────────
|
|
@@ -98,6 +213,7 @@ export async function saveCommit(event, adapter) {
|
|
|
98
213
|
const type = String(form.get('type') ?? '');
|
|
99
214
|
const id = String(form.get('id') ?? '');
|
|
100
215
|
const body = String(form.get('body') ?? '');
|
|
216
|
+
const newSuffix = form.get('new') === '1' ? '&new=1' : '';
|
|
101
217
|
const collection = findCollection(adapter, type);
|
|
102
218
|
if (!collection || !id)
|
|
103
219
|
throw error(400, 'Bad request');
|
|
@@ -109,7 +225,7 @@ export async function saveCommit(event, adapter) {
|
|
|
109
225
|
}
|
|
110
226
|
catch (err) {
|
|
111
227
|
const message = err instanceof Error ? err.message : 'Invalid frontmatter';
|
|
112
|
-
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
|
|
228
|
+
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}${newSuffix}`);
|
|
113
229
|
}
|
|
114
230
|
const markdown = serializeMarkdown(frontmatter, body);
|
|
115
231
|
const token = await installationToken({
|
|
@@ -125,12 +241,95 @@ export async function saveCommit(event, adapter) {
|
|
|
125
241
|
// the current version and reapplies. Any other error is unexpected, so rethrow.
|
|
126
242
|
if (err instanceof CommitConflictError) {
|
|
127
243
|
const message = 'This file changed since you opened it. Reload and reapply your edits.';
|
|
128
|
-
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}`);
|
|
244
|
+
throw redirect(303, `/admin/edit/${type}/${id}?error=${encodeURIComponent(message)}${newSuffix}`);
|
|
129
245
|
}
|
|
130
246
|
throw err;
|
|
131
247
|
}
|
|
132
248
|
throw redirect(303, `/admin/edit/${type}/${id}?saved=1`);
|
|
133
249
|
}
|
|
250
|
+
/** List page-collection entries for the picker (one directory listing per page collection). */
|
|
251
|
+
async function navPageOptions(adapter, env) {
|
|
252
|
+
const token = await readToken(env);
|
|
253
|
+
const pageCollections = adapter.collections.filter((c) => (c.kind ?? 'story') === 'page');
|
|
254
|
+
const lists = await Promise.all(pageCollections.map(async (c) => {
|
|
255
|
+
try {
|
|
256
|
+
const files = await listMarkdown(adapter.backend, c.dir, token);
|
|
257
|
+
return files.map((f) => ({ label: f.id, url: `/${f.id}` }));
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return [];
|
|
261
|
+
}
|
|
262
|
+
}));
|
|
263
|
+
return lists.flat();
|
|
264
|
+
}
|
|
265
|
+
export async function navLoad(event, adapter) {
|
|
266
|
+
requireCapability(event.locals.user, 'nav:manage');
|
|
267
|
+
const config = adapter.navMenu;
|
|
268
|
+
if (!config)
|
|
269
|
+
throw error(404, 'No navigation menu configured');
|
|
270
|
+
const maxDepth = config.maxDepth ?? 2;
|
|
271
|
+
const menu = { name: config.menuName, label: config.label, maxDepth };
|
|
272
|
+
// Read the menu from the committed YAML. A missing/unparsable file degrades to an empty tree so
|
|
273
|
+
// the editor still loads (a first edit then creates the menu); only the read itself is best-effort.
|
|
274
|
+
const token = await readToken(event.platform?.env);
|
|
275
|
+
let tree = [];
|
|
276
|
+
try {
|
|
277
|
+
const raw = await readRaw(adapter.backend, config.configPath, token);
|
|
278
|
+
if (raw !== null)
|
|
279
|
+
tree = extractMenu(parseSiteConfig(raw), config.menuName, maxDepth);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
console.error(`cairn nav: failed to read "${config.configPath}":`, err);
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
menu,
|
|
286
|
+
tree,
|
|
287
|
+
pages: await navPageOptions(adapter, event.platform?.env),
|
|
288
|
+
saved: event.url.searchParams.get('saved') === '1',
|
|
289
|
+
error: event.url.searchParams.get('error'),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
export async function navSave(event, adapter) {
|
|
293
|
+
const user = requireCapability(event.locals.user, 'nav:manage');
|
|
294
|
+
const config = adapter.navMenu;
|
|
295
|
+
if (!config)
|
|
296
|
+
throw error(404, 'No navigation menu configured');
|
|
297
|
+
const maxDepth = config.maxDepth ?? 2;
|
|
298
|
+
const env = event.platform?.env;
|
|
299
|
+
if (!env?.GITHUB_APP_ID || !env.GITHUB_APP_INSTALLATION_ID || !env.GITHUB_APP_PRIVATE_KEY_B64) {
|
|
300
|
+
throw error(500, 'GitHub App is not configured');
|
|
301
|
+
}
|
|
302
|
+
const form = await event.request.formData();
|
|
303
|
+
let tree;
|
|
304
|
+
try {
|
|
305
|
+
tree = validateNavTree(JSON.parse(String(form.get('tree') ?? '[]')), maxDepth);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const message = err instanceof Error ? err.message : 'Invalid navigation';
|
|
309
|
+
throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
|
|
310
|
+
}
|
|
311
|
+
const token = await installationToken({
|
|
312
|
+
appId: env.GITHUB_APP_ID,
|
|
313
|
+
installationId: env.GITHUB_APP_INSTALLATION_ID,
|
|
314
|
+
privateKeyB64: env.GITHUB_APP_PRIVATE_KEY_B64,
|
|
315
|
+
});
|
|
316
|
+
// Read-modify-commit: replace only this menu in the current file, preserving the rest.
|
|
317
|
+
const raw = await readRaw(adapter.backend, config.configPath, token);
|
|
318
|
+
if (raw === null)
|
|
319
|
+
throw error(404, `Site config not found at ${config.configPath}`);
|
|
320
|
+
try {
|
|
321
|
+
await commitFile(adapter.backend, config.configPath, setMenu(raw, config.menuName, tree), { message: `Update ${config.label.toLowerCase()}`, author: { name: user.name, email: user.email } }, token);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
// Concurrent-edit 409 (C3): fail safe, same as the content save path.
|
|
325
|
+
if (err instanceof CommitConflictError) {
|
|
326
|
+
const message = 'The site config changed since you opened it. Reload and reapply your edits.';
|
|
327
|
+
throw redirect(303, `/admin/nav?error=${encodeURIComponent(message)}`);
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
throw redirect(303, '/admin/nav?saved=1');
|
|
332
|
+
}
|
|
134
333
|
/**
|
|
135
334
|
* Deploy-time health check (M2): signs a dummy App JWT to prove the GitHub App key loads and
|
|
136
335
|
* the PKCS#1→PKCS#8 conversion still works, before an editor hits it on save. Behind the
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"**/*.svelte",
|
|
8
|
+
"**/*.css"
|
|
9
|
+
],
|
|
6
10
|
"license": "MIT",
|
|
7
11
|
"author": "Geoff Wright",
|
|
8
12
|
"repository": {
|
|
@@ -99,7 +103,8 @@
|
|
|
99
103
|
"remark-parse": "^11.0.0",
|
|
100
104
|
"remark-rehype": "^11.1.2",
|
|
101
105
|
"unified": "^11.0.5",
|
|
102
|
-
"unist-util-visit": "^5.1.0"
|
|
106
|
+
"unist-util-visit": "^5.1.0",
|
|
107
|
+
"yaml": "^2"
|
|
103
108
|
},
|
|
104
109
|
"devDependencies": {
|
|
105
110
|
"@better-auth/cli": "^1.4.21",
|
package/src/lib/adapter.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface CairnCollection {
|
|
|
53
53
|
/** Route `[type]` segment and list key, e.g. `posts`. */
|
|
54
54
|
type: string;
|
|
55
55
|
label: string;
|
|
56
|
+
/**
|
|
57
|
+
* Editing shape. `story` (the default when absent) is a dated feed entry; `page` is a
|
|
58
|
+
* navigation-placed entry with a path-like slug and no date emphasis. Drives the create
|
|
59
|
+
* form and the editor header. Never gates editing capability: the palette and toolbar are
|
|
60
|
+
* available to both. (Pass K, R4.)
|
|
61
|
+
*/
|
|
62
|
+
kind?: 'page' | 'story';
|
|
56
63
|
/** Repo-relative folder holding the collection's markdown files. */
|
|
57
64
|
dir: string;
|
|
58
65
|
/** Editor form fields, rendered in order. */
|
|
@@ -61,6 +68,18 @@ export interface CairnCollection {
|
|
|
61
68
|
validate(data: Record<string, unknown>, source: string): object;
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
/** A managed navigation menu, read from and committed to the site's YAML config file. */
|
|
72
|
+
export interface NavMenuConfig {
|
|
73
|
+
/** Repo-relative path to the site-config YAML, e.g. 'src/lib/site.config.yaml'. */
|
|
74
|
+
configPath: string;
|
|
75
|
+
/** Key within the file's `menus` map, e.g. 'primary'. */
|
|
76
|
+
menuName: string;
|
|
77
|
+
/** Sidebar/admin label for the menu. */
|
|
78
|
+
label: string;
|
|
79
|
+
/** Max nesting depth allowed in the editor (1 = flat). Defaults to 2. */
|
|
80
|
+
maxDepth?: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
64
83
|
export interface CairnAdapter {
|
|
65
84
|
/** Branding + magic-link email copy. */
|
|
66
85
|
siteName: string;
|
|
@@ -79,6 +98,12 @@ export interface CairnAdapter {
|
|
|
79
98
|
* omit it or supply an empty registry.
|
|
80
99
|
*/
|
|
81
100
|
registry?: ComponentRegistry;
|
|
101
|
+
/**
|
|
102
|
+
* The navigation menu this site manages from `/admin/nav` (R3/Pass L2). The menu lives in the
|
|
103
|
+
* site's git-committed YAML config (read at build time by the layout, committed back by the
|
|
104
|
+
* editor). Omit to hide the nav surface, the same opt-in shape as `registry`.
|
|
105
|
+
*/
|
|
106
|
+
navMenu?: NavMenuConfig;
|
|
82
107
|
}
|
|
83
108
|
|
|
84
109
|
/** Look up a collection by its route segment, or undefined if the segment is unknown. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// cairn-core: capability checks. Management surfaces gate on a capability, not on a role name,
|
|
2
|
+
// so the two-tier owner/editor model can grow finer capabilities (and a future role) additively.
|
|
3
|
+
// Creating a page and changing the nav are structural acts, so they sit with owner; editing a
|
|
4
|
+
// page's content and running the story feed are everyday editor work.
|
|
5
|
+
import { error } from '@sveltejs/kit';
|
|
6
|
+
import type { CairnUser } from './guard';
|
|
7
|
+
|
|
8
|
+
export type Capability =
|
|
9
|
+
| 'story:create'
|
|
10
|
+
| 'story:edit'
|
|
11
|
+
| 'page:edit'
|
|
12
|
+
| 'page:create'
|
|
13
|
+
| 'nav:manage'
|
|
14
|
+
| 'user:manage';
|
|
15
|
+
|
|
16
|
+
// One source of truth. `'all'` means every capability; otherwise the explicit grant list. A future
|
|
17
|
+
// `manager` role is one more row here, no call-site changes.
|
|
18
|
+
const CAPS_BY_ROLE: Record<CairnUser['role'], readonly Capability[] | 'all'> = {
|
|
19
|
+
owner: 'all',
|
|
20
|
+
editor: ['story:create', 'story:edit', 'page:edit'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Does this user hold the capability? A signed-out (null) user holds nothing. */
|
|
24
|
+
export function can(user: CairnUser | null, cap: Capability): boolean {
|
|
25
|
+
if (!user) return false;
|
|
26
|
+
const grants = CAPS_BY_ROLE[user.role];
|
|
27
|
+
return grants === 'all' || grants.includes(cap);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Assert the capability for a route load/action: 401 when signed out, 403 when under-privileged. */
|
|
31
|
+
export function requireCapability(user: CairnUser | null, cap: Capability): CairnUser {
|
|
32
|
+
if (!user) throw error(401, 'Not signed in');
|
|
33
|
+
if (!can(user, cap)) throw error(403, 'You do not have permission to do that');
|
|
34
|
+
return user;
|
|
35
|
+
}
|
package/src/lib/auth/index.ts
CHANGED
|
@@ -4,3 +4,4 @@
|
|
|
4
4
|
export { createAuth, type Auth, type AuthEnv, type AuthBranding } from './config';
|
|
5
5
|
export { loadSession, requireSession, confirmSignIn, signOut, type CairnUser } from './guard';
|
|
6
6
|
export { adminsLoad, addAdmin, removeAdmin, setAdminRole, requireOwner, type AdminsData } from './admins';
|
|
7
|
+
export { can, requireCapability, type Capability } from './capabilities';
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
// Neutral admin chrome
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// patterned on scosman/CMSaasStarter's `(admin)/(menu)` layout. The nav is data-driven and
|
|
6
|
-
// role-gated, so a new surface is one entry in `nav` (plus its route + component). Signed out
|
|
7
|
-
// (the login page lives under this layout) it falls back to a minimal centered shell.
|
|
8
|
-
// Each site's `admin/+layout.svelte` is a one-line shim that forwards `data` + `children`.
|
|
2
|
+
// Neutral admin chrome shared across sites. Signed in: DaisyUI drawer+navbar shell (sidebar
|
|
3
|
+
// pinned on desktop, slide-over on mobile). Signed out: minimal centered shell. The
|
|
4
|
+
// `cairn-admin` class on both roots scopes the "Warm Stone" theme; see the style block.
|
|
9
5
|
import type { Snippet } from 'svelte';
|
|
10
6
|
import type { CairnUser } from '../auth';
|
|
11
7
|
|
|
@@ -13,7 +9,14 @@
|
|
|
13
9
|
data,
|
|
14
10
|
children,
|
|
15
11
|
}: {
|
|
16
|
-
data: {
|
|
12
|
+
data: {
|
|
13
|
+
siteName: string;
|
|
14
|
+
user: CairnUser | null;
|
|
15
|
+
pathname: string;
|
|
16
|
+
collections: { type: string; label: string }[];
|
|
17
|
+
navMenus: { name: string; label: string }[];
|
|
18
|
+
canManageNav: boolean;
|
|
19
|
+
};
|
|
17
20
|
children: Snippet;
|
|
18
21
|
} = $props();
|
|
19
22
|
|
|
@@ -27,12 +30,17 @@
|
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
const nav = $derived<NavItem[]>([
|
|
30
|
-
{
|
|
31
|
-
href:
|
|
32
|
-
label:
|
|
33
|
+
...data.collections.map((collection) => ({
|
|
34
|
+
href: `/admin/${collection.type}`,
|
|
35
|
+
label: collection.label,
|
|
33
36
|
icon: contentIcon,
|
|
34
|
-
active:
|
|
35
|
-
|
|
37
|
+
active:
|
|
38
|
+
data.pathname === `/admin/${collection.type}` ||
|
|
39
|
+
data.pathname.startsWith(`/admin/edit/${collection.type}/`),
|
|
40
|
+
})),
|
|
41
|
+
...(data.canManageNav && data.navMenus.length
|
|
42
|
+
? [{ href: '/admin/nav', label: 'Navigation', icon: navIcon, active: data.pathname.startsWith('/admin/nav') }]
|
|
43
|
+
: []),
|
|
36
44
|
{
|
|
37
45
|
href: '/admin/admins',
|
|
38
46
|
label: 'Editors',
|
|
@@ -43,7 +51,7 @@
|
|
|
43
51
|
]);
|
|
44
52
|
const visibleNav = $derived(nav.filter((item) => !item.owner || data.user?.role === 'owner'));
|
|
45
53
|
|
|
46
|
-
// Close the slide-over after a nav tap on mobile
|
|
54
|
+
// Close the slide-over after a nav tap on mobile.
|
|
47
55
|
function closeDrawer(): void {
|
|
48
56
|
const toggle = document.getElementById('admin-drawer');
|
|
49
57
|
if (toggle instanceof HTMLInputElement) toggle.checked = false;
|
|
@@ -64,12 +72,19 @@
|
|
|
64
72
|
</svg>
|
|
65
73
|
{/snippet}
|
|
66
74
|
|
|
75
|
+
{#snippet navIcon()}
|
|
76
|
+
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
|
77
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
78
|
+
d="M4 6h16M4 12h16M4 18h16" />
|
|
79
|
+
</svg>
|
|
80
|
+
{/snippet}
|
|
81
|
+
|
|
67
82
|
<svelte:head>
|
|
68
83
|
<meta name="robots" content="noindex, nofollow" />
|
|
69
84
|
</svelte:head>
|
|
70
85
|
|
|
71
86
|
{#if data.user}
|
|
72
|
-
<div class="drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
|
|
87
|
+
<div class="cairn-admin drawer min-h-screen bg-base-200 lg:drawer-open" data-pagefind-ignore>
|
|
73
88
|
<input id="admin-drawer" type="checkbox" class="drawer-toggle" />
|
|
74
89
|
|
|
75
90
|
<div class="drawer-content">
|
|
@@ -122,9 +137,50 @@
|
|
|
122
137
|
</div>
|
|
123
138
|
{:else}
|
|
124
139
|
<!-- Signed out (login page): no nav, just a centered surface. -->
|
|
125
|
-
<div class="min-h-screen bg-base-200" data-pagefind-ignore>
|
|
140
|
+
<div class="cairn-admin min-h-screen bg-base-200" data-pagefind-ignore>
|
|
126
141
|
<div class="mx-auto max-w-3xl px-4 py-8">
|
|
127
142
|
{@render children()}
|
|
128
143
|
</div>
|
|
129
144
|
</div>
|
|
130
145
|
{/if}
|
|
146
|
+
|
|
147
|
+
<style>
|
|
148
|
+
/* Warm Stone: a neutral, fully self-contained admin theme (R6), light-only. Overriding the
|
|
149
|
+
DaisyUI v5 tokens + font on this root re-skins the whole admin subtree by inheritance, so
|
|
150
|
+
the tool looks identical on every host regardless of the site's own theme. Values are OKLCH
|
|
151
|
+
(no hex/rgb, per the design-system rule). Warm-gray neutrals (hue ~75), violet accent. */
|
|
152
|
+
.cairn-admin {
|
|
153
|
+
color-scheme: light;
|
|
154
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
155
|
+
|
|
156
|
+
--color-base-100: oklch(98.5% 0.004 75);
|
|
157
|
+
--color-base-200: oklch(96% 0.005 75);
|
|
158
|
+
--color-base-300: oklch(92% 0.008 75);
|
|
159
|
+
--color-base-content: oklch(28% 0.012 75);
|
|
160
|
+
|
|
161
|
+
--color-primary: oklch(52% 0.20 293);
|
|
162
|
+
--color-primary-content: oklch(98% 0.012 293);
|
|
163
|
+
--color-secondary: oklch(45% 0.02 75);
|
|
164
|
+
--color-secondary-content: oklch(98% 0.004 75);
|
|
165
|
+
--color-accent: oklch(58% 0.16 300);
|
|
166
|
+
--color-accent-content: oklch(98% 0.012 300);
|
|
167
|
+
--color-neutral: oklch(32% 0.012 75);
|
|
168
|
+
--color-neutral-content: oklch(96% 0.004 75);
|
|
169
|
+
|
|
170
|
+
--color-info: oklch(60% 0.12 240);
|
|
171
|
+
--color-info-content: oklch(98% 0.01 240);
|
|
172
|
+
--color-success: oklch(58% 0.12 150);
|
|
173
|
+
--color-success-content: oklch(98% 0.01 150);
|
|
174
|
+
--color-warning: oklch(75% 0.15 70);
|
|
175
|
+
--color-warning-content: oklch(25% 0.02 70);
|
|
176
|
+
--color-error: oklch(58% 0.20 25);
|
|
177
|
+
--color-error-content: oklch(98% 0.01 25);
|
|
178
|
+
|
|
179
|
+
--radius-selector: 0.5rem;
|
|
180
|
+
--radius-field: 0.5rem;
|
|
181
|
+
--radius-box: 0.75rem;
|
|
182
|
+
--size-selector: 0.25rem;
|
|
183
|
+
--size-field: 0.25rem;
|
|
184
|
+
--border: 1px;
|
|
185
|
+
}
|
|
186
|
+
</style>
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// One collection's entries: a table (title, date, draft badge) linking into the editor, plus a
|
|
3
|
+
// collapsible "New entry" form. The author types a title; the slug stem derives from it (R4) and
|
|
4
|
+
// stays editable. A story collection also collects a date, which createEntry forwards so the new
|
|
5
|
+
// entry opens with its date set. Placeholders differ by kind. The shell (AdminLayout) owns the
|
|
6
|
+
// chrome and nav; this renders only the body.
|
|
7
|
+
import type { CollectionListData } from '../sveltekit';
|
|
8
|
+
import { slugify } from '../slug';
|
|
9
|
+
|
|
10
|
+
let { data }: { data: CollectionListData } = $props();
|
|
11
|
+
|
|
12
|
+
let title = $state('');
|
|
13
|
+
let slug = $state('');
|
|
14
|
+
let slugEdited = $state(false);
|
|
15
|
+
|
|
16
|
+
// Keep the slug in sync with the title until the author edits the slug directly.
|
|
17
|
+
function onTitleInput(value: string) {
|
|
18
|
+
title = value;
|
|
19
|
+
if (!slugEdited) slug = slugify(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const slugPlaceholder = $derived(data.kind === 'page' ? 'about-us' : '2026-05-my-entry');
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class="flex items-center justify-between gap-4">
|
|
26
|
+
<h1 class="text-2xl font-bold">{data.label}</h1>
|
|
27
|
+
{#if data.canCreate}
|
|
28
|
+
<details class="dropdown dropdown-end">
|
|
29
|
+
<summary class="btn btn-primary btn-sm">New entry</summary>
|
|
30
|
+
<form
|
|
31
|
+
method="POST"
|
|
32
|
+
action="?/create"
|
|
33
|
+
class="dropdown-content z-10 mt-2 flex w-80 flex-col gap-2 rounded-box border border-base-300 bg-base-100 p-4 shadow"
|
|
34
|
+
>
|
|
35
|
+
<label class="flex flex-col gap-1">
|
|
36
|
+
<span class="text-sm font-medium">Title</span>
|
|
37
|
+
<input
|
|
38
|
+
type="text"
|
|
39
|
+
value={title}
|
|
40
|
+
oninput={(e) => onTitleInput(e.currentTarget.value)}
|
|
41
|
+
placeholder="A human title"
|
|
42
|
+
class="input w-full"
|
|
43
|
+
/>
|
|
44
|
+
</label>
|
|
45
|
+
|
|
46
|
+
{#if data.kind === 'story'}
|
|
47
|
+
<label class="flex flex-col gap-1">
|
|
48
|
+
<span class="text-sm font-medium">Date</span>
|
|
49
|
+
<input type="date" name="date" class="input w-full" />
|
|
50
|
+
</label>
|
|
51
|
+
{/if}
|
|
52
|
+
|
|
53
|
+
<label class="flex flex-col gap-1">
|
|
54
|
+
<span class="text-sm font-medium">Slug</span>
|
|
55
|
+
<input
|
|
56
|
+
type="text"
|
|
57
|
+
name="id"
|
|
58
|
+
required
|
|
59
|
+
bind:value={slug}
|
|
60
|
+
oninput={() => (slugEdited = true)}
|
|
61
|
+
placeholder={slugPlaceholder}
|
|
62
|
+
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
|
|
63
|
+
class="input w-full"
|
|
64
|
+
/>
|
|
65
|
+
<span class="text-xs opacity-60">Lowercase letters, numbers, and hyphens. Becomes the filename.</span>
|
|
66
|
+
</label>
|
|
67
|
+
|
|
68
|
+
<button type="submit" class="btn btn-primary btn-sm">Create & edit</button>
|
|
69
|
+
</form>
|
|
70
|
+
</details>
|
|
71
|
+
{/if}
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{#if data.formError}
|
|
75
|
+
<div class="alert alert-error mt-4"><span>{data.formError}</span></div>
|
|
76
|
+
{/if}
|
|
77
|
+
|
|
78
|
+
{#if data.error}
|
|
79
|
+
<div class="alert alert-warning mt-6">Couldn't load {data.label.toLowerCase()}: {data.error}</div>
|
|
80
|
+
{:else if data.entries.length === 0}
|
|
81
|
+
<p class="mt-6 opacity-60">No entries yet.</p>
|
|
82
|
+
{:else}
|
|
83
|
+
<ul class="menu mt-6 rounded-box border border-base-300 bg-base-100 p-2">
|
|
84
|
+
{#each data.entries as entry (entry.path)}
|
|
85
|
+
<li>
|
|
86
|
+
<a href="/admin/edit/{data.type}/{entry.id}" class="flex items-center justify-between gap-3">
|
|
87
|
+
<span class="flex items-center gap-2">
|
|
88
|
+
<span>{entry.title}</span>
|
|
89
|
+
{#if entry.draft}<span class="badge badge-warning badge-sm">Draft</span>{/if}
|
|
90
|
+
</span>
|
|
91
|
+
{#if entry.date}<span class="text-xs opacity-60">{entry.date}</span>{/if}
|
|
92
|
+
</a>
|
|
93
|
+
</li>
|
|
94
|
+
{/each}
|
|
95
|
+
</ul>
|
|
96
|
+
{/if}
|