@glw907/cairn-cms 0.17.0 → 0.21.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.
- package/dist/components/DeleteDialog.svelte +81 -0
- package/dist/components/DeleteDialog.svelte.d.ts +21 -0
- package/dist/components/DeleteDialog.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +136 -10
- package/dist/components/EditPage.svelte.d.ts +10 -0
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/LinkPicker.svelte +109 -0
- package/dist/components/LinkPicker.svelte.d.ts +18 -0
- package/dist/components/LinkPicker.svelte.d.ts.map +1 -0
- package/dist/components/MarkdownEditor.svelte +33 -3
- package/dist/components/MarkdownEditor.svelte.d.ts +5 -0
- package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -1
- package/dist/components/RenameDialog.svelte +72 -0
- package/dist/components/RenameDialog.svelte.d.ts +20 -0
- package/dist/components/RenameDialog.svelte.d.ts.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -0
- package/dist/components/link-completion.d.ts +16 -0
- package/dist/components/link-completion.d.ts.map +1 -0
- package/dist/components/link-completion.js +48 -0
- package/dist/components/markdown-format.d.ts +25 -5
- package/dist/components/markdown-format.d.ts.map +1 -1
- package/dist/components/markdown-format.js +85 -0
- package/dist/content/compose.d.ts.map +1 -1
- package/dist/content/compose.js +1 -0
- package/dist/content/ids.d.ts +7 -0
- package/dist/content/ids.d.ts.map +1 -1
- package/dist/content/ids.js +11 -0
- package/dist/content/links.d.ts +21 -0
- package/dist/content/links.d.ts.map +1 -0
- package/dist/content/links.js +52 -0
- package/dist/content/manifest.d.ts +69 -0
- package/dist/content/manifest.d.ts.map +1 -0
- package/dist/content/manifest.js +140 -0
- package/dist/content/types.d.ts +10 -1
- package/dist/content/types.d.ts.map +1 -1
- package/dist/delivery/index.d.ts +1 -0
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +1 -0
- package/dist/delivery/manifest.d.ts +13 -0
- package/dist/delivery/manifest.d.ts.map +1 -0
- package/dist/delivery/manifest.js +38 -0
- package/dist/github/repo.d.ts +21 -0
- package/dist/github/repo.d.ts.map +1 -1
- package/dist/github/repo.js +86 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/render/pipeline.d.ts +4 -1
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +7 -2
- package/dist/render/resolve-links.d.ts +8 -0
- package/dist/render/resolve-links.d.ts.map +1 -0
- package/dist/render/resolve-links.js +36 -0
- package/dist/render/sanitize-schema.d.ts.map +1 -1
- package/dist/render/sanitize-schema.js +9 -0
- package/dist/sveltekit/content-routes.d.ts +13 -1
- package/dist/sveltekit/content-routes.d.ts.map +1 -1
- package/dist/sveltekit/content-routes.js +182 -7
- package/dist/sveltekit/public-routes.d.ts +2 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +2 -1
- package/package.json +2 -1
- package/src/lib/components/DeleteDialog.svelte +81 -0
- package/src/lib/components/EditPage.svelte +136 -10
- package/src/lib/components/LinkPicker.svelte +109 -0
- package/src/lib/components/MarkdownEditor.svelte +33 -3
- package/src/lib/components/RenameDialog.svelte +72 -0
- package/src/lib/components/index.ts +3 -0
- package/src/lib/components/link-completion.ts +57 -0
- package/src/lib/components/markdown-format.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/ids.ts +12 -0
- package/src/lib/content/links.ts +61 -0
- package/src/lib/content/manifest.ts +190 -0
- package/src/lib/content/types.ts +10 -3
- package/src/lib/delivery/index.ts +1 -0
- package/src/lib/delivery/manifest.ts +44 -0
- package/src/lib/github/repo.ts +110 -0
- package/src/lib/index.ts +17 -0
- package/src/lib/render/pipeline.ts +8 -2
- package/src/lib/render/resolve-links.ts +42 -0
- package/src/lib/render/sanitize-schema.ts +9 -0
- package/src/lib/sveltekit/content-routes.ts +209 -10
- package/src/lib/sveltekit/public-routes.ts +4 -2
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
// A factory closes over the composed runtime and the GitHub token mint, so the read and
|
|
3
3
|
// commit paths are unit-testable against a fetch double with an injected token, mirroring the
|
|
4
4
|
// email `send` injection in auth-routes. A shim stays one line: `export const load = routes.editLoad`.
|
|
5
|
-
import { redirect, error } from '@sveltejs/kit';
|
|
5
|
+
import { redirect, error, fail } from '@sveltejs/kit';
|
|
6
6
|
import { findConcept } from '../content/concepts.js';
|
|
7
|
+
import { extractCairnLinks, formatCairnToken } from '../content/links.js';
|
|
7
8
|
import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
|
|
8
|
-
import { isValidId, slugify, filenameFromId, composeDatedId } from '../content/ids.js';
|
|
9
|
+
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
10
|
+
import { rewriteCairnLink } from '../components/markdown-format.js';
|
|
9
11
|
import { appCredentials } from '../github/credentials.js';
|
|
10
|
-
import { listMarkdown, readRaw,
|
|
12
|
+
import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
|
|
11
13
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
14
|
+
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
|
|
12
15
|
import { CommitConflictError } from '../github/types.js';
|
|
13
16
|
/** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
|
|
14
17
|
function sessionOf(event) {
|
|
@@ -134,11 +137,30 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
134
137
|
throw error(400, 'Invalid entry id');
|
|
135
138
|
const isNew = event.url.searchParams.get('new') === '1';
|
|
136
139
|
const token = await mintToken(event.platform?.env ?? {});
|
|
137
|
-
const
|
|
140
|
+
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
141
|
+
// The entry file and the manifest are independent reads sharing the token; fetch them together.
|
|
142
|
+
const [raw, manifestRaw] = await Promise.all([
|
|
143
|
+
readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token),
|
|
144
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
145
|
+
]);
|
|
138
146
|
if (raw === null && !isNew)
|
|
139
147
|
throw error(404, 'Entry not found');
|
|
140
148
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
141
149
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
150
|
+
let linkTargets = [];
|
|
151
|
+
let inbound = [];
|
|
152
|
+
if (manifestRaw !== null) {
|
|
153
|
+
const manifest = parseManifest(manifestRaw);
|
|
154
|
+
linkTargets = manifest.entries.map((e) => ({
|
|
155
|
+
concept: e.concept,
|
|
156
|
+
id: e.id,
|
|
157
|
+
permalink: e.permalink,
|
|
158
|
+
title: e.title,
|
|
159
|
+
date: e.date,
|
|
160
|
+
draft: e.draft,
|
|
161
|
+
}));
|
|
162
|
+
inbound = inboundLinks(manifest, concept.id, id);
|
|
163
|
+
}
|
|
142
164
|
return {
|
|
143
165
|
conceptId: concept.id,
|
|
144
166
|
id,
|
|
@@ -149,7 +171,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
149
171
|
title,
|
|
150
172
|
isNew,
|
|
151
173
|
saved: event.url.searchParams.get('saved') === '1',
|
|
174
|
+
renamed: event.url.searchParams.get('renamed') === '1',
|
|
152
175
|
error: event.url.searchParams.get('error'),
|
|
176
|
+
slug: slugFromId(id, datePrefix),
|
|
177
|
+
linkTargets,
|
|
178
|
+
inboundLinks: inbound,
|
|
153
179
|
};
|
|
154
180
|
}
|
|
155
181
|
/** Match a commit conflict by class and by name (bundling can alias the class identity). */
|
|
@@ -177,8 +203,43 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
177
203
|
}
|
|
178
204
|
const markdown = serializeMarkdown(result.data, body);
|
|
179
205
|
const token = await mintToken(event.platform?.env ?? {});
|
|
206
|
+
// Read the committed manifest, upsert this entry's row, and commit content and manifest in one
|
|
207
|
+
// commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
|
|
208
|
+
// and verifies the manifest, so this incremental patch is the cheap request-time path. On a
|
|
209
|
+
// 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
|
|
210
|
+
// leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
|
|
211
|
+
// it with npm run cairn:manifest to recover.
|
|
212
|
+
const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
213
|
+
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
214
|
+
const row = manifestEntryFromFile(concept, { path, raw: markdown });
|
|
215
|
+
const upserted = upsertEntry(manifest, row);
|
|
216
|
+
const nextManifest = serializeManifest(upserted);
|
|
217
|
+
// Save guard: resolve the body's cairn links against the manifest with this entry upserted, so a
|
|
218
|
+
// self-link and a link to any existing target resolves. A link to an absent target hard-blocks
|
|
219
|
+
// the save (it would red the deploy build and the author would not see it); a link to a draft
|
|
220
|
+
// target commits with a warning, since it is valid and resolves once the target is published.
|
|
221
|
+
const byKey = new Map(upserted.entries.map((e) => [`${e.concept}/${e.id}`, e]));
|
|
222
|
+
const absent = [];
|
|
223
|
+
const draft = [];
|
|
224
|
+
for (const ref of extractCairnLinks(body)) {
|
|
225
|
+
// A self-link is valid by construction (the upserted manifest holds this very entry), so
|
|
226
|
+
// skip it before classifying. Mirrors inboundLinks's self-exclusion.
|
|
227
|
+
if (ref.concept === concept.id && ref.id === id)
|
|
228
|
+
continue;
|
|
229
|
+
const target = byKey.get(`${ref.concept}/${ref.id}`);
|
|
230
|
+
if (!target)
|
|
231
|
+
absent.push(formatCairnToken(ref));
|
|
232
|
+
else if (target.draft)
|
|
233
|
+
draft.push(formatCairnToken(ref));
|
|
234
|
+
}
|
|
235
|
+
if (absent.length) {
|
|
236
|
+
return fail(400, { brokenLinks: absent, body });
|
|
237
|
+
}
|
|
180
238
|
try {
|
|
181
|
-
await
|
|
239
|
+
await commitFiles(runtime.backend, [
|
|
240
|
+
{ path, content: markdown },
|
|
241
|
+
{ path: runtime.manifestPath, content: nextManifest },
|
|
242
|
+
], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
|
|
182
243
|
}
|
|
183
244
|
catch (err) {
|
|
184
245
|
if (isConflict(err)) {
|
|
@@ -187,7 +248,121 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
187
248
|
}
|
|
188
249
|
throw err;
|
|
189
250
|
}
|
|
190
|
-
|
|
251
|
+
const savedQuery = draft.length ? `saved=1&drafts=${encodeURIComponent(draft.join(','))}` : 'saved=1';
|
|
252
|
+
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
253
|
+
}
|
|
254
|
+
/** Delete an entry. Block-until-clean: refuse while inbound links exist (naming them), else commit
|
|
255
|
+
* the file removal and the manifest patch in one commit. The inbound recheck here is the
|
|
256
|
+
* authoritative gate, closing the load-to-delete race. */
|
|
257
|
+
async function deleteAction(event) {
|
|
258
|
+
const editor = sessionOf(event);
|
|
259
|
+
const concept = conceptOf(runtime, event.params);
|
|
260
|
+
const id = event.params.id ?? '';
|
|
261
|
+
if (!isValidId(id))
|
|
262
|
+
throw error(400, 'Invalid entry id');
|
|
263
|
+
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
264
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
265
|
+
// An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
|
|
266
|
+
// check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
|
|
267
|
+
const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
268
|
+
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
269
|
+
const inbound = inboundLinks(manifest, concept.id, id);
|
|
270
|
+
if (inbound.length) {
|
|
271
|
+
return fail(409, { inboundLinks: inbound });
|
|
272
|
+
}
|
|
273
|
+
const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
|
|
274
|
+
try {
|
|
275
|
+
await commitFiles(runtime.backend, [
|
|
276
|
+
{ path, content: null },
|
|
277
|
+
{ path: runtime.manifestPath, content: nextManifest },
|
|
278
|
+
], { message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (isConflict(err)) {
|
|
282
|
+
const message = 'This file changed since you opened it. Reload and try again.';
|
|
283
|
+
throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
throw redirect(303, `/admin/${concept.id}`);
|
|
288
|
+
}
|
|
289
|
+
/** Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
|
|
290
|
+
* atomic commit, so no internal link breaks. The collision check and the inbound recompute here
|
|
291
|
+
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
292
|
+
* caught by the build's fail-closed backstop. */
|
|
293
|
+
async function renameAction(event) {
|
|
294
|
+
const editor = sessionOf(event);
|
|
295
|
+
const concept = conceptOf(runtime, event.params);
|
|
296
|
+
const id = event.params.id ?? '';
|
|
297
|
+
if (!isValidId(id))
|
|
298
|
+
throw error(400, 'Invalid entry id');
|
|
299
|
+
const form = await event.request.formData();
|
|
300
|
+
const newSlug = String(form.get('slug') ?? '').trim();
|
|
301
|
+
if (!isValidId(newSlug)) {
|
|
302
|
+
return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
|
|
303
|
+
}
|
|
304
|
+
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
305
|
+
if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
|
|
306
|
+
return fail(400, { renameError: 'Leave the date out of the slug.' });
|
|
307
|
+
}
|
|
308
|
+
if (newSlug === slugFromId(id, datePrefix)) {
|
|
309
|
+
return fail(400, { renameError: 'That is already the slug.' });
|
|
310
|
+
}
|
|
311
|
+
const newId = renameId(id, newSlug, datePrefix);
|
|
312
|
+
const oldPath = `${concept.dir}/${filenameFromId(id)}`;
|
|
313
|
+
const newPath = `${concept.dir}/${filenameFromId(newId)}`;
|
|
314
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
315
|
+
// Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
|
|
316
|
+
// single readRaw cannot tell apart: a static collision with an existing entry, and a
|
|
317
|
+
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
318
|
+
const clobber = await readRaw(runtime.backend, newPath, token);
|
|
319
|
+
if (clobber !== null) {
|
|
320
|
+
return fail(409, { renameError: 'An entry with that slug already exists.' });
|
|
321
|
+
}
|
|
322
|
+
const [entryRaw, manifestRaw] = await Promise.all([
|
|
323
|
+
readRaw(runtime.backend, oldPath, token),
|
|
324
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
325
|
+
]);
|
|
326
|
+
if (entryRaw === null)
|
|
327
|
+
throw error(404, 'Entry not found');
|
|
328
|
+
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
329
|
+
const oldHref = formatCairnToken({ concept: concept.id, id });
|
|
330
|
+
const newHref = formatCairnToken({ concept: concept.id, id: newId });
|
|
331
|
+
// The moved file keeps its content, except a self-token rewrite. Re-derive its manifest row from
|
|
332
|
+
// the new path so the row carries the new id and permalink by construction.
|
|
333
|
+
const movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
|
|
334
|
+
const changes = [
|
|
335
|
+
{ path: oldPath, content: null },
|
|
336
|
+
{ path: newPath, content: movedRaw },
|
|
337
|
+
];
|
|
338
|
+
let next = removeEntry(manifest, concept.id, id);
|
|
339
|
+
next = upsertEntry(next, manifestEntryFromFile(concept, { path: newPath, raw: movedRaw }));
|
|
340
|
+
// Rewrite every inbound linker's body and re-derive its row, so its outbound edge points at the
|
|
341
|
+
// new id. A linker missing from the repo is skipped; the build backstop catches any drift.
|
|
342
|
+
for (const linker of inboundLinks(manifest, concept.id, id)) {
|
|
343
|
+
const linkerConcept = findConcept(runtime.concepts, linker.concept);
|
|
344
|
+
if (!linkerConcept)
|
|
345
|
+
continue;
|
|
346
|
+
const linkerPath = `${linkerConcept.dir}/${filenameFromId(linker.id)}`;
|
|
347
|
+
const linkerRaw = await readRaw(runtime.backend, linkerPath, token);
|
|
348
|
+
if (linkerRaw === null)
|
|
349
|
+
continue;
|
|
350
|
+
const rewritten = rewriteCairnLink(linkerRaw, oldHref, newHref);
|
|
351
|
+
changes.push({ path: linkerPath, content: rewritten });
|
|
352
|
+
next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: rewritten }));
|
|
353
|
+
}
|
|
354
|
+
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
355
|
+
try {
|
|
356
|
+
await commitFiles(runtime.backend, changes, { message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } }, token);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
if (isConflict(err)) {
|
|
360
|
+
const message = 'This file changed since you opened it. Reload and try again.';
|
|
361
|
+
throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
|
|
362
|
+
}
|
|
363
|
+
throw err;
|
|
364
|
+
}
|
|
365
|
+
throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
|
|
191
366
|
}
|
|
192
|
-
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, mintToken };
|
|
367
|
+
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, renameAction, mintToken };
|
|
193
368
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
|
|
2
2
|
import type { SiteIndex } from '../delivery/site-index.js';
|
|
3
3
|
import type { SeoMeta } from '../delivery/seo.js';
|
|
4
|
+
import type { LinkResolve } from '../content/links.js';
|
|
4
5
|
/** Injected dependencies for the public loaders. */
|
|
5
6
|
export interface PublicRoutesDeps {
|
|
6
7
|
site: SiteIndex;
|
|
7
8
|
render: (md: string, opts?: {
|
|
8
9
|
stagger?: boolean;
|
|
10
|
+
resolve?: LinkResolve;
|
|
9
11
|
}) => string | Promise<string>;
|
|
10
12
|
origin: string;
|
|
11
13
|
/** Site name for og:site_name and the SEO head. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtG,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;6EACyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BA0BjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { error } from '@sveltejs/kit';
|
|
7
7
|
import { buildSeoMeta } from '../delivery/seo.js';
|
|
8
8
|
import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
|
|
9
|
+
import { buildLinkResolver } from '../delivery/manifest.js';
|
|
9
10
|
/** Build the public loaders for a site's unified index. */
|
|
10
11
|
export function createPublicRoutes(deps) {
|
|
11
12
|
const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
|
|
@@ -40,7 +41,7 @@ export function createPublicRoutes(deps) {
|
|
|
40
41
|
...(fields.author ? { author: fields.author } : {}),
|
|
41
42
|
...(entry.date ? { feeds } : {}),
|
|
42
43
|
});
|
|
43
|
-
return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
|
|
44
|
+
return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
|
|
44
45
|
}
|
|
45
46
|
/** The chronological archive for one concept: every non-draft summary, newest-first. */
|
|
46
47
|
function archiveLoad(conceptId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"svelte": "^5.0.0"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
+
"@codemirror/autocomplete": "^6.20.2",
|
|
67
68
|
"@codemirror/commands": "^6.10.3",
|
|
68
69
|
"@codemirror/lang-markdown": "^6.5.0",
|
|
69
70
|
"@codemirror/language": "^6.12.3",
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The Delete control and its modal. With no inbound links it is a plain confirm that posts to the
|
|
4
|
+
?/delete action. With inbound links it blocks: it names how many entries link here and lists them,
|
|
5
|
+
each linking to its edit page, so the author repoints or removes those links first. Built on a native
|
|
6
|
+
<dialog>, following the LinkPicker a11y conventions.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { InboundLink } from '../content/manifest.js';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
/** The concept this entry belongs to, e.g. "posts". Posted with the confirm. */
|
|
13
|
+
conceptId: string;
|
|
14
|
+
/** The entry id within its concept. Posted with the confirm. */
|
|
15
|
+
id: string;
|
|
16
|
+
/** A human label for the concept, e.g. "Post", used in the prompts. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** The entries that link to this one; non-empty blocks the delete. */
|
|
19
|
+
inboundLinks: InboundLink[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let { conceptId, id, label, inboundLinks }: Props = $props();
|
|
23
|
+
|
|
24
|
+
let dialog = $state<HTMLDialogElement | null>(null);
|
|
25
|
+
const blocked = $derived(inboundLinks.length > 0);
|
|
26
|
+
const noun = $derived(label.toLowerCase());
|
|
27
|
+
// One inbound link reads "1 post links here ... repoint it"; many reads "2 posts link here ...
|
|
28
|
+
// repoint them". The subject-verb agreement inverts the usual plural-s, so derive each form once.
|
|
29
|
+
const single = $derived(inboundLinks.length === 1);
|
|
30
|
+
const nouns = $derived(single ? noun : `${noun}s`);
|
|
31
|
+
const verb = $derived(single ? 'links' : 'link');
|
|
32
|
+
const pronoun = $derived(single ? 'it' : 'them');
|
|
33
|
+
|
|
34
|
+
function open() {
|
|
35
|
+
dialog?.showModal();
|
|
36
|
+
}
|
|
37
|
+
function close() {
|
|
38
|
+
dialog?.close();
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<button type="button" class="btn btn-sm btn-ghost text-error" aria-haspopup="dialog" onclick={open}>
|
|
43
|
+
Delete
|
|
44
|
+
</button>
|
|
45
|
+
|
|
46
|
+
<dialog class="modal" aria-labelledby="cairn-delete-dialog-title" bind:this={dialog}>
|
|
47
|
+
<div class="modal-box">
|
|
48
|
+
<div class="mb-3 flex items-center justify-between">
|
|
49
|
+
<h2 id="cairn-delete-dialog-title" class="text-base font-semibold">Delete this {label.toLowerCase()}?</h2>
|
|
50
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{#if blocked}
|
|
54
|
+
<p class="mb-2 text-sm">
|
|
55
|
+
{inboundLinks.length} {nouns} {verb} here. Remove or repoint {pronoun} before deleting, so no link is left
|
|
56
|
+
broken.
|
|
57
|
+
</p>
|
|
58
|
+
<ul class="menu w-full">
|
|
59
|
+
{#each inboundLinks as link (link.concept + '/' + link.id)}
|
|
60
|
+
<li>
|
|
61
|
+
<a href={`/admin/${link.concept}/${link.id}`}>{link.title}</a>
|
|
62
|
+
</li>
|
|
63
|
+
{/each}
|
|
64
|
+
</ul>
|
|
65
|
+
<div class="mt-3 flex justify-end">
|
|
66
|
+
<button type="button" class="btn btn-sm" onclick={close}>Close</button>
|
|
67
|
+
</div>
|
|
68
|
+
{:else}
|
|
69
|
+
<p class="mb-3 text-sm">This cannot be undone.</p>
|
|
70
|
+
<form method="POST" action="?/delete" class="flex justify-end gap-2">
|
|
71
|
+
<input type="hidden" name="concept" value={conceptId} />
|
|
72
|
+
<input type="hidden" name="id" value={id} />
|
|
73
|
+
<button type="button" class="btn btn-sm" onclick={close}>Cancel</button>
|
|
74
|
+
<button type="submit" class="btn btn-sm btn-error">Delete this {label.toLowerCase()}</button>
|
|
75
|
+
</form>
|
|
76
|
+
{/if}
|
|
77
|
+
</div>
|
|
78
|
+
<form method="dialog" class="modal-backdrop">
|
|
79
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
80
|
+
</form>
|
|
81
|
+
</dialog>
|
|
@@ -8,10 +8,17 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
8
8
|
import { untrack } from 'svelte';
|
|
9
9
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
10
10
|
import ComponentInsertDialog from './ComponentInsertDialog.svelte';
|
|
11
|
+
import LinkPicker from './LinkPicker.svelte';
|
|
12
|
+
import DeleteDialog from './DeleteDialog.svelte';
|
|
13
|
+
import RenameDialog from './RenameDialog.svelte';
|
|
14
|
+
import { cairnLinkCompletionSource } from './link-completion.js';
|
|
15
|
+
import { unwrapCairnLink } from './markdown-format.js';
|
|
11
16
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
12
17
|
import type { IconSet } from '../render/glyph.js';
|
|
13
18
|
import type { EditData } from '../sveltekit/content-routes.js';
|
|
14
19
|
import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
|
|
20
|
+
import type { LinkResolve } from '../content/links.js';
|
|
21
|
+
import { manifestLinkResolver } from '../content/manifest.js';
|
|
15
22
|
|
|
16
23
|
interface Props {
|
|
17
24
|
/** The edit load's data, plus the site name for the heading. */
|
|
@@ -19,19 +26,88 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
19
26
|
/** The site's component registry, for the insert palette. */
|
|
20
27
|
registry?: ComponentRegistry;
|
|
21
28
|
/** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
|
|
22
|
-
render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
29
|
+
render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
|
|
23
30
|
/** The site's icon set, for the guided form's icon fields. */
|
|
24
31
|
icons?: IconSet;
|
|
32
|
+
/** The `?/save` or `?/delete` action result. Carries the save guard's broken links when a save was
|
|
33
|
+
* blocked, or the delete guard's inbound linkers when a delete was refused. */
|
|
34
|
+
form?: { brokenLinks?: string[]; body?: string; inboundLinks?: import('../content/manifest.js').InboundLink[]; renameError?: string } | null;
|
|
25
35
|
}
|
|
26
36
|
|
|
27
|
-
let { data, registry, render, icons }: Props = $props();
|
|
37
|
+
let { data, registry, render, icons, form }: Props = $props();
|
|
28
38
|
|
|
29
|
-
// `body` is local editor state seeded once
|
|
30
|
-
//
|
|
31
|
-
|
|
39
|
+
// `body` is local editor state seeded once; it diverges as the user types. A blocked save returns
|
|
40
|
+
// the author's edited markdown as form.body, so seed from that when present to keep the edits and
|
|
41
|
+
// the broken link they were told to fix. On the success and delete-refused paths form carries no
|
|
42
|
+
// body, so it falls back to the committed data.body. untrack() captures the initial value without
|
|
43
|
+
// subscribing to future prop changes.
|
|
44
|
+
let body = $state(untrack(() => form?.body ?? data.body));
|
|
32
45
|
let showPreview = $state(false);
|
|
33
46
|
let previewHtml = $state('');
|
|
34
47
|
let insert = $state.raw<(text: string) => void>(() => {});
|
|
48
|
+
let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
|
|
49
|
+
|
|
50
|
+
// The save guard's broken links, from the blocked action result. The fix unwraps a link in the
|
|
51
|
+
// local body, which the bound editor reconciles, so the author re-saves clean.
|
|
52
|
+
const brokenLinks = $derived(form?.brokenLinks ?? []);
|
|
53
|
+
// Track the hrefs the author has already fixed this session. The banner reads the immutable action
|
|
54
|
+
// result, so without this a fixed row would linger and "Remove link" would read as a no-op.
|
|
55
|
+
let removedLinks = $state<string[]>([]);
|
|
56
|
+
const visibleBrokenLinks = $derived(brokenLinks.filter((h) => !removedLinks.includes(h)));
|
|
57
|
+
function removeBrokenLink(href: string) {
|
|
58
|
+
// Hide the row only when the unwrap changed the body. A genuine no-op keeps the row honest.
|
|
59
|
+
const next = unwrapCairnLink(body, href);
|
|
60
|
+
if (next !== body) {
|
|
61
|
+
body = next;
|
|
62
|
+
removedLinks = [...removedLinks, href];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// The delete guard's inbound linkers, from a refused delete (fail 409). Empty when the delete was
|
|
67
|
+
// not refused. When set, a delete was blocked by a link that appeared since the page loaded.
|
|
68
|
+
const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
|
|
69
|
+
|
|
70
|
+
// A rename that hit a collision or an invalid slug returns form.renameError.
|
|
71
|
+
const renameError = $derived(form?.renameError ?? '');
|
|
72
|
+
|
|
73
|
+
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>.
|
|
74
|
+
let draftWarning = $state('');
|
|
75
|
+
$effect(() => {
|
|
76
|
+
const search = typeof location === 'undefined' ? '' : location.search;
|
|
77
|
+
const drafts = new URLSearchParams(search).get('drafts');
|
|
78
|
+
draftWarning = drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// One persistent live region announces the current message, since a {#if}-gated role element
|
|
82
|
+
// inserted fresh is announced inconsistently. A polite region carries the success and draft
|
|
83
|
+
// notices; an assertive region carries the errors. The visible banners below keep their styling
|
|
84
|
+
// but drop their roles, so a message is announced once.
|
|
85
|
+
const politeMessage = $derived.by(() => {
|
|
86
|
+
if (draftWarning) return `Saved. This page links to unpublished pages: ${draftWarning}.`;
|
|
87
|
+
if (data.saved) return 'Saved.';
|
|
88
|
+
if (data.renamed) return `The URL is now ${data.slug}.`;
|
|
89
|
+
return '';
|
|
90
|
+
});
|
|
91
|
+
const assertiveMessage = $derived.by(() => {
|
|
92
|
+
if (data.error) return data.error;
|
|
93
|
+
if (renameError) return renameError;
|
|
94
|
+
if (deleteRefusedLinks.length) {
|
|
95
|
+
const count = deleteRefusedLinks.length;
|
|
96
|
+
return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
|
|
97
|
+
}
|
|
98
|
+
if (visibleBrokenLinks.length) {
|
|
99
|
+
const count = visibleBrokenLinks.length;
|
|
100
|
+
return `This page links to ${count} missing ${count === 1 ? 'page' : 'pages'}.`;
|
|
101
|
+
}
|
|
102
|
+
return '';
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
|
|
106
|
+
// returns undefined for a missing target so the render step marks it cairn-broken-link.
|
|
107
|
+
const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
|
|
108
|
+
|
|
109
|
+
// The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
|
|
110
|
+
const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
|
|
35
111
|
|
|
36
112
|
const PREVIEW_KEY = 'cairn-admin:preview';
|
|
37
113
|
|
|
@@ -53,10 +129,11 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
53
129
|
$effect(() => {
|
|
54
130
|
if (!showPreview || !render) return;
|
|
55
131
|
const md = body;
|
|
132
|
+
const resolve = resolveLink; // tracked read in the effect body
|
|
56
133
|
const run = ++previewRun;
|
|
57
134
|
const handle = setTimeout(async () => {
|
|
58
135
|
try {
|
|
59
|
-
const html = await render(md);
|
|
136
|
+
const html = await render(md, { resolve });
|
|
60
137
|
if (run === previewRun) previewHtml = html;
|
|
61
138
|
} catch {
|
|
62
139
|
if (run === previewRun) previewHtml = '';
|
|
@@ -78,6 +155,9 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
78
155
|
</div>
|
|
79
156
|
<div class="flex items-center gap-2">
|
|
80
157
|
<ComponentInsertDialog {registry} {insert} {icons} />
|
|
158
|
+
<LinkPicker linkTargets={data.linkTargets} insert={insertLink} />
|
|
159
|
+
<RenameDialog conceptId={data.conceptId} id={data.id} label={data.label} slug={data.slug} />
|
|
160
|
+
<DeleteDialog conceptId={data.conceptId} id={data.id} label={data.label} inboundLinks={data.inboundLinks} />
|
|
81
161
|
<button
|
|
82
162
|
type="button"
|
|
83
163
|
class="btn btn-sm btn-ghost"
|
|
@@ -90,11 +170,51 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
90
170
|
</div>
|
|
91
171
|
</header>
|
|
92
172
|
|
|
93
|
-
{
|
|
94
|
-
|
|
173
|
+
<div class="sr-only" aria-live="polite">{politeMessage}</div>
|
|
174
|
+
<div class="sr-only" aria-live="assertive">{assertiveMessage}</div>
|
|
175
|
+
|
|
176
|
+
{#if data.saved && !draftWarning}
|
|
177
|
+
<div class="alert alert-success mb-4 text-sm">Saved.</div>
|
|
178
|
+
{/if}
|
|
179
|
+
{#if data.renamed}
|
|
180
|
+
<div class="alert alert-success mb-4 text-sm">The URL is now {data.slug}.</div>
|
|
95
181
|
{/if}
|
|
96
182
|
{#if data.error}
|
|
97
|
-
<div
|
|
183
|
+
<div class="alert alert-error mb-4 text-sm">{data.error}</div>
|
|
184
|
+
{/if}
|
|
185
|
+
{#if renameError}
|
|
186
|
+
<div class="alert alert-error mb-4 text-sm">{renameError}</div>
|
|
187
|
+
{/if}
|
|
188
|
+
{#if deleteRefusedLinks.length}
|
|
189
|
+
<div class="alert alert-error mb-4 flex-col items-start text-sm">
|
|
190
|
+
<p class="font-medium">This {data.label.toLowerCase()} could not be deleted.</p>
|
|
191
|
+
<p>{deleteRefusedLinks.length} {deleteRefusedLinks.length === 1 ? 'page' : 'pages'} now link to it. Remove or repoint the {deleteRefusedLinks.length === 1 ? 'link' : 'links'} listed below, then delete again.</p>
|
|
192
|
+
<ul class="mt-1 w-full">
|
|
193
|
+
{#each deleteRefusedLinks as link (link.concept + '/' + link.id)}
|
|
194
|
+
<li>
|
|
195
|
+
<a class="link" href={`/admin/${link.concept}/${link.id}`}>{link.title}</a>
|
|
196
|
+
</li>
|
|
197
|
+
{/each}
|
|
198
|
+
</ul>
|
|
199
|
+
</div>
|
|
200
|
+
{/if}
|
|
201
|
+
{#if visibleBrokenLinks.length}
|
|
202
|
+
<div class="alert alert-error mb-4 flex-col items-start text-sm">
|
|
203
|
+
<p>This page links to {visibleBrokenLinks.length === 1 ? 'a page' : 'pages'} that no longer {visibleBrokenLinks.length === 1 ? 'exists' : 'exist'}. Remove the broken {visibleBrokenLinks.length === 1 ? 'link' : 'links'} and save again.</p>
|
|
204
|
+
<ul class="mt-1 w-full">
|
|
205
|
+
{#each visibleBrokenLinks as href (href)}
|
|
206
|
+
<li class="flex items-center justify-between gap-2">
|
|
207
|
+
<code class="text-xs">{href}</code>
|
|
208
|
+
<button type="button" class="btn btn-xs" onclick={() => removeBrokenLink(href)}>Remove link</button>
|
|
209
|
+
</li>
|
|
210
|
+
{/each}
|
|
211
|
+
</ul>
|
|
212
|
+
</div>
|
|
213
|
+
{/if}
|
|
214
|
+
{#if draftWarning}
|
|
215
|
+
<div class="alert alert-warning mb-4 text-sm">
|
|
216
|
+
Saved. Note: this page links to unpublished {draftWarning.includes(',') ? 'pages' : 'a page'} ({draftWarning}), which will 404 until published.
|
|
217
|
+
</div>
|
|
98
218
|
{/if}
|
|
99
219
|
|
|
100
220
|
<form method="POST" action="?/save" class="lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6">
|
|
@@ -102,7 +222,13 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
102
222
|
|
|
103
223
|
<div class="lg:order-1">
|
|
104
224
|
<div class="rounded-box border border-base-300 bg-base-100 overflow-hidden">
|
|
105
|
-
<MarkdownEditor
|
|
225
|
+
<MarkdownEditor
|
|
226
|
+
bind:value={body}
|
|
227
|
+
name="body"
|
|
228
|
+
registerInsert={(fn) => (insert = fn)}
|
|
229
|
+
registerInsertLink={(fn) => (insertLink = fn)}
|
|
230
|
+
{completionSources}
|
|
231
|
+
/>
|
|
106
232
|
</div>
|
|
107
233
|
{#if showPreview}
|
|
108
234
|
<section
|