@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
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
/** The concept this entry belongs to, e.g. "posts". Posted with the confirm. */
|
|
3
|
+
conceptId: string;
|
|
4
|
+
/** The entry id within its concept. Posted with the confirm. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** A human label for the concept, e.g. "Post", used in the prompts. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** The current slug, prefilled into the input. */
|
|
9
|
+
slug: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* The Change URL control and its modal. The author edits the URL slug; on submit the ?/rename action
|
|
13
|
+
* moves the entry and rewrites every inbound cairn link in one commit, so no internal link breaks. A
|
|
14
|
+
* dated post keeps its date; only the slug changes. Built on a native <dialog>, following the
|
|
15
|
+
* DeleteDialog a11y conventions.
|
|
16
|
+
*/
|
|
17
|
+
declare const RenameDialog: import("svelte").Component<Props, {}, "">;
|
|
18
|
+
type RenameDialog = ReturnType<typeof RenameDialog>;
|
|
19
|
+
export default RenameDialog;
|
|
20
|
+
//# sourceMappingURL=RenameDialog.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RenameDialog.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/RenameDialog.svelte.ts"],"names":[],"mappings":"AAGE,UAAU,KAAK;IACb,gFAAgF;IAChF,SAAS,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,EAAE,EAAE,MAAM,CAAC;IACX,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,kDAAkD;IAClD,IAAI,EAAE,MAAM,CAAC;CACd;AA6DH;;;;;GAKG;AACH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -9,4 +9,7 @@ export { default as ComponentInsertDialog } from './ComponentInsertDialog.svelte
|
|
|
9
9
|
export { default as ComponentForm } from './ComponentForm.svelte';
|
|
10
10
|
export { default as IconPicker } from './IconPicker.svelte';
|
|
11
11
|
export { default as NavTree } from './NavTree.svelte';
|
|
12
|
+
export { default as LinkPicker } from './LinkPicker.svelte';
|
|
13
|
+
export { default as DeleteDialog } from './DeleteDialog.svelte';
|
|
14
|
+
export { default as RenameDialog } from './RenameDialog.svelte';
|
|
12
15
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/components/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,EAAE,OAAO,IAAI,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAClF,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kBAAkB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/components/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,OAAO,IAAI,QAAQ,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,EAAE,OAAO,IAAI,qBAAqB,EAAE,MAAM,gCAAgC,CAAC;AAClF,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,OAAO,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,uBAAuB,CAAC"}
|
package/dist/components/index.js
CHANGED
|
@@ -11,3 +11,6 @@ export { default as ComponentInsertDialog } from './ComponentInsertDialog.svelte
|
|
|
11
11
|
export { default as ComponentForm } from './ComponentForm.svelte';
|
|
12
12
|
export { default as IconPicker } from './IconPicker.svelte';
|
|
13
13
|
export { default as NavTree } from './NavTree.svelte';
|
|
14
|
+
export { default as LinkPicker } from './LinkPicker.svelte';
|
|
15
|
+
export { default as DeleteDialog } from './DeleteDialog.svelte';
|
|
16
|
+
export { default as RenameDialog } from './RenameDialog.svelte';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Completion, CompletionSource } from '@codemirror/autocomplete';
|
|
2
|
+
import type { LinkTarget } from '../content/manifest.js';
|
|
3
|
+
/** The open `[[query` before the cursor, or null. The query stops at a closing bracket or a newline,
|
|
4
|
+
* so a finished `[[x]]` link and ordinary prose never trigger. `from` is the index of the `[[`. */
|
|
5
|
+
export declare function matchCairnTrigger(before: string): {
|
|
6
|
+
query: string;
|
|
7
|
+
from: number;
|
|
8
|
+
} | null;
|
|
9
|
+
/** The completion options for a query: a case-insensitive title substring match, each option grouped
|
|
10
|
+
* by concept, a draft marked and a post date shown in the detail, and the apply text the full link. */
|
|
11
|
+
export declare function linkCompletions(targets: LinkTarget[], query: string): Completion[];
|
|
12
|
+
/** A CodeMirror CompletionSource over the site's link targets, triggered by `[[`. It replaces the
|
|
13
|
+
* whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
|
|
14
|
+
* filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
|
|
15
|
+
export declare function cairnLinkCompletionSource(targets: LinkTarget[]): CompletionSource;
|
|
16
|
+
//# sourceMappingURL=link-completion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"link-completion.d.ts","sourceRoot":"","sources":["../../src/lib/components/link-completion.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAuC,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAElH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAazD;oGACoG;AACpG,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAGxF;AAED;wGACwG;AACxG,wBAAgB,eAAe,CAAC,OAAO,EAAE,UAAU,EAAE,EAAE,KAAK,EAAE,MAAM,GAAG,UAAU,EAAE,CASlF;AAED;;kGAEkG;AAClG,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,UAAU,EAAE,GAAG,gBAAgB,CAcjF"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { syntaxTree } from '@codemirror/language';
|
|
2
|
+
import { formatCairnToken, escapeLinkText } from '../content/links.js';
|
|
3
|
+
/** The known concepts in display order; an unlisted concept sorts after these under its own name. */
|
|
4
|
+
const CONCEPT_SECTIONS = {
|
|
5
|
+
pages: { name: 'Pages', rank: 0 },
|
|
6
|
+
posts: { name: 'Posts', rank: 1 },
|
|
7
|
+
};
|
|
8
|
+
function sectionFor(concept) {
|
|
9
|
+
return CONCEPT_SECTIONS[concept] ?? { name: concept.charAt(0).toUpperCase() + concept.slice(1), rank: 2 };
|
|
10
|
+
}
|
|
11
|
+
/** The open `[[query` before the cursor, or null. The query stops at a closing bracket or a newline,
|
|
12
|
+
* so a finished `[[x]]` link and ordinary prose never trigger. `from` is the index of the `[[`. */
|
|
13
|
+
export function matchCairnTrigger(before) {
|
|
14
|
+
const match = /\[\[([^[\]\n]*)$/.exec(before);
|
|
15
|
+
return match ? { query: match[1], from: match.index } : null;
|
|
16
|
+
}
|
|
17
|
+
/** The completion options for a query: a case-insensitive title substring match, each option grouped
|
|
18
|
+
* by concept, a draft marked and a post date shown in the detail, and the apply text the full link. */
|
|
19
|
+
export function linkCompletions(targets, query) {
|
|
20
|
+
const q = query.trim().toLowerCase();
|
|
21
|
+
const matched = q ? targets.filter((t) => t.title.toLowerCase().includes(q)) : targets;
|
|
22
|
+
return matched.map((t) => ({
|
|
23
|
+
label: t.title,
|
|
24
|
+
section: sectionFor(t.concept),
|
|
25
|
+
detail: t.draft ? 'Draft' : t.date,
|
|
26
|
+
apply: `[${escapeLinkText(t.title)}](${formatCairnToken(t)})`,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
/** A CodeMirror CompletionSource over the site's link targets, triggered by `[[`. It replaces the
|
|
30
|
+
* whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
|
|
31
|
+
* filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
|
|
32
|
+
export function cairnLinkCompletionSource(targets) {
|
|
33
|
+
return (context) => {
|
|
34
|
+
const line = context.state.doc.lineAt(context.pos);
|
|
35
|
+
const before = context.state.sliceDoc(line.from, context.pos);
|
|
36
|
+
const trigger = matchCairnTrigger(before);
|
|
37
|
+
if (!trigger)
|
|
38
|
+
return null;
|
|
39
|
+
// Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
|
|
40
|
+
// the build resolver does not look inside code. The node name carries "Code" for both forms.
|
|
41
|
+
const node = syntaxTree(context.state).resolveInner(context.pos, -1);
|
|
42
|
+
for (let n = node; n; n = n.parent) {
|
|
43
|
+
if (/Code/.test(n.name))
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return { from: line.from + trigger.from, options: linkCompletions(targets, trigger.query), filter: false };
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure markdown selection transforms for the editor toolbar. Each call maps a document and a
|
|
3
|
-
* selection range to a new document and a new selection, with no DOM. The MarkdownEditor view
|
|
4
|
-
* dispatches the result; keeping the logic here lets it unit-test without a browser.
|
|
5
|
-
*/
|
|
6
1
|
export type FormatKind = 'bold' | 'italic' | 'code' | 'heading' | 'quote' | 'ul' | 'link';
|
|
7
2
|
export interface FormatResult {
|
|
8
3
|
doc: string;
|
|
@@ -10,4 +5,29 @@ export interface FormatResult {
|
|
|
10
5
|
to: number;
|
|
11
6
|
}
|
|
12
7
|
export declare function applyMarkdownFormat(doc: string, from: number, to: number, kind: FormatKind): FormatResult;
|
|
8
|
+
/**
|
|
9
|
+
* Insert an inline markdown link at the selection. With a non-empty selection the selected text
|
|
10
|
+
* becomes the display text; with an empty selection the title is the display text. The cursor
|
|
11
|
+
* collapses just after the inserted link. Unlike the block insert, this adds no surrounding
|
|
12
|
+
* blank lines, since a link is inline. Pure, so the editor dispatches the result.
|
|
13
|
+
*/
|
|
14
|
+
export declare function insertInlineLink(doc: string, from: number, to: number, href: string, title: string): FormatResult;
|
|
15
|
+
/**
|
|
16
|
+
* Unwrap every cairn: link whose href is exactly `href`, replacing it with its plain display text.
|
|
17
|
+
* The save guard's one-click fix calls this to drop a broken link while keeping the words. The
|
|
18
|
+
* document is parsed with the same remark pipeline extractCairnLinks uses, so the two agree on what
|
|
19
|
+
* a link is. Each matching link node is located by its source offsets and spliced out from last to
|
|
20
|
+
* first, which leaves the rest of the document exact and unescapes the display text. A token inside
|
|
21
|
+
* a code span or fence is not a link node, so it is never touched, and a link with a different url
|
|
22
|
+
* is left in place.
|
|
23
|
+
*/
|
|
24
|
+
export declare function unwrapCairnLink(doc: string, href: string): string;
|
|
25
|
+
/**
|
|
26
|
+
* Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
|
|
27
|
+
* the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
|
|
28
|
+
* inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
|
|
29
|
+
* span is not a link node and is never touched. Each matching node's source span is rewritten from
|
|
30
|
+
* last to first, replacing only the `](oldHref` run so the label and title stay exact.
|
|
31
|
+
*/
|
|
32
|
+
export declare function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string;
|
|
13
33
|
//# sourceMappingURL=markdown-format.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"markdown-format.d.ts","sourceRoot":"","sources":["../../src/lib/components/markdown-format.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"markdown-format.d.ts","sourceRoot":"","sources":["../../src/lib/components/markdown-format.ts"],"names":[],"mappings":"AAYA,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,GAAG,IAAI,GAAG,MAAM,CAAC;AAE1F,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAKD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,UAAU,GAAG,YAAY,CAsBzG;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAKjH;AAUD;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAgBjE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAkBtF"}
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure markdown selection transforms for the editor toolbar. Each call maps a document and a
|
|
3
|
+
* selection range to a new document and a new selection, with no DOM. The MarkdownEditor view
|
|
4
|
+
* dispatches the result; keeping the logic here lets it unit-test without a browser.
|
|
5
|
+
*/
|
|
6
|
+
import { unified } from 'unified';
|
|
7
|
+
import remarkParse from 'remark-parse';
|
|
8
|
+
import remarkGfm from 'remark-gfm';
|
|
9
|
+
import { visit } from 'unist-util-visit';
|
|
10
|
+
import { escapeLinkText } from '../content/links.js';
|
|
1
11
|
const WRAP = { bold: '**', italic: '_', code: '`' };
|
|
2
12
|
const LINE_PREFIX = { heading: '# ', quote: '> ', ul: '- ' };
|
|
3
13
|
export function applyMarkdownFormat(doc, from, to, kind) {
|
|
@@ -21,3 +31,78 @@ export function applyMarkdownFormat(doc, from, to, kind) {
|
|
|
21
31
|
const added = prefixed.length - region.length;
|
|
22
32
|
return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
|
|
23
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Insert an inline markdown link at the selection. With a non-empty selection the selected text
|
|
36
|
+
* becomes the display text; with an empty selection the title is the display text. The cursor
|
|
37
|
+
* collapses just after the inserted link. Unlike the block insert, this adds no surrounding
|
|
38
|
+
* blank lines, since a link is inline. Pure, so the editor dispatches the result.
|
|
39
|
+
*/
|
|
40
|
+
export function insertInlineLink(doc, from, to, href, title) {
|
|
41
|
+
const text = from < to ? doc.slice(from, to) : escapeLinkText(title);
|
|
42
|
+
const inserted = `[${text}](${href})`;
|
|
43
|
+
const end = from + inserted.length;
|
|
44
|
+
return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: end, to: end };
|
|
45
|
+
}
|
|
46
|
+
/** Concatenate a link node's text-child values. The parser has already unescaped them, so a source
|
|
47
|
+
* `Notes \[draft\]` yields `Notes [draft]`. Used instead of mdast-util-to-string, which is not a
|
|
48
|
+
* direct dependency. Non-text children (a nested emphasis, say) contribute no value, which is fine
|
|
49
|
+
* for the picker-produced links this fix targets. */
|
|
50
|
+
function linkText(node) {
|
|
51
|
+
return node.children.map((c) => ('value' in c ? c.value : '')).join('');
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Unwrap every cairn: link whose href is exactly `href`, replacing it with its plain display text.
|
|
55
|
+
* The save guard's one-click fix calls this to drop a broken link while keeping the words. The
|
|
56
|
+
* document is parsed with the same remark pipeline extractCairnLinks uses, so the two agree on what
|
|
57
|
+
* a link is. Each matching link node is located by its source offsets and spliced out from last to
|
|
58
|
+
* first, which leaves the rest of the document exact and unescapes the display text. A token inside
|
|
59
|
+
* a code span or fence is not a link node, so it is never touched, and a link with a different url
|
|
60
|
+
* is left in place.
|
|
61
|
+
*/
|
|
62
|
+
export function unwrapCairnLink(doc, href) {
|
|
63
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
|
|
64
|
+
const spans = [];
|
|
65
|
+
visit(tree, 'link', (node) => {
|
|
66
|
+
if (node.url !== href)
|
|
67
|
+
return;
|
|
68
|
+
const start = node.position?.start?.offset;
|
|
69
|
+
const end = node.position?.end?.offset;
|
|
70
|
+
if (start == null || end == null)
|
|
71
|
+
return;
|
|
72
|
+
spans.push({ start, end, text: linkText(node) });
|
|
73
|
+
});
|
|
74
|
+
spans.sort((a, b) => b.start - a.start);
|
|
75
|
+
let out = doc;
|
|
76
|
+
for (const span of spans) {
|
|
77
|
+
out = out.slice(0, span.start) + span.text + out.slice(span.end);
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
|
|
83
|
+
* the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
|
|
84
|
+
* inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
|
|
85
|
+
* span is not a link node and is never touched. Each matching node's source span is rewritten from
|
|
86
|
+
* last to first, replacing only the `](oldHref` run so the label and title stay exact.
|
|
87
|
+
*/
|
|
88
|
+
export function rewriteCairnLink(doc, oldHref, newHref) {
|
|
89
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
|
|
90
|
+
const spans = [];
|
|
91
|
+
visit(tree, 'link', (node) => {
|
|
92
|
+
if (node.url !== oldHref)
|
|
93
|
+
return;
|
|
94
|
+
const start = node.position?.start?.offset;
|
|
95
|
+
const end = node.position?.end?.offset;
|
|
96
|
+
if (start == null || end == null)
|
|
97
|
+
return;
|
|
98
|
+
spans.push({ start, end });
|
|
99
|
+
});
|
|
100
|
+
spans.sort((a, b) => b.start - a.start);
|
|
101
|
+
let out = doc;
|
|
102
|
+
for (const span of spans) {
|
|
103
|
+
const src = out.slice(span.start, span.end);
|
|
104
|
+
const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
|
|
105
|
+
out = out.slice(0, span.start) + rewritten + out.slice(span.end);
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../../src/lib/content/compose.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAc,YAAY,EAAE,cAAc,EAAE,YAAY,EAAiB,gBAAgB,EAAgB,MAAM,YAAY,CAAC;AAGxI;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,YAAY,EACrB,UAAU,GAAE,cAAc,EAAO,EACjC,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,CAAM,GAC3D,YAAY,
|
|
1
|
+
{"version":3,"file":"compose.d.ts","sourceRoot":"","sources":["../../src/lib/content/compose.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAc,YAAY,EAAE,cAAc,EAAE,YAAY,EAAiB,gBAAgB,EAAgB,MAAM,YAAY,CAAC;AAGxI;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,OAAO,EAAE,YAAY,EACrB,UAAU,GAAE,cAAc,EAAO,EACjC,SAAS,GAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,SAAS,CAAM,GAC3D,YAAY,CAyBd"}
|
package/dist/content/compose.js
CHANGED
|
@@ -23,6 +23,7 @@ export function composeRuntime(adapter, extensions = [], urlPolicy = {}) {
|
|
|
23
23
|
backend: adapter.backend,
|
|
24
24
|
sender: adapter.sender,
|
|
25
25
|
render: adapter.render,
|
|
26
|
+
manifestPath: adapter.manifestPath ?? 'src/content/.cairn/index.json',
|
|
26
27
|
registry: adapter.registry,
|
|
27
28
|
icons: adapter.icons,
|
|
28
29
|
navMenu: adapter.navMenu,
|
package/dist/content/ids.d.ts
CHANGED
|
@@ -28,4 +28,11 @@ export declare function slugFromId(id: string, datePrefix: DatePrefix | null): s
|
|
|
28
28
|
* malformed date so a bad create fails before touching git.
|
|
29
29
|
*/
|
|
30
30
|
export declare function composeDatedId(date: string, slug: string, datePrefix: DatePrefix): string;
|
|
31
|
+
/**
|
|
32
|
+
* Rename an id by swapping its slug, keeping any date prefix. slugFromId strips only the leading
|
|
33
|
+
* date prefix, so the id is exactly its prefix followed by its slug; this replaces the slug suffix
|
|
34
|
+
* with newSlug. A non-dated concept passes null, so the whole id is the slug and the id becomes
|
|
35
|
+
* newSlug. The caller validates newSlug with isValidId first.
|
|
36
|
+
*/
|
|
37
|
+
export declare function renameId(oldId: string, newSlug: string, datePrefix: DatePrefix | null): string;
|
|
31
38
|
//# sourceMappingURL=ids.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../../src/lib/content/ids.ts"],"names":[],"mappings":"AAOA,qGAAqG;AACrG,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,yDAAyD;AACzD,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM7C;AAED,uGAAuG;AACvG,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC;AASlD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,MAAM,CAG5E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,MAAM,CAiBzF"}
|
|
1
|
+
{"version":3,"file":"ids.d.ts","sourceRoot":"","sources":["../../src/lib/content/ids.ts"],"names":[],"mappings":"AAOA,qGAAqG;AACrG,wBAAgB,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAE7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,yDAAyD;AACzD,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED;;;;GAIG;AACH,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM7C;AAED,uGAAuG;AACvG,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,OAAO,GAAG,KAAK,CAAC;AASlD;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,MAAM,CAG5E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,MAAM,CAiBzF;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,GAAG,IAAI,GAAG,MAAM,CAI9F"}
|
package/dist/content/ids.js
CHANGED
|
@@ -71,3 +71,14 @@ export function composeDatedId(date, slug, datePrefix) {
|
|
|
71
71
|
}
|
|
72
72
|
return `${prefix}-${slug}`;
|
|
73
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Rename an id by swapping its slug, keeping any date prefix. slugFromId strips only the leading
|
|
76
|
+
* date prefix, so the id is exactly its prefix followed by its slug; this replaces the slug suffix
|
|
77
|
+
* with newSlug. A non-dated concept passes null, so the whole id is the slug and the id becomes
|
|
78
|
+
* newSlug. The caller validates newSlug with isValidId first.
|
|
79
|
+
*/
|
|
80
|
+
export function renameId(oldId, newSlug, datePrefix) {
|
|
81
|
+
const oldSlug = slugFromId(oldId, datePrefix);
|
|
82
|
+
const prefix = oldId.slice(0, oldId.length - oldSlug.length);
|
|
83
|
+
return prefix + newSlug;
|
|
84
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** A resolved reference to a content entry by its concept and permanent id. */
|
|
2
|
+
export interface CairnRef {
|
|
3
|
+
concept: string;
|
|
4
|
+
id: string;
|
|
5
|
+
}
|
|
6
|
+
/** Resolve a reference to its live permalink. Returns undefined when the target is missing (the
|
|
7
|
+
* preview marks it); the build resolver throws instead, so a dangling token fails the build. */
|
|
8
|
+
export type LinkResolve = (ref: CairnRef) => string | undefined;
|
|
9
|
+
/** Parse a `cairn:<concept>/<id>` href, or null for any other href or a malformed token. */
|
|
10
|
+
export declare function parseCairnToken(href: string): CairnRef | null;
|
|
11
|
+
/** Write the `cairn:<concept>/<id>` token for a ref. The inverse of parseCairnToken, so the editor
|
|
12
|
+
* link picker and the autocomplete write exactly the form the resolver reads back. */
|
|
13
|
+
export declare function formatCairnToken(ref: CairnRef): string;
|
|
14
|
+
/** Escape the characters that would break a markdown link's display text: a backslash and the
|
|
15
|
+
* square brackets that delimit the text. Used where a content title becomes link display text,
|
|
16
|
+
* so an unbalanced bracket in a title cannot truncate the generated link. */
|
|
17
|
+
export declare function escapeLinkText(text: string): string;
|
|
18
|
+
/** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
|
|
19
|
+
* Parses the body as mdast, so a token inside a code span or fence is never matched. */
|
|
20
|
+
export declare function extractCairnLinks(body: string): CairnRef[];
|
|
21
|
+
//# sourceMappingURL=links.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/lib/content/links.ts"],"names":[],"mappings":"AAUA,+EAA+E;AAC/E,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACZ;AAED;iGACiG;AACjG,MAAM,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,QAAQ,KAAK,MAAM,GAAG,SAAS,CAAC;AAEhE,4FAA4F;AAC5F,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAS7D;AAED;uFACuF;AACvF,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,QAAQ,GAAG,MAAM,CAEtD;AAED;;8EAE8E;AAC9E,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEnD;AAED;yFACyF;AACzF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,EAAE,CAa1D"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// cairn-cms: the cairn: internal-link token. An internal link is a standard CommonMark link
|
|
2
|
+
// whose href is `cairn:<concept>/<id>`, keyed to the target's permanent filename stem so it
|
|
3
|
+
// survives a slug, date, or permalink change (content-graph design). This module owns the
|
|
4
|
+
// grammar; the render resolver (resolve-links.ts) reuses parseCairnToken.
|
|
5
|
+
import { unified } from 'unified';
|
|
6
|
+
import remarkParse from 'remark-parse';
|
|
7
|
+
import remarkGfm from 'remark-gfm';
|
|
8
|
+
import { visit } from 'unist-util-visit';
|
|
9
|
+
import { isValidId } from './ids.js';
|
|
10
|
+
/** Parse a `cairn:<concept>/<id>` href, or null for any other href or a malformed token. */
|
|
11
|
+
export function parseCairnToken(href) {
|
|
12
|
+
if (!href.startsWith('cairn:'))
|
|
13
|
+
return null;
|
|
14
|
+
const rest = href.slice('cairn:'.length);
|
|
15
|
+
const slash = rest.indexOf('/');
|
|
16
|
+
if (slash <= 0)
|
|
17
|
+
return null;
|
|
18
|
+
const concept = rest.slice(0, slash);
|
|
19
|
+
const id = rest.slice(slash + 1);
|
|
20
|
+
if (!concept || !isValidId(id))
|
|
21
|
+
return null;
|
|
22
|
+
return { concept, id };
|
|
23
|
+
}
|
|
24
|
+
/** Write the `cairn:<concept>/<id>` token for a ref. The inverse of parseCairnToken, so the editor
|
|
25
|
+
* link picker and the autocomplete write exactly the form the resolver reads back. */
|
|
26
|
+
export function formatCairnToken(ref) {
|
|
27
|
+
return `cairn:${ref.concept}/${ref.id}`;
|
|
28
|
+
}
|
|
29
|
+
/** Escape the characters that would break a markdown link's display text: a backslash and the
|
|
30
|
+
* square brackets that delimit the text. Used where a content title becomes link display text,
|
|
31
|
+
* so an unbalanced bracket in a title cannot truncate the generated link. */
|
|
32
|
+
export function escapeLinkText(text) {
|
|
33
|
+
return text.replace(/[\\[\]]/g, (ch) => `\\${ch}`);
|
|
34
|
+
}
|
|
35
|
+
/** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
|
|
36
|
+
* Parses the body as mdast, so a token inside a code span or fence is never matched. */
|
|
37
|
+
export function extractCairnLinks(body) {
|
|
38
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
const refs = [];
|
|
41
|
+
visit(tree, 'link', (node) => {
|
|
42
|
+
const ref = node.url ? parseCairnToken(node.url) : null;
|
|
43
|
+
if (!ref)
|
|
44
|
+
return;
|
|
45
|
+
const key = `${ref.concept}/${ref.id}`;
|
|
46
|
+
if (seen.has(key))
|
|
47
|
+
return;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
refs.push(ref);
|
|
50
|
+
});
|
|
51
|
+
return refs;
|
|
52
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type CairnRef, type LinkResolve } from './links.js';
|
|
2
|
+
import type { ConceptDescriptor } from './types.js';
|
|
3
|
+
/** One entry's projection: its identity, routing, draft flag, and outbound cairn: edges. */
|
|
4
|
+
export interface ManifestEntry {
|
|
5
|
+
id: string;
|
|
6
|
+
concept: string;
|
|
7
|
+
title: string;
|
|
8
|
+
date?: string;
|
|
9
|
+
permalink: string;
|
|
10
|
+
draft: boolean;
|
|
11
|
+
links: CairnRef[];
|
|
12
|
+
}
|
|
13
|
+
/** The whole corpus as one committed file. `version` guards a future shape migration. */
|
|
14
|
+
export interface Manifest {
|
|
15
|
+
version: 1;
|
|
16
|
+
entries: ManifestEntry[];
|
|
17
|
+
}
|
|
18
|
+
/** The minimal entry view the preview resolver and (later) the picker read. */
|
|
19
|
+
export interface LinkTarget {
|
|
20
|
+
concept: string;
|
|
21
|
+
id: string;
|
|
22
|
+
permalink: string;
|
|
23
|
+
title: string;
|
|
24
|
+
date?: string;
|
|
25
|
+
draft: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** Build one manifest entry from a content file. Drafts are included and flagged. */
|
|
28
|
+
export declare function manifestEntryFromFile(descriptor: ConceptDescriptor, file: {
|
|
29
|
+
path: string;
|
|
30
|
+
raw: string;
|
|
31
|
+
}): ManifestEntry;
|
|
32
|
+
/** An empty manifest, the starting point when no committed file exists yet. */
|
|
33
|
+
export declare function emptyManifest(): Manifest;
|
|
34
|
+
/** Serialize canonically: entries sorted by concept then id, links sorted and deduped, a fixed key
|
|
35
|
+
* order, two-space pretty, and a trailing newline, so the committed file diffs cleanly in a PR. */
|
|
36
|
+
export declare function serializeManifest(manifest: Manifest): string;
|
|
37
|
+
/** Parse a committed manifest. Throws on malformed JSON, a wrong version, or a malformed entry, so
|
|
38
|
+
* every reader (the save guard, the delete path, the preview) sees a well-formed graph or a clear
|
|
39
|
+
* error. The build regenerates the manifest, so a real file is always canonical; this guards a
|
|
40
|
+
* hand-edited or truncated one. */
|
|
41
|
+
export declare function parseManifest(raw: string): Manifest;
|
|
42
|
+
/** Throw if the committed manifest drifts from what the corpus says. Both sides are compared in the
|
|
43
|
+
* canonical serialized form, so semantic equality never spuriously fails. The build calls this so a
|
|
44
|
+
* raw-git content edit, which leaves the committed manifest stale, fails the build loudly. */
|
|
45
|
+
export declare function verifyManifest(built: Manifest, committedRaw: string): void;
|
|
46
|
+
/** Replace the entry with the same concept and id, or add it. Order does not matter, since
|
|
47
|
+
* serializeManifest sorts. This is the save path's incremental patch. */
|
|
48
|
+
export declare function upsertEntry(manifest: Manifest, entry: ManifestEntry): Manifest;
|
|
49
|
+
/** Drop the entry with the given concept and id, if present. The delete path's patch. */
|
|
50
|
+
export declare function removeEntry(manifest: Manifest, concept: string, id: string): Manifest;
|
|
51
|
+
/** One inbound linker: enough to name it and link to its edit page in the delete guard. */
|
|
52
|
+
export interface InboundLink {
|
|
53
|
+
concept: string;
|
|
54
|
+
id: string;
|
|
55
|
+
title: string;
|
|
56
|
+
permalink: string;
|
|
57
|
+
}
|
|
58
|
+
/** Every entry whose outbound edges point at the target, excluding the target itself. The delete
|
|
59
|
+
* guard reads this to name "what links here"; the backlinks panel will reuse it. Pure over the
|
|
60
|
+
* manifest, so the request-time delete path and a unit test call it the same way. */
|
|
61
|
+
export declare function inboundLinks(manifest: Manifest, concept: string, id: string): InboundLink[];
|
|
62
|
+
/** A resolver backed by manifest targets, for the admin preview. A miss returns undefined, so the
|
|
63
|
+
* render step marks the link broken rather than throwing. The build resolver throws instead. */
|
|
64
|
+
export declare function manifestLinkResolver(targets: {
|
|
65
|
+
concept: string;
|
|
66
|
+
id: string;
|
|
67
|
+
permalink: string;
|
|
68
|
+
}[]): LinkResolve;
|
|
69
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../src/lib/content/manifest.ts"],"names":[],"mappings":"AAQA,OAAO,EAAqB,KAAK,QAAQ,EAAE,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAChF,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,4FAA4F;AAC5F,MAAM,WAAW,aAAa;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,QAAQ,EAAE,CAAC;CACnB;AAED,yFAAyF;AACzF,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,CAAC;IACX,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B;AAED,+EAA+E;AAC/E,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;CAChB;AAmBD,qFAAqF;AACrF,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,iBAAiB,EAAE,IAAI,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,aAAa,CAiBvH;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,IAAI,QAAQ,CAExC;AAMD;oGACoG;AACpG,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAW5D;AAED;;;oCAGoC;AACpC,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,CAqCnD;AAED;;+FAE+F;AAC/F,wBAAgB,cAAc,CAAC,KAAK,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,GAAG,IAAI,CAM1E;AAED;0EAC0E;AAC1E,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,aAAa,GAAG,QAAQ,CAI9E;AAED,yFAAyF;AACzF,wBAAgB,WAAW,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,QAAQ,CAErF;AAED,2FAA2F;AAC3F,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;sFAEsF;AACtF,wBAAgB,YAAY,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,WAAW,EAAE,CAK3F;AAED;iGACiG;AACjG,wBAAgB,oBAAoB,CAAC,OAAO,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,GAAG,WAAW,CAG/G"}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// cairn-cms: the content manifest, a committed JSON projection of the corpus (content-graph
|
|
2
|
+
// design). The files in git stay the source of truth; the manifest exists so request-time admin
|
|
3
|
+
// code reads the content graph without an N+1 GitHub crawl. The build regenerates and verifies
|
|
4
|
+
// it; the save path patches one entry and commits it with the content in one commit. Each entry
|
|
5
|
+
// carries its identity and its outbound cairn: edges, so the manifest is the link graph.
|
|
6
|
+
import { idFromFilename, slugFromId } from './ids.js';
|
|
7
|
+
import { parseMarkdown } from './frontmatter.js';
|
|
8
|
+
import { permalink } from './permalink.js';
|
|
9
|
+
import { extractCairnLinks } from './links.js';
|
|
10
|
+
function basename(path) {
|
|
11
|
+
const slash = path.lastIndexOf('/');
|
|
12
|
+
return slash >= 0 ? path.slice(slash + 1) : path;
|
|
13
|
+
}
|
|
14
|
+
/** Mirror content-index's frontmatter coercion: a present non-empty string, else undefined. */
|
|
15
|
+
function asString(value) {
|
|
16
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
/** Mirror content-index's date coercion: an unquoted YAML date is a JS Date, a string is sliced. */
|
|
19
|
+
function asDate(value) {
|
|
20
|
+
if (value instanceof Date)
|
|
21
|
+
return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
|
|
22
|
+
if (typeof value === 'string')
|
|
23
|
+
return value.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
/** Build one manifest entry from a content file. Drafts are included and flagged. */
|
|
27
|
+
export function manifestEntryFromFile(descriptor, file) {
|
|
28
|
+
const id = idFromFilename(basename(file.path));
|
|
29
|
+
// Use the same slug rule content-index uses, so the manifest's permalink for an entry always
|
|
30
|
+
// equals content-index's permalink for it. A cairn link must resolve to one URL whether the
|
|
31
|
+
// admin preview reads the manifest or the public build reads the content index.
|
|
32
|
+
const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
|
|
33
|
+
const { frontmatter, body } = parseMarkdown(file.raw);
|
|
34
|
+
const date = asDate(frontmatter.date);
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
concept: descriptor.id,
|
|
38
|
+
title: asString(frontmatter.title) ?? id,
|
|
39
|
+
date,
|
|
40
|
+
permalink: permalink(descriptor, { id, slug, date }),
|
|
41
|
+
draft: frontmatter.draft === true,
|
|
42
|
+
links: extractCairnLinks(body),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** An empty manifest, the starting point when no committed file exists yet. */
|
|
46
|
+
export function emptyManifest() {
|
|
47
|
+
return { version: 1, entries: [] };
|
|
48
|
+
}
|
|
49
|
+
function compareRef(a, b) {
|
|
50
|
+
return a.concept.localeCompare(b.concept) || a.id.localeCompare(b.id);
|
|
51
|
+
}
|
|
52
|
+
/** Serialize canonically: entries sorted by concept then id, links sorted and deduped, a fixed key
|
|
53
|
+
* order, two-space pretty, and a trailing newline, so the committed file diffs cleanly in a PR. */
|
|
54
|
+
export function serializeManifest(manifest) {
|
|
55
|
+
const entries = [...manifest.entries].sort(compareRef).map((e) => ({
|
|
56
|
+
id: e.id,
|
|
57
|
+
concept: e.concept,
|
|
58
|
+
title: e.title,
|
|
59
|
+
...(e.date ? { date: e.date } : {}),
|
|
60
|
+
permalink: e.permalink,
|
|
61
|
+
draft: e.draft,
|
|
62
|
+
links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
|
|
63
|
+
}));
|
|
64
|
+
return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
|
|
65
|
+
}
|
|
66
|
+
/** Parse a committed manifest. Throws on malformed JSON, a wrong version, or a malformed entry, so
|
|
67
|
+
* every reader (the save guard, the delete path, the preview) sees a well-formed graph or a clear
|
|
68
|
+
* error. The build regenerates the manifest, so a real file is always canonical; this guards a
|
|
69
|
+
* hand-edited or truncated one. */
|
|
70
|
+
export function parseManifest(raw) {
|
|
71
|
+
const data = JSON.parse(raw);
|
|
72
|
+
if (!data || typeof data !== 'object') {
|
|
73
|
+
throw new Error('content manifest: malformed file, expected { version, entries: [] }');
|
|
74
|
+
}
|
|
75
|
+
const obj = data;
|
|
76
|
+
if (obj.version !== 1) {
|
|
77
|
+
throw new Error(`content manifest: unsupported version ${String(obj.version)}, expected 1`);
|
|
78
|
+
}
|
|
79
|
+
if (!Array.isArray(obj.entries)) {
|
|
80
|
+
throw new Error('content manifest: malformed file, expected { version, entries: [] }');
|
|
81
|
+
}
|
|
82
|
+
for (const entry of obj.entries) {
|
|
83
|
+
const e = entry;
|
|
84
|
+
const ok = e &&
|
|
85
|
+
typeof e.id === 'string' &&
|
|
86
|
+
typeof e.concept === 'string' &&
|
|
87
|
+
typeof e.title === 'string' &&
|
|
88
|
+
typeof e.permalink === 'string' &&
|
|
89
|
+
typeof e.draft === 'boolean' &&
|
|
90
|
+
(e.date === undefined || typeof e.date === 'string') &&
|
|
91
|
+
Array.isArray(e.links);
|
|
92
|
+
if (!ok) {
|
|
93
|
+
throw new Error(`content manifest: malformed entry ${JSON.stringify(e)}`);
|
|
94
|
+
}
|
|
95
|
+
// Validate each link element's shape, not just that links is an array. inboundLinks and the
|
|
96
|
+
// delete guard read l.concept and l.id, so a string, null, or id-less element would read as
|
|
97
|
+
// undefined and silently drop a real inbound linker. Reject it here instead.
|
|
98
|
+
for (const link of e.links) {
|
|
99
|
+
const l = link;
|
|
100
|
+
if (!l || typeof l !== 'object' || typeof l.concept !== 'string' || typeof l.id !== 'string') {
|
|
101
|
+
throw new Error(`content manifest: malformed link ${JSON.stringify(link)} in entry ${JSON.stringify(e)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { version: 1, entries: obj.entries };
|
|
106
|
+
}
|
|
107
|
+
/** Throw if the committed manifest drifts from what the corpus says. Both sides are compared in the
|
|
108
|
+
* canonical serialized form, so semantic equality never spuriously fails. The build calls this so a
|
|
109
|
+
* raw-git content edit, which leaves the committed manifest stale, fails the build loudly. */
|
|
110
|
+
export function verifyManifest(built, committedRaw) {
|
|
111
|
+
if (committedRaw !== serializeManifest(built)) {
|
|
112
|
+
throw new Error('content manifest is stale: the committed file does not match the corpus. Regenerate it (npm run cairn:manifest) and commit the result.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Replace the entry with the same concept and id, or add it. Order does not matter, since
|
|
116
|
+
* serializeManifest sorts. This is the save path's incremental patch. */
|
|
117
|
+
export function upsertEntry(manifest, entry) {
|
|
118
|
+
const entries = manifest.entries.filter((e) => !(e.concept === entry.concept && e.id === entry.id));
|
|
119
|
+
entries.push(entry);
|
|
120
|
+
return { version: 1, entries };
|
|
121
|
+
}
|
|
122
|
+
/** Drop the entry with the given concept and id, if present. The delete path's patch. */
|
|
123
|
+
export function removeEntry(manifest, concept, id) {
|
|
124
|
+
return { version: 1, entries: manifest.entries.filter((e) => !(e.concept === concept && e.id === id)) };
|
|
125
|
+
}
|
|
126
|
+
/** Every entry whose outbound edges point at the target, excluding the target itself. The delete
|
|
127
|
+
* guard reads this to name "what links here"; the backlinks panel will reuse it. Pure over the
|
|
128
|
+
* manifest, so the request-time delete path and a unit test call it the same way. */
|
|
129
|
+
export function inboundLinks(manifest, concept, id) {
|
|
130
|
+
return manifest.entries
|
|
131
|
+
.filter((e) => !(e.concept === concept && e.id === id))
|
|
132
|
+
.filter((e) => e.links.some((l) => l.concept === concept && l.id === id))
|
|
133
|
+
.map((e) => ({ concept: e.concept, id: e.id, title: e.title, permalink: e.permalink }));
|
|
134
|
+
}
|
|
135
|
+
/** A resolver backed by manifest targets, for the admin preview. A miss returns undefined, so the
|
|
136
|
+
* render step marks the link broken rather than throwing. The build resolver throws instead. */
|
|
137
|
+
export function manifestLinkResolver(targets) {
|
|
138
|
+
const byKey = new Map(targets.map((t) => [`${t.concept}/${t.id}`, t.permalink]));
|
|
139
|
+
return (ref) => byKey.get(`${ref.concept}/${ref.id}`);
|
|
140
|
+
}
|
package/dist/content/types.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ComponentRegistry } from '../render/registry.js';
|
|
|
2
2
|
import type { IconSet } from '../render/glyph.js';
|
|
3
3
|
import type { DatePrefix } from './ids.js';
|
|
4
4
|
import type { ConceptSchema } from './schema.js';
|
|
5
|
+
import type { LinkResolve } from './links.js';
|
|
5
6
|
/** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
|
|
6
7
|
interface FieldBase {
|
|
7
8
|
/** Frontmatter key and form input name. */
|
|
@@ -149,10 +150,16 @@ export interface CairnAdapter {
|
|
|
149
150
|
};
|
|
150
151
|
backend: BackendConfig;
|
|
151
152
|
sender: SenderConfig;
|
|
152
|
-
/** The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
153
|
+
/** The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
154
|
+
* `resolve` rewrites cairn: links to live permalinks; the build passes a site-index resolver, the
|
|
155
|
+
* preview a manifest one. */
|
|
153
156
|
render(md: string, opts?: {
|
|
154
157
|
stagger?: boolean;
|
|
158
|
+
resolve?: LinkResolve;
|
|
155
159
|
}): string | Promise<string>;
|
|
160
|
+
/** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
|
|
161
|
+
* in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
|
|
162
|
+
manifestPath?: string;
|
|
156
163
|
/** Directive component registry; the renderer and the future palette derive from it (seam 3). */
|
|
157
164
|
registry?: ComponentRegistry;
|
|
158
165
|
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
@@ -243,7 +250,9 @@ export interface CairnRuntime {
|
|
|
243
250
|
/** The site's one renderer: the editor preview and every public page call it (design decision 4). */
|
|
244
251
|
render(md: string, opts?: {
|
|
245
252
|
stagger?: boolean;
|
|
253
|
+
resolve?: LinkResolve;
|
|
246
254
|
}): string | Promise<string>;
|
|
255
|
+
manifestPath: string;
|
|
247
256
|
registry?: ComponentRegistry;
|
|
248
257
|
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
249
258
|
icons?: IconSet;
|