@glw907/cairn-cms 0.18.0 → 0.24.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 +127 -8
- package/dist/components/EditPage.svelte.d.ts +8 -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/concepts.d.ts.map +1 -1
- package/dist/content/concepts.js +7 -0
- package/dist/content/frontmatter.d.ts +8 -0
- package/dist/content/frontmatter.d.ts.map +1 -1
- package/dist/content/frontmatter.js +19 -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 +7 -0
- package/dist/content/links.d.ts.map +1 -1
- package/dist/content/links.js +11 -0
- package/dist/content/manifest.d.ts +15 -1
- package/dist/content/manifest.d.ts.map +1 -1
- package/dist/content/manifest.js +45 -3
- package/dist/content/types.d.ts +6 -0
- package/dist/content/types.d.ts.map +1 -1
- package/dist/content/validate.d.ts.map +1 -1
- package/dist/content/validate.js +8 -1
- package/dist/delivery/content-index.d.ts +7 -0
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +7 -0
- package/dist/delivery/head.d.ts +2 -0
- package/dist/delivery/head.d.ts.map +1 -0
- package/dist/delivery/head.js +4 -0
- package/dist/delivery/index.d.ts +0 -1
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +0 -1
- package/dist/delivery/manifest.d.ts.map +1 -1
- package/dist/delivery/manifest.js +7 -0
- package/dist/github/repo.d.ts.map +1 -1
- package/dist/github/repo.js +8 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/render/pipeline.d.ts +4 -0
- package/dist/render/pipeline.d.ts.map +1 -1
- package/dist/render/pipeline.js +3 -1
- package/dist/render/registry.d.ts +1 -1
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.d.ts +5 -0
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.js +12 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/remark-directives.js +15 -6
- package/dist/render/sanitize-schema.d.ts +4 -3
- package/dist/render/sanitize-schema.d.ts.map +1 -1
- package/dist/render/sanitize-schema.js +6 -5
- package/dist/sveltekit/content-routes.d.ts +11 -2
- package/dist/sveltekit/content-routes.d.ts.map +1 -1
- package/dist/sveltekit/content-routes.js +157 -9
- package/dist/sveltekit/public-routes.d.ts +1 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +1 -1
- package/package.json +7 -1
- package/src/lib/components/DeleteDialog.svelte +81 -0
- package/src/lib/components/EditPage.svelte +127 -8
- 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/concepts.ts +9 -0
- package/src/lib/content/frontmatter.ts +21 -0
- package/src/lib/content/ids.ts +12 -0
- package/src/lib/content/links.ts +13 -0
- package/src/lib/content/manifest.ts +55 -3
- package/src/lib/content/types.ts +6 -0
- package/src/lib/content/validate.ts +6 -1
- package/src/lib/delivery/content-index.ts +13 -0
- package/src/lib/delivery/head.ts +4 -0
- package/src/lib/delivery/index.ts +0 -1
- package/src/lib/delivery/manifest.ts +6 -0
- package/src/lib/github/repo.ts +8 -1
- package/src/lib/index.ts +10 -2
- package/src/lib/render/pipeline.ts +6 -1
- package/src/lib/render/registry.ts +1 -1
- package/src/lib/render/rehype-dispatch.ts +12 -1
- package/src/lib/render/remark-directives.ts +16 -5
- package/src/lib/render/sanitize-schema.ts +6 -5
- package/src/lib/sveltekit/content-routes.ts +178 -11
- package/src/lib/sveltekit/public-routes.ts +2 -1
|
@@ -30,6 +30,10 @@ export interface RendererOptions {
|
|
|
30
30
|
* vector the floor closes, so it is only for a site whose content is fully developer-controlled.
|
|
31
31
|
* It is a code-level adapter decision, never an editor-facing setting. */
|
|
32
32
|
unsafeDisableSanitize?: boolean;
|
|
33
|
+
/** The `rel` value forced on every `target="_blank"` anchor, applied last so it also covers
|
|
34
|
+
* component-built anchors. Defaults to `'noopener noreferrer'`. Set a different string to change
|
|
35
|
+
* it, or `false` to disable the injection (a site that owns its own anchor hardening). */
|
|
36
|
+
anchorRel?: string | false;
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
/** Compose a site's render pipeline from its component registry: directive syntax to
|
|
@@ -43,13 +47,14 @@ export function createRenderer(registry: ComponentRegistry, options: RendererOpt
|
|
|
43
47
|
const floor: PluggableList = options.unsafeDisableSanitize
|
|
44
48
|
? []
|
|
45
49
|
: [[rehypeSanitize, buildSanitizeSchema(registry, options.sanitizeSchema)]];
|
|
50
|
+
const rel = options.anchorRel ?? 'noopener noreferrer';
|
|
46
51
|
const rehypePlugins: PluggableList = [
|
|
47
52
|
rehypeRaw,
|
|
48
53
|
...floor,
|
|
49
54
|
[rehypeDispatch, registry, options.stagger],
|
|
50
55
|
rehypeSlug,
|
|
51
|
-
rehypeAnchorRel,
|
|
52
56
|
];
|
|
57
|
+
if (rel !== false) rehypePlugins.push([rehypeAnchorRel, rel]);
|
|
53
58
|
const processor = unified()
|
|
54
59
|
.use(remarkParse)
|
|
55
60
|
.use(remarkGfm)
|
|
@@ -19,7 +19,7 @@ export interface AttributeField {
|
|
|
19
19
|
/** Initial value; a string for text/select/icon, a boolean for boolean. */
|
|
20
20
|
default?: string | boolean;
|
|
21
21
|
/** Allowed values for `type: 'select'`. */
|
|
22
|
-
options?: string[];
|
|
22
|
+
options?: readonly string[];
|
|
23
23
|
/** Helper text shown under the field. */
|
|
24
24
|
help?: string;
|
|
25
25
|
}
|
|
@@ -7,7 +7,7 @@ export function isElement(node: ElementContent | undefined): node is Element {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
// hast Properties values are PropertyValue (string | number | boolean | array | null).
|
|
10
|
-
// Directive markers (
|
|
10
|
+
// Directive markers (dataPrimitive/dataRole/dataAttr<Key>) are always stamped as strings;
|
|
11
11
|
// this reads them back with that guarantee instead of casting at each call site.
|
|
12
12
|
export function strProp(node: Element, name: string): string | undefined {
|
|
13
13
|
const value = node.properties?.[name];
|
|
@@ -28,6 +28,17 @@ export function cardShell(classes: string[], body: ElementContent[]): Element {
|
|
|
28
28
|
return h('section', { className: classes }, [h('div', { className: ['card-body'] }, body)]);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Card head row: `<div class="ec-head">[icon]<h2 class="card-title">{title}</h2></div>`.
|
|
32
|
+
* Pass the title's inline children and an optional pre-built icon element, the way `cardShell`
|
|
33
|
+
* takes already-built body content. This factors the icon-plus-heading head that a titled
|
|
34
|
+
* component build would otherwise rebuild by hand (the shape the removed `splitHead` produced). */
|
|
35
|
+
export function headRow(title: ElementContent[], icon?: Element): Element {
|
|
36
|
+
const children: ElementContent[] = [];
|
|
37
|
+
if (icon) children.push(icon);
|
|
38
|
+
children.push(h('h2', { className: ['card-title'] }, title));
|
|
39
|
+
return h('div', { className: ['ec-head'] }, children);
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
/** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
|
|
32
43
|
* text nodes so the bare list serializes without newlines. Returns that <ul>. */
|
|
33
44
|
export function markFirstList(children: ElementContent[]): Element | undefined {
|
|
@@ -59,17 +59,22 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
|
|
|
59
59
|
const def = registry.get(node.name);
|
|
60
60
|
const attrs = node.attributes ?? {};
|
|
61
61
|
const role = attrs.role || undefined;
|
|
62
|
-
|
|
62
|
+
const iconField = def?.attributes?.find((field) => field.type === 'icon');
|
|
63
|
+
const iconKey = iconField?.key ?? 'icon';
|
|
64
|
+
let icon = attrs[iconKey] || undefined;
|
|
63
65
|
if (!icon && role) icon = registry.defaultIcon(node.name, role);
|
|
64
66
|
|
|
65
67
|
const properties: Record<string, string> = { dataPrimitive: node.name };
|
|
66
|
-
if (icon) properties.dataIcon = icon;
|
|
67
68
|
if (role) properties.dataRole = role;
|
|
68
69
|
// Carry every declared attribute to hast so the dispatch partitioner can build the
|
|
69
|
-
// component context.
|
|
70
|
-
//
|
|
70
|
+
// component context. The icon attribute uses the already-resolved `icon` (the author value
|
|
71
|
+
// coerced through the same empty-is-absent rule above, or the defaultIconByRole default), so
|
|
72
|
+
// a role default reaches the build through the one declared path and a blank `icon=` falls
|
|
73
|
+
// back to that default the same way a missing one does. data-attr-<key> survives to the
|
|
74
|
+
// element; build() consumes it and returns a fresh element, so the marker never reaches the
|
|
75
|
+
// published DOM.
|
|
71
76
|
for (const field of def?.attributes ?? []) {
|
|
72
|
-
const raw = attrs[field.key];
|
|
77
|
+
const raw = field === iconField ? icon : attrs[field.key];
|
|
73
78
|
if (raw != null) properties[dataAttrProp(field.key)] = raw;
|
|
74
79
|
}
|
|
75
80
|
|
|
@@ -91,6 +96,12 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
|
|
|
91
96
|
markSlot(child, (child as { name: string }).name);
|
|
92
97
|
}
|
|
93
98
|
}
|
|
99
|
+
|
|
100
|
+
// A directive [label] that the component has no `title` slot to claim would otherwise fall
|
|
101
|
+
// through as body content and render as a stray paragraph. Drop it.
|
|
102
|
+
if (!slotNames.has('title')) {
|
|
103
|
+
node.children = node.children.filter((child) => !isDirectiveLabel(child)) as typeof node.children;
|
|
104
|
+
}
|
|
94
105
|
});
|
|
95
106
|
|
|
96
107
|
visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
|
|
@@ -5,7 +5,7 @@ import { dataAttrProp, type ComponentRegistry } from './registry.js';
|
|
|
5
5
|
|
|
6
6
|
// The fixed directive markers the stamp writes and the dispatch reads. They are inert data
|
|
7
7
|
// attributes, never a script vector, and must survive the floor so the dispatch still runs.
|
|
8
|
-
const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', '
|
|
8
|
+
const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataRole', 'dataRise'];
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
|
|
@@ -51,15 +51,16 @@ export function buildSanitizeSchema(
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* Force rel
|
|
54
|
+
* Force a `rel` value on every target="_blank" anchor, to prevent reverse-tabnabbing.
|
|
55
55
|
* hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
|
|
56
|
-
* DOMPurify preview pass enforced, now on the delivered output as well.
|
|
56
|
+
* DOMPurify preview pass enforced, now on the delivered output as well. The value is the renderer's
|
|
57
|
+
* `anchorRel` option (default `noopener noreferrer`); a site can override it or disable it entirely.
|
|
57
58
|
*/
|
|
58
|
-
export function rehypeAnchorRel() {
|
|
59
|
+
export function rehypeAnchorRel(rel: string) {
|
|
59
60
|
return (tree: Root) => {
|
|
60
61
|
visit(tree, 'element', (node: Element) => {
|
|
61
62
|
if (node.tagName === 'a' && node.properties?.target === '_blank') {
|
|
62
|
-
node.properties.rel =
|
|
63
|
+
node.properties.rel = rel;
|
|
63
64
|
}
|
|
64
65
|
});
|
|
65
66
|
};
|
|
@@ -2,14 +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, commitFiles } from '../github/repo.js';
|
|
12
|
+
import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
|
|
11
13
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
12
|
-
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, type LinkTarget } from '../content/manifest.js';
|
|
14
|
+
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
13
15
|
import { CommitConflictError } from '../github/types.js';
|
|
14
16
|
import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
|
|
15
17
|
import type { Editor, Role } from '../auth/types.js';
|
|
@@ -63,9 +65,15 @@ export interface EditData {
|
|
|
63
65
|
title: string;
|
|
64
66
|
isNew: boolean;
|
|
65
67
|
saved: boolean;
|
|
68
|
+
/** True after a successful rename redirect (`?renamed=1`), to confirm the new URL to the author. */
|
|
69
|
+
renamed: boolean;
|
|
66
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;
|
|
67
73
|
/** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
|
|
68
74
|
linkTargets: LinkTarget[];
|
|
75
|
+
/** The entries that link to this one, for the delete guard. Empty when nothing links here. */
|
|
76
|
+
inboundLinks: InboundLink[];
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
@@ -205,16 +213,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
205
213
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
206
214
|
const isNew = event.url.searchParams.get('new') === '1';
|
|
207
215
|
const token = await mintToken(event.platform?.env ?? {});
|
|
208
|
-
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
|
+
]);
|
|
209
222
|
if (raw === null && !isNew) throw error(404, 'Entry not found');
|
|
210
223
|
|
|
211
224
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
212
225
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
213
226
|
|
|
214
227
|
let linkTargets: LinkTarget[] = [];
|
|
215
|
-
|
|
228
|
+
let inbound: InboundLink[] = [];
|
|
216
229
|
if (manifestRaw !== null) {
|
|
217
|
-
|
|
230
|
+
const manifest = parseManifest(manifestRaw);
|
|
231
|
+
linkTargets = manifest.entries.map((e) => ({
|
|
218
232
|
concept: e.concept,
|
|
219
233
|
id: e.id,
|
|
220
234
|
permalink: e.permalink,
|
|
@@ -222,6 +236,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
222
236
|
date: e.date,
|
|
223
237
|
draft: e.draft,
|
|
224
238
|
}));
|
|
239
|
+
inbound = inboundLinks(manifest, concept.id, id);
|
|
225
240
|
}
|
|
226
241
|
|
|
227
242
|
return {
|
|
@@ -234,8 +249,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
234
249
|
title,
|
|
235
250
|
isNew,
|
|
236
251
|
saved: event.url.searchParams.get('saved') === '1',
|
|
252
|
+
renamed: event.url.searchParams.get('renamed') === '1',
|
|
237
253
|
error: event.url.searchParams.get('error'),
|
|
254
|
+
slug: slugFromId(id, datePrefix),
|
|
238
255
|
linkTargets,
|
|
256
|
+
inboundLinks: inbound,
|
|
239
257
|
};
|
|
240
258
|
}
|
|
241
259
|
|
|
@@ -245,7 +263,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
/** Save an edit: validate, then commit with the session editor as author. Fails safe on 409. */
|
|
248
|
-
async function saveAction(event: ContentEvent): Promise<never> {
|
|
266
|
+
async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
249
267
|
const editor = sessionOf(event);
|
|
250
268
|
const concept = conceptOf(runtime, event.params);
|
|
251
269
|
const id = event.params.id ?? '';
|
|
@@ -277,7 +295,27 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
277
295
|
const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
278
296
|
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
279
297
|
const row = manifestEntryFromFile(concept, { path, raw: markdown });
|
|
280
|
-
const
|
|
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
|
+
}
|
|
281
319
|
|
|
282
320
|
try {
|
|
283
321
|
await commitFiles(
|
|
@@ -296,8 +334,137 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
296
334
|
}
|
|
297
335
|
throw err;
|
|
298
336
|
}
|
|
299
|
-
|
|
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`);
|
|
300
467
|
}
|
|
301
468
|
|
|
302
|
-
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, mintToken };
|
|
469
|
+
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, renameAction, mintToken };
|
|
303
470
|
}
|
|
@@ -45,6 +45,7 @@ export interface TagIndexData {
|
|
|
45
45
|
|
|
46
46
|
/** One entry's data: the detail entry, its rendered html, and its canonical URL. */
|
|
47
47
|
export interface EntryData {
|
|
48
|
+
concept: string;
|
|
48
49
|
entry: ContentEntry;
|
|
49
50
|
html: string;
|
|
50
51
|
canonicalUrl: string;
|
|
@@ -87,7 +88,7 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
|
|
|
87
88
|
...(fields.author ? { author: fields.author } : {}),
|
|
88
89
|
...(entry.date ? { feeds } : {}),
|
|
89
90
|
});
|
|
90
|
-
return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
|
|
91
|
+
return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
/** The chronological archive for one concept: every non-draft summary, newest-first. */
|