@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, type GithubKeyEnv } from '../github/credentials.js';
|
|
10
|
-
import { listMarkdown, readRaw,
|
|
12
|
+
import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
|
|
11
13
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
14
|
+
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
12
15
|
import { CommitConflictError } from '../github/types.js';
|
|
13
16
|
import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
|
|
14
17
|
import type { Editor, Role } from '../auth/types.js';
|
|
@@ -62,7 +65,15 @@ export interface EditData {
|
|
|
62
65
|
title: string;
|
|
63
66
|
isNew: boolean;
|
|
64
67
|
saved: boolean;
|
|
68
|
+
/** True after a successful rename redirect (`?renamed=1`), to confirm the new URL to the author. */
|
|
69
|
+
renamed: boolean;
|
|
65
70
|
error: string | null;
|
|
71
|
+
/** The current URL slug (the date-stripped id for a dated concept), for the rename dialog prefill. */
|
|
72
|
+
slug: string;
|
|
73
|
+
/** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
|
|
74
|
+
linkTargets: LinkTarget[];
|
|
75
|
+
/** The entries that link to this one, for the delete guard. Empty when nothing links here. */
|
|
76
|
+
inboundLinks: InboundLink[];
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
@@ -202,11 +213,32 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
202
213
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
203
214
|
const isNew = event.url.searchParams.get('new') === '1';
|
|
204
215
|
const token = await mintToken(event.platform?.env ?? {});
|
|
205
|
-
const
|
|
216
|
+
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
217
|
+
// The entry file and the manifest are independent reads sharing the token; fetch them together.
|
|
218
|
+
const [raw, manifestRaw] = await Promise.all([
|
|
219
|
+
readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token),
|
|
220
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
221
|
+
]);
|
|
206
222
|
if (raw === null && !isNew) throw error(404, 'Entry not found');
|
|
207
223
|
|
|
208
224
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
209
225
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
226
|
+
|
|
227
|
+
let linkTargets: LinkTarget[] = [];
|
|
228
|
+
let inbound: InboundLink[] = [];
|
|
229
|
+
if (manifestRaw !== null) {
|
|
230
|
+
const manifest = parseManifest(manifestRaw);
|
|
231
|
+
linkTargets = manifest.entries.map((e) => ({
|
|
232
|
+
concept: e.concept,
|
|
233
|
+
id: e.id,
|
|
234
|
+
permalink: e.permalink,
|
|
235
|
+
title: e.title,
|
|
236
|
+
date: e.date,
|
|
237
|
+
draft: e.draft,
|
|
238
|
+
}));
|
|
239
|
+
inbound = inboundLinks(manifest, concept.id, id);
|
|
240
|
+
}
|
|
241
|
+
|
|
210
242
|
return {
|
|
211
243
|
conceptId: concept.id,
|
|
212
244
|
id,
|
|
@@ -217,7 +249,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
217
249
|
title,
|
|
218
250
|
isNew,
|
|
219
251
|
saved: event.url.searchParams.get('saved') === '1',
|
|
252
|
+
renamed: event.url.searchParams.get('renamed') === '1',
|
|
220
253
|
error: event.url.searchParams.get('error'),
|
|
254
|
+
slug: slugFromId(id, datePrefix),
|
|
255
|
+
linkTargets,
|
|
256
|
+
inboundLinks: inbound,
|
|
221
257
|
};
|
|
222
258
|
}
|
|
223
259
|
|
|
@@ -227,7 +263,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
227
263
|
}
|
|
228
264
|
|
|
229
265
|
/** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
|
|
230
|
-
async function saveAction(event: ContentEvent): Promise<never> {
|
|
266
|
+
async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
231
267
|
const editor = sessionOf(event);
|
|
232
268
|
const concept = conceptOf(runtime, event.params);
|
|
233
269
|
const id = event.params.id ?? '';
|
|
@@ -249,11 +285,45 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
249
285
|
|
|
250
286
|
const markdown = serializeMarkdown(result.data, body);
|
|
251
287
|
const token = await mintToken(event.platform?.env ?? {});
|
|
288
|
+
|
|
289
|
+
// Read the committed manifest, upsert this entry's row, and commit content and manifest in one
|
|
290
|
+
// commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
|
|
291
|
+
// and verifies the manifest, so this incremental patch is the cheap request-time path. On a
|
|
292
|
+
// 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
|
|
293
|
+
// leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
|
|
294
|
+
// it with npm run cairn:manifest to recover.
|
|
295
|
+
const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
296
|
+
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
297
|
+
const row = manifestEntryFromFile(concept, { path, raw: markdown });
|
|
298
|
+
const upserted = upsertEntry(manifest, row);
|
|
299
|
+
const nextManifest = serializeManifest(upserted);
|
|
300
|
+
|
|
301
|
+
// Save guard: resolve the body's cairn links against the manifest with this entry upserted, so a
|
|
302
|
+
// self-link and a link to any existing target resolves. A link to an absent target hard-blocks
|
|
303
|
+
// the save (it would red the deploy build and the author would not see it); a link to a draft
|
|
304
|
+
// target commits with a warning, since it is valid and resolves once the target is published.
|
|
305
|
+
const byKey = new Map(upserted.entries.map((e) => [`${e.concept}/${e.id}`, e]));
|
|
306
|
+
const absent: string[] = [];
|
|
307
|
+
const draft: string[] = [];
|
|
308
|
+
for (const ref of extractCairnLinks(body)) {
|
|
309
|
+
// A self-link is valid by construction (the upserted manifest holds this very entry), so
|
|
310
|
+
// skip it before classifying. Mirrors inboundLinks's self-exclusion.
|
|
311
|
+
if (ref.concept === concept.id && ref.id === id) continue;
|
|
312
|
+
const target = byKey.get(`${ref.concept}/${ref.id}`);
|
|
313
|
+
if (!target) absent.push(formatCairnToken(ref));
|
|
314
|
+
else if (target.draft) draft.push(formatCairnToken(ref));
|
|
315
|
+
}
|
|
316
|
+
if (absent.length) {
|
|
317
|
+
return fail(400, { brokenLinks: absent, body });
|
|
318
|
+
}
|
|
319
|
+
|
|
252
320
|
try {
|
|
253
|
-
await
|
|
321
|
+
await commitFiles(
|
|
254
322
|
runtime.backend,
|
|
255
|
-
|
|
256
|
-
|
|
323
|
+
[
|
|
324
|
+
{ path, content: markdown },
|
|
325
|
+
{ path: runtime.manifestPath, content: nextManifest },
|
|
326
|
+
],
|
|
257
327
|
{ message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
|
|
258
328
|
token,
|
|
259
329
|
);
|
|
@@ -264,8 +334,137 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
264
334
|
}
|
|
265
335
|
throw err;
|
|
266
336
|
}
|
|
267
|
-
|
|
337
|
+
const savedQuery = draft.length ? `saved=1&drafts=${encodeURIComponent(draft.join(','))}` : 'saved=1';
|
|
338
|
+
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Delete an entry. Block-until-clean: refuse while inbound links exist (naming them), else commit
|
|
342
|
+
* the file removal and the manifest patch in one commit. The inbound recheck here is the
|
|
343
|
+
* authoritative gate, closing the load-to-delete race. */
|
|
344
|
+
async function deleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
345
|
+
const editor = sessionOf(event);
|
|
346
|
+
const concept = conceptOf(runtime, event.params);
|
|
347
|
+
const id = event.params.id ?? '';
|
|
348
|
+
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
349
|
+
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
350
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
351
|
+
|
|
352
|
+
// An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
|
|
353
|
+
// check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
|
|
354
|
+
const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
355
|
+
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
356
|
+
const inbound = inboundLinks(manifest, concept.id, id);
|
|
357
|
+
if (inbound.length) {
|
|
358
|
+
return fail(409, { inboundLinks: inbound });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
|
|
362
|
+
try {
|
|
363
|
+
await commitFiles(
|
|
364
|
+
runtime.backend,
|
|
365
|
+
[
|
|
366
|
+
{ path, content: null },
|
|
367
|
+
{ path: runtime.manifestPath, content: nextManifest },
|
|
368
|
+
],
|
|
369
|
+
{ message: `Delete ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
|
|
370
|
+
token,
|
|
371
|
+
);
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (isConflict(err)) {
|
|
374
|
+
const message = 'This file changed since you opened it. Reload and try again.';
|
|
375
|
+
throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
|
|
376
|
+
}
|
|
377
|
+
throw err;
|
|
378
|
+
}
|
|
379
|
+
throw redirect(303, `/admin/${concept.id}`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Rename an entry: change its slug, move the file, and rewrite every inbound cairn token in one
|
|
383
|
+
* atomic commit, so no internal link breaks. The collision check and the inbound recompute here
|
|
384
|
+
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
385
|
+
* caught by the build's fail-closed backstop. */
|
|
386
|
+
async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
387
|
+
const editor = sessionOf(event);
|
|
388
|
+
const concept = conceptOf(runtime, event.params);
|
|
389
|
+
const id = event.params.id ?? '';
|
|
390
|
+
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
391
|
+
|
|
392
|
+
const form = await event.request.formData();
|
|
393
|
+
const newSlug = String(form.get('slug') ?? '').trim();
|
|
394
|
+
if (!isValidId(newSlug)) {
|
|
395
|
+
return fail(400, { renameError: 'Enter a valid slug: lowercase letters, numbers, and hyphens.' });
|
|
396
|
+
}
|
|
397
|
+
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
398
|
+
if (concept.routing.dated && /^\d{4}-/.test(newSlug)) {
|
|
399
|
+
return fail(400, { renameError: 'Leave the date out of the slug.' });
|
|
400
|
+
}
|
|
401
|
+
if (newSlug === slugFromId(id, datePrefix)) {
|
|
402
|
+
return fail(400, { renameError: 'That is already the slug.' });
|
|
403
|
+
}
|
|
404
|
+
const newId = renameId(id, newSlug, datePrefix);
|
|
405
|
+
const oldPath = `${concept.dir}/${filenameFromId(id)}`;
|
|
406
|
+
const newPath = `${concept.dir}/${filenameFromId(newId)}`;
|
|
407
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
408
|
+
|
|
409
|
+
// Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
|
|
410
|
+
// single readRaw cannot tell apart: a static collision with an existing entry, and a
|
|
411
|
+
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
412
|
+
const clobber = await readRaw(runtime.backend, newPath, token);
|
|
413
|
+
if (clobber !== null) {
|
|
414
|
+
return fail(409, { renameError: 'An entry with that slug already exists.' });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const [entryRaw, manifestRaw] = await Promise.all([
|
|
418
|
+
readRaw(runtime.backend, oldPath, token),
|
|
419
|
+
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
420
|
+
]);
|
|
421
|
+
if (entryRaw === null) throw error(404, 'Entry not found');
|
|
422
|
+
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
423
|
+
|
|
424
|
+
const oldHref = formatCairnToken({ concept: concept.id, id });
|
|
425
|
+
const newHref = formatCairnToken({ concept: concept.id, id: newId });
|
|
426
|
+
|
|
427
|
+
// The moved file keeps its content, except a self-token rewrite. Re-derive its manifest row from
|
|
428
|
+
// the new path so the row carries the new id and permalink by construction.
|
|
429
|
+
const movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
|
|
430
|
+
const changes: FileChange[] = [
|
|
431
|
+
{ path: oldPath, content: null },
|
|
432
|
+
{ path: newPath, content: movedRaw },
|
|
433
|
+
];
|
|
434
|
+
let next = removeEntry(manifest, concept.id, id);
|
|
435
|
+
next = upsertEntry(next, manifestEntryFromFile(concept, { path: newPath, raw: movedRaw }));
|
|
436
|
+
|
|
437
|
+
// Rewrite every inbound linker's body and re-derive its row, so its outbound edge points at the
|
|
438
|
+
// new id. A linker missing from the repo is skipped; the build backstop catches any drift.
|
|
439
|
+
for (const linker of inboundLinks(manifest, concept.id, id)) {
|
|
440
|
+
const linkerConcept = findConcept(runtime.concepts, linker.concept);
|
|
441
|
+
if (!linkerConcept) continue;
|
|
442
|
+
const linkerPath = `${linkerConcept.dir}/${filenameFromId(linker.id)}`;
|
|
443
|
+
const linkerRaw = await readRaw(runtime.backend, linkerPath, token);
|
|
444
|
+
if (linkerRaw === null) continue;
|
|
445
|
+
const rewritten = rewriteCairnLink(linkerRaw, oldHref, newHref);
|
|
446
|
+
changes.push({ path: linkerPath, content: rewritten });
|
|
447
|
+
next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: rewritten }));
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
451
|
+
|
|
452
|
+
try {
|
|
453
|
+
await commitFiles(
|
|
454
|
+
runtime.backend,
|
|
455
|
+
changes,
|
|
456
|
+
{ message: `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`, author: { name: editor.displayName, email: editor.email } },
|
|
457
|
+
token,
|
|
458
|
+
);
|
|
459
|
+
} catch (err) {
|
|
460
|
+
if (isConflict(err)) {
|
|
461
|
+
const message = 'This file changed since you opened it. Reload and try again.';
|
|
462
|
+
throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
|
|
463
|
+
}
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
|
|
268
467
|
}
|
|
269
468
|
|
|
270
|
-
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, mintToken };
|
|
469
|
+
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, renameAction, mintToken };
|
|
271
470
|
}
|
|
@@ -9,11 +9,13 @@ import type { SiteIndex } from '../delivery/site-index.js';
|
|
|
9
9
|
import { buildSeoMeta } from '../delivery/seo.js';
|
|
10
10
|
import type { SeoMeta } from '../delivery/seo.js';
|
|
11
11
|
import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
|
|
12
|
+
import { buildLinkResolver } from '../delivery/manifest.js';
|
|
13
|
+
import type { LinkResolve } from '../content/links.js';
|
|
12
14
|
|
|
13
15
|
/** Injected dependencies for the public loaders. */
|
|
14
16
|
export interface PublicRoutesDeps {
|
|
15
17
|
site: SiteIndex;
|
|
16
|
-
render: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
18
|
+
render: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
|
|
17
19
|
origin: string;
|
|
18
20
|
/** Site name for og:site_name and the SEO head. */
|
|
19
21
|
siteName: string;
|
|
@@ -85,7 +87,7 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
|
|
|
85
87
|
...(fields.author ? { author: fields.author } : {}),
|
|
86
88
|
...(entry.date ? { feeds } : {}),
|
|
87
89
|
});
|
|
88
|
-
return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
|
|
90
|
+
return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
/** The chronological archive for one concept: every non-draft summary, newest-first. */
|