@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,109 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The "Link to page" control and its modal. It lists the site's posts and pages from the committed
|
|
4
|
+
manifest (the linkTargets the editor receives), grouped by concept with Pages first, each post
|
|
5
|
+
showing its date and each draft marked. Picking a target inserts a cairn: internal link through the
|
|
6
|
+
editor's registerInsertLink seam. Built on a native <dialog>, following the component dialog's a11y
|
|
7
|
+
conventions. The plain-URL link stays the toolbar's link button; this is for an internal target.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import type { LinkTarget } from '../content/manifest.js';
|
|
11
|
+
import { formatCairnToken } from '../content/links.js';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** The site's link targets, from the committed manifest (editLoad ships them). */
|
|
15
|
+
linkTargets: LinkTarget[];
|
|
16
|
+
/** Insert an inline cairn link at the editor cursor. */
|
|
17
|
+
insert: (href: string, title: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { linkTargets, insert }: Props = $props();
|
|
21
|
+
|
|
22
|
+
let dialog = $state<HTMLDialogElement | null>(null);
|
|
23
|
+
let query = $state('');
|
|
24
|
+
|
|
25
|
+
// Group filtered targets by concept, Pages first then Posts then any other concept, so the list
|
|
26
|
+
// reads in a stable order. The filter is a case-insensitive title substring.
|
|
27
|
+
const ORDER: Record<string, number> = { pages: 0, posts: 1 };
|
|
28
|
+
function rank(concept: string): number {
|
|
29
|
+
return ORDER[concept] ?? 2;
|
|
30
|
+
}
|
|
31
|
+
function heading(concept: string): string {
|
|
32
|
+
if (concept === 'pages') return 'Pages';
|
|
33
|
+
if (concept === 'posts') return 'Posts';
|
|
34
|
+
return concept.charAt(0).toUpperCase() + concept.slice(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const groups = $derived.by(() => {
|
|
38
|
+
const q = query.trim().toLowerCase();
|
|
39
|
+
const matched = q ? linkTargets.filter((t) => t.title.toLowerCase().includes(q)) : linkTargets;
|
|
40
|
+
const byConcept = new Map<string, LinkTarget[]>();
|
|
41
|
+
for (const t of matched) {
|
|
42
|
+
const list = byConcept.get(t.concept) ?? [];
|
|
43
|
+
list.push(t);
|
|
44
|
+
byConcept.set(t.concept, list);
|
|
45
|
+
}
|
|
46
|
+
return [...byConcept.entries()]
|
|
47
|
+
.map(([concept, items]) => ({ concept, heading: heading(concept), items }))
|
|
48
|
+
.sort((a, b) => rank(a.concept) - rank(b.concept) || a.heading.localeCompare(b.heading));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
function open() {
|
|
52
|
+
query = '';
|
|
53
|
+
dialog?.showModal();
|
|
54
|
+
}
|
|
55
|
+
function close() {
|
|
56
|
+
dialog?.close();
|
|
57
|
+
}
|
|
58
|
+
function choose(target: LinkTarget) {
|
|
59
|
+
insert(formatCairnToken(target), target.title);
|
|
60
|
+
close();
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Link to page" onclick={open}>
|
|
65
|
+
Link to page
|
|
66
|
+
</button>
|
|
67
|
+
|
|
68
|
+
<dialog class="modal" aria-labelledby="cairn-link-dialog-title" bind:this={dialog}>
|
|
69
|
+
<div class="modal-box">
|
|
70
|
+
<div class="mb-3 flex items-center justify-between">
|
|
71
|
+
<h2 id="cairn-link-dialog-title" class="text-base font-semibold">Link to a page</h2>
|
|
72
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<input
|
|
76
|
+
type="search"
|
|
77
|
+
class="input input-bordered mb-3 w-full"
|
|
78
|
+
placeholder="Search by title"
|
|
79
|
+
aria-label="Search pages and posts"
|
|
80
|
+
bind:value={query}
|
|
81
|
+
/>
|
|
82
|
+
|
|
83
|
+
{#if groups.length === 0}
|
|
84
|
+
<p class="text-sm text-[var(--color-muted)]">No pages or posts to link to.</p>
|
|
85
|
+
{:else}
|
|
86
|
+
{#each groups as group (group.concept)}
|
|
87
|
+
<h3 class="mt-2 mb-1 text-xs font-semibold tracking-wide text-[var(--color-muted)] uppercase">{group.heading}</h3>
|
|
88
|
+
<ul class="menu w-full">
|
|
89
|
+
{#each group.items as target (`${target.concept}/${target.id}`)}
|
|
90
|
+
<li>
|
|
91
|
+
<button type="button" onclick={() => choose(target)}>
|
|
92
|
+
<span class="flex flex-col items-start">
|
|
93
|
+
<span class="font-medium">{target.title}</span>
|
|
94
|
+
<span class="text-xs text-[var(--color-muted)]">
|
|
95
|
+
{#if target.draft}<span class="badge badge-ghost badge-sm mr-1">Draft</span>{/if}
|
|
96
|
+
{#if target.date}{target.date}{/if}
|
|
97
|
+
</span>
|
|
98
|
+
</span>
|
|
99
|
+
</button>
|
|
100
|
+
</li>
|
|
101
|
+
{/each}
|
|
102
|
+
</ul>
|
|
103
|
+
{/each}
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
<form method="dialog" class="modal-backdrop">
|
|
107
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
108
|
+
</form>
|
|
109
|
+
</dialog>
|
|
@@ -9,7 +9,7 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
9
9
|
<script lang="ts">
|
|
10
10
|
import { onMount, onDestroy } from 'svelte';
|
|
11
11
|
import EditorToolbar from './EditorToolbar.svelte';
|
|
12
|
-
import { applyMarkdownFormat, type FormatKind } from './markdown-format.js';
|
|
12
|
+
import { applyMarkdownFormat, insertInlineLink, type FormatKind } from './markdown-format.js';
|
|
13
13
|
|
|
14
14
|
interface Props {
|
|
15
15
|
/** The markdown source; bindable so the parent reads edits back. */
|
|
@@ -18,9 +18,14 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
18
18
|
name: string;
|
|
19
19
|
/** Receives a `(text) => void` that inserts at the cursor; the palette calls it. */
|
|
20
20
|
registerInsert?: (insert: (text: string) => void) => void;
|
|
21
|
+
/** Receives a `(href, title) => void` that inserts an inline link; the link picker calls it. */
|
|
22
|
+
registerInsertLink?: (insert: (href: string, title: string) => void) => void;
|
|
23
|
+
/** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
|
|
24
|
+
* type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
|
|
25
|
+
completionSources?: import('@codemirror/autocomplete').CompletionSource[];
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
let { value = $bindable(), name, registerInsert }: Props = $props();
|
|
28
|
+
let { value = $bindable(), name, registerInsert, registerInsertLink, completionSources = [] }: Props = $props();
|
|
24
29
|
|
|
25
30
|
let host = $state<HTMLDivElement | null>(null);
|
|
26
31
|
let mounted = $state(false);
|
|
@@ -35,6 +40,7 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
35
40
|
const markdownMod = await import('@codemirror/lang-markdown');
|
|
36
41
|
const commandsMod = await import('@codemirror/commands');
|
|
37
42
|
const languageMod = await import('@codemirror/language');
|
|
43
|
+
const autocompleteMod = await import('@codemirror/autocomplete');
|
|
38
44
|
|
|
39
45
|
if (!host) return;
|
|
40
46
|
|
|
@@ -56,8 +62,13 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
56
62
|
doc: value,
|
|
57
63
|
extensions: [
|
|
58
64
|
commandsMod.history(),
|
|
59
|
-
keymap.of([...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
|
|
65
|
+
keymap.of([...autocompleteMod.completionKeymap, ...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
|
|
60
66
|
markdownMod.markdown(),
|
|
67
|
+
...(completionSources.length
|
|
68
|
+
? // interactionDelay 0: the popup opens only on an explicit `[[` trigger, so the default
|
|
69
|
+
// accidental-accept guard adds no value and would swallow an immediate Enter into a newline.
|
|
70
|
+
[autocompleteMod.autocompletion({ override: completionSources, interactionDelay: 0 })]
|
|
71
|
+
: []),
|
|
61
72
|
EditorView.lineWrapping,
|
|
62
73
|
languageMod.syntaxHighlighting(languageMod.defaultHighlightStyle, { fallback: true }),
|
|
63
74
|
theme,
|
|
@@ -69,6 +80,7 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
69
80
|
});
|
|
70
81
|
|
|
71
82
|
registerInsert?.(insertAtCursor);
|
|
83
|
+
registerInsertLink?.(insertLink);
|
|
72
84
|
mounted = true;
|
|
73
85
|
});
|
|
74
86
|
|
|
@@ -96,6 +108,24 @@ preview lives in EditPage through the adapter's render. Swapping the editor stay
|
|
|
96
108
|
view.focus();
|
|
97
109
|
}
|
|
98
110
|
|
|
111
|
+
function insertLink(href: string, title: string) {
|
|
112
|
+
if (!view) {
|
|
113
|
+
// The editor has not mounted yet; append the link to the raw value so a pick is never lost,
|
|
114
|
+
// mirroring insertAtCursor's pre-mount fallback.
|
|
115
|
+
const link = insertInlineLink('', 0, 0, href, title).doc;
|
|
116
|
+
value = value ? `${value} ${link}` : link;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { from, to } = view.state.selection.main;
|
|
120
|
+
const doc = view.state.doc.toString();
|
|
121
|
+
const next = insertInlineLink(doc, from, to, href, title);
|
|
122
|
+
view.dispatch({
|
|
123
|
+
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
124
|
+
selection: { anchor: next.from, head: next.to },
|
|
125
|
+
});
|
|
126
|
+
view.focus();
|
|
127
|
+
}
|
|
128
|
+
|
|
99
129
|
function applyFormat(kind: FormatKind) {
|
|
100
130
|
if (!view) return;
|
|
101
131
|
const { from, to } = view.state.selection.main;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The Change URL control and its modal. The author edits the URL slug; on submit the ?/rename action
|
|
4
|
+
moves the entry and rewrites every inbound cairn link in one commit, so no internal link breaks. A
|
|
5
|
+
dated post keeps its date; only the slug changes. Built on a native <dialog>, following the
|
|
6
|
+
DeleteDialog a11y conventions.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
interface Props {
|
|
10
|
+
/** The concept this entry belongs to, e.g. "posts". Posted with the confirm. */
|
|
11
|
+
conceptId: string;
|
|
12
|
+
/** The entry id within its concept. Posted with the confirm. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** A human label for the concept, e.g. "Post", used in the prompts. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** The current slug, prefilled into the input. */
|
|
17
|
+
slug: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { conceptId, id, label, slug }: Props = $props();
|
|
21
|
+
|
|
22
|
+
let dialog = $state<HTMLDialogElement | null>(null);
|
|
23
|
+
let slugInput = $state<HTMLInputElement | null>(null);
|
|
24
|
+
// Seeded on open() rather than from the prop at declaration, so the input prefills with the
|
|
25
|
+
// current slug each time the dialog opens without capturing only the initial prop value.
|
|
26
|
+
let nextSlug = $state('');
|
|
27
|
+
|
|
28
|
+
function open() {
|
|
29
|
+
nextSlug = slug;
|
|
30
|
+
dialog?.showModal();
|
|
31
|
+
// showModal() lands focus on the first focusable element (the header Close button), so move
|
|
32
|
+
// it to the slug input the dialog exists for, and select the prefill so the author can replace
|
|
33
|
+
// it in one keystroke (WCAG 2.4.3). A microtask defers past the dialog's own focus handling.
|
|
34
|
+
queueMicrotask(() => {
|
|
35
|
+
slugInput?.focus();
|
|
36
|
+
slugInput?.select();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
function close() {
|
|
40
|
+
dialog?.close();
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" onclick={open}>Change URL</button>
|
|
45
|
+
|
|
46
|
+
<dialog class="modal" aria-labelledby="cairn-rename-dialog-title" bind:this={dialog}>
|
|
47
|
+
<div class="modal-box">
|
|
48
|
+
<div class="mb-3 flex items-center justify-between">
|
|
49
|
+
<h2 id="cairn-rename-dialog-title" class="text-base font-semibold">Change this {label.toLowerCase()} URL</h2>
|
|
50
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
|
|
51
|
+
</div>
|
|
52
|
+
<form method="POST" action="?/rename" class="flex flex-col gap-3">
|
|
53
|
+
<input type="hidden" name="concept" value={conceptId} />
|
|
54
|
+
<input type="hidden" name="id" value={id} />
|
|
55
|
+
<label class="flex flex-col gap-1">
|
|
56
|
+
<span class="text-sm font-medium">URL slug</span>
|
|
57
|
+
<input class="input" name="slug" bind:value={nextSlug} bind:this={slugInput} autocomplete="off" />
|
|
58
|
+
</label>
|
|
59
|
+
<p class="text-xs text-[var(--color-muted)]">
|
|
60
|
+
Links from other pages update automatically, so nothing breaks. The new URL slug will be
|
|
61
|
+
<code class="text-xs">{nextSlug}</code>.
|
|
62
|
+
</p>
|
|
63
|
+
<div class="flex justify-end gap-2">
|
|
64
|
+
<button type="button" class="btn btn-sm" onclick={close}>Cancel</button>
|
|
65
|
+
<button type="submit" class="btn btn-sm btn-primary">Change URL</button>
|
|
66
|
+
</div>
|
|
67
|
+
</form>
|
|
68
|
+
</div>
|
|
69
|
+
<form method="dialog" class="modal-backdrop">
|
|
70
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
71
|
+
</form>
|
|
72
|
+
</dialog>
|
|
@@ -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,57 @@
|
|
|
1
|
+
// cairn-cms: the [[ link autocomplete (content-graph design). The matcher and the completion
|
|
2
|
+
// builder are pure so they unit-test without a DOM; cairnLinkCompletionSource is a thin adapter
|
|
3
|
+
// to CodeMirror's CompletionSource. The editor wires the source through a generic completionSources
|
|
4
|
+
// prop, so this stays the only link-aware piece and the seam itself knows nothing about links.
|
|
5
|
+
import type { Completion, CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
|
|
6
|
+
import { syntaxTree } from '@codemirror/language';
|
|
7
|
+
import type { LinkTarget } from '../content/manifest.js';
|
|
8
|
+
import { formatCairnToken, escapeLinkText } from '../content/links.js';
|
|
9
|
+
|
|
10
|
+
/** The known concepts in display order; an unlisted concept sorts after these under its own name. */
|
|
11
|
+
const CONCEPT_SECTIONS: Record<string, { name: string; rank: number }> = {
|
|
12
|
+
pages: { name: 'Pages', rank: 0 },
|
|
13
|
+
posts: { name: 'Posts', rank: 1 },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function sectionFor(concept: string): { name: string; rank: number } {
|
|
17
|
+
return CONCEPT_SECTIONS[concept] ?? { name: concept.charAt(0).toUpperCase() + concept.slice(1), rank: 2 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The open `[[query` before the cursor, or null. The query stops at a closing bracket or a newline,
|
|
21
|
+
* so a finished `[[x]]` link and ordinary prose never trigger. `from` is the index of the `[[`. */
|
|
22
|
+
export function matchCairnTrigger(before: string): { query: string; from: number } | null {
|
|
23
|
+
const match = /\[\[([^[\]\n]*)$/.exec(before);
|
|
24
|
+
return match ? { query: match[1], from: match.index } : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The completion options for a query: a case-insensitive title substring match, each option grouped
|
|
28
|
+
* by concept, a draft marked and a post date shown in the detail, and the apply text the full link. */
|
|
29
|
+
export function linkCompletions(targets: LinkTarget[], query: string): Completion[] {
|
|
30
|
+
const q = query.trim().toLowerCase();
|
|
31
|
+
const matched = q ? targets.filter((t) => t.title.toLowerCase().includes(q)) : targets;
|
|
32
|
+
return matched.map((t) => ({
|
|
33
|
+
label: t.title,
|
|
34
|
+
section: sectionFor(t.concept),
|
|
35
|
+
detail: t.draft ? 'Draft' : t.date,
|
|
36
|
+
apply: `[${escapeLinkText(t.title)}](${formatCairnToken(t)})`,
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A CodeMirror CompletionSource over the site's link targets, triggered by `[[`. It replaces the
|
|
41
|
+
* whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
|
|
42
|
+
* filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
|
|
43
|
+
export function cairnLinkCompletionSource(targets: LinkTarget[]): CompletionSource {
|
|
44
|
+
return (context: CompletionContext): CompletionResult | null => {
|
|
45
|
+
const line = context.state.doc.lineAt(context.pos);
|
|
46
|
+
const before = context.state.sliceDoc(line.from, context.pos);
|
|
47
|
+
const trigger = matchCairnTrigger(before);
|
|
48
|
+
if (!trigger) return null;
|
|
49
|
+
// Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
|
|
50
|
+
// the build resolver does not look inside code. The node name carries "Code" for both forms.
|
|
51
|
+
const node = syntaxTree(context.state).resolveInner(context.pos, -1);
|
|
52
|
+
for (let n: typeof node | null = node; n; n = n.parent) {
|
|
53
|
+
if (/Code/.test(n.name)) return null;
|
|
54
|
+
}
|
|
55
|
+
return { from: line.from + trigger.from, options: linkCompletions(targets, trigger.query), filter: false };
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
* selection range to a new document and a new selection, with no DOM. The MarkdownEditor view
|
|
4
4
|
* dispatches the result; keeping the logic here lets it unit-test without a browser.
|
|
5
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 type { Link } from 'mdast';
|
|
11
|
+
import { escapeLinkText } from '../content/links.js';
|
|
12
|
+
|
|
6
13
|
export type FormatKind = 'bold' | 'italic' | 'code' | 'heading' | 'quote' | 'ul' | 'link';
|
|
7
14
|
|
|
8
15
|
export interface FormatResult {
|
|
@@ -37,3 +44,78 @@ export function applyMarkdownFormat(doc: string, from: number, to: number, kind:
|
|
|
37
44
|
const added = prefixed.length - region.length;
|
|
38
45
|
return { doc: doc.slice(0, lineStart) + prefixed + doc.slice(to), from: from + prefix.length, to: to + added };
|
|
39
46
|
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Insert an inline markdown link at the selection. With a non-empty selection the selected text
|
|
50
|
+
* becomes the display text; with an empty selection the title is the display text. The cursor
|
|
51
|
+
* collapses just after the inserted link. Unlike the block insert, this adds no surrounding
|
|
52
|
+
* blank lines, since a link is inline. Pure, so the editor dispatches the result.
|
|
53
|
+
*/
|
|
54
|
+
export function insertInlineLink(doc: string, from: number, to: number, href: string, title: string): FormatResult {
|
|
55
|
+
const text = from < to ? doc.slice(from, to) : escapeLinkText(title);
|
|
56
|
+
const inserted = `[${text}](${href})`;
|
|
57
|
+
const end = from + inserted.length;
|
|
58
|
+
return { doc: doc.slice(0, from) + inserted + doc.slice(to), from: end, to: end };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Concatenate a link node's text-child values. The parser has already unescaped them, so a source
|
|
62
|
+
* `Notes \[draft\]` yields `Notes [draft]`. Used instead of mdast-util-to-string, which is not a
|
|
63
|
+
* direct dependency. Non-text children (a nested emphasis, say) contribute no value, which is fine
|
|
64
|
+
* for the picker-produced links this fix targets. */
|
|
65
|
+
function linkText(node: Link): string {
|
|
66
|
+
return node.children.map((c) => ('value' in c ? c.value : '')).join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Unwrap every cairn: link whose href is exactly `href`, replacing it with its plain display text.
|
|
71
|
+
* The save guard's one-click fix calls this to drop a broken link while keeping the words. The
|
|
72
|
+
* document is parsed with the same remark pipeline extractCairnLinks uses, so the two agree on what
|
|
73
|
+
* a link is. Each matching link node is located by its source offsets and spliced out from last to
|
|
74
|
+
* first, which leaves the rest of the document exact and unescapes the display text. A token inside
|
|
75
|
+
* a code span or fence is not a link node, so it is never touched, and a link with a different url
|
|
76
|
+
* is left in place.
|
|
77
|
+
*/
|
|
78
|
+
export function unwrapCairnLink(doc: string, href: string): string {
|
|
79
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
|
|
80
|
+
const spans: { start: number; end: number; text: string }[] = [];
|
|
81
|
+
visit(tree, 'link', (node: Link) => {
|
|
82
|
+
if (node.url !== href) return;
|
|
83
|
+
const start = node.position?.start?.offset;
|
|
84
|
+
const end = node.position?.end?.offset;
|
|
85
|
+
if (start == null || end == null) return;
|
|
86
|
+
spans.push({ start, end, text: linkText(node) });
|
|
87
|
+
});
|
|
88
|
+
spans.sort((a, b) => b.start - a.start);
|
|
89
|
+
let out = doc;
|
|
90
|
+
for (const span of spans) {
|
|
91
|
+
out = out.slice(0, span.start) + span.text + out.slice(span.end);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
|
|
98
|
+
* the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
|
|
99
|
+
* inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
|
|
100
|
+
* span is not a link node and is never touched. Each matching node's source span is rewritten from
|
|
101
|
+
* last to first, replacing only the `](oldHref` run so the label and title stay exact.
|
|
102
|
+
*/
|
|
103
|
+
export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
|
|
104
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
|
|
105
|
+
const spans: { start: number; end: number }[] = [];
|
|
106
|
+
visit(tree, 'link', (node: Link) => {
|
|
107
|
+
if (node.url !== oldHref) return;
|
|
108
|
+
const start = node.position?.start?.offset;
|
|
109
|
+
const end = node.position?.end?.offset;
|
|
110
|
+
if (start == null || end == null) return;
|
|
111
|
+
spans.push({ start, end });
|
|
112
|
+
});
|
|
113
|
+
spans.sort((a, b) => b.start - a.start);
|
|
114
|
+
let out = doc;
|
|
115
|
+
for (const span of spans) {
|
|
116
|
+
const src = out.slice(span.start, span.end);
|
|
117
|
+
const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
|
|
118
|
+
out = out.slice(0, span.start) + rewritten + out.slice(span.end);
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
@@ -31,6 +31,7 @@ export function composeRuntime(
|
|
|
31
31
|
backend: adapter.backend,
|
|
32
32
|
sender: adapter.sender,
|
|
33
33
|
render: adapter.render,
|
|
34
|
+
manifestPath: adapter.manifestPath ?? 'src/content/.cairn/index.json',
|
|
34
35
|
registry: adapter.registry,
|
|
35
36
|
icons: adapter.icons,
|
|
36
37
|
navMenu: adapter.navMenu,
|
package/src/lib/content/ids.ts
CHANGED
|
@@ -80,3 +80,15 @@ export function composeDatedId(date: string, slug: string, datePrefix: DatePrefi
|
|
|
80
80
|
}
|
|
81
81
|
return `${prefix}-${slug}`;
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Rename an id by swapping its slug, keeping any date prefix. slugFromId strips only the leading
|
|
86
|
+
* date prefix, so the id is exactly its prefix followed by its slug; this replaces the slug suffix
|
|
87
|
+
* with newSlug. A non-dated concept passes null, so the whole id is the slug and the id becomes
|
|
88
|
+
* newSlug. The caller validates newSlug with isValidId first.
|
|
89
|
+
*/
|
|
90
|
+
export function renameId(oldId: string, newSlug: string, datePrefix: DatePrefix | null): string {
|
|
91
|
+
const oldSlug = slugFromId(oldId, datePrefix);
|
|
92
|
+
const prefix = oldId.slice(0, oldId.length - oldSlug.length);
|
|
93
|
+
return prefix + newSlug;
|
|
94
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
|
|
11
|
+
/** A resolved reference to a content entry by its concept and permanent id. */
|
|
12
|
+
export interface CairnRef {
|
|
13
|
+
concept: string;
|
|
14
|
+
id: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Resolve a reference to its live permalink. Returns undefined when the target is missing (the
|
|
18
|
+
* preview marks it); the build resolver throws instead, so a dangling token fails the build. */
|
|
19
|
+
export type LinkResolve = (ref: CairnRef) => string | undefined;
|
|
20
|
+
|
|
21
|
+
/** Parse a `cairn:<concept>/<id>` href, or null for any other href or a malformed token. */
|
|
22
|
+
export function parseCairnToken(href: string): CairnRef | null {
|
|
23
|
+
if (!href.startsWith('cairn:')) return null;
|
|
24
|
+
const rest = href.slice('cairn:'.length);
|
|
25
|
+
const slash = rest.indexOf('/');
|
|
26
|
+
if (slash <= 0) return null;
|
|
27
|
+
const concept = rest.slice(0, slash);
|
|
28
|
+
const id = rest.slice(slash + 1);
|
|
29
|
+
if (!concept || !isValidId(id)) return null;
|
|
30
|
+
return { concept, id };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Write the `cairn:<concept>/<id>` token for a ref. The inverse of parseCairnToken, so the editor
|
|
34
|
+
* link picker and the autocomplete write exactly the form the resolver reads back. */
|
|
35
|
+
export function formatCairnToken(ref: CairnRef): string {
|
|
36
|
+
return `cairn:${ref.concept}/${ref.id}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Escape the characters that would break a markdown link's display text: a backslash and the
|
|
40
|
+
* square brackets that delimit the text. Used where a content title becomes link display text,
|
|
41
|
+
* so an unbalanced bracket in a title cannot truncate the generated link. */
|
|
42
|
+
export function escapeLinkText(text: string): string {
|
|
43
|
+
return text.replace(/[\\[\]]/g, (ch) => `\\${ch}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
|
|
47
|
+
* Parses the body as mdast, so a token inside a code span or fence is never matched. */
|
|
48
|
+
export function extractCairnLinks(body: string): CairnRef[] {
|
|
49
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
const refs: CairnRef[] = [];
|
|
52
|
+
visit(tree, 'link', (node: { url?: string }) => {
|
|
53
|
+
const ref = node.url ? parseCairnToken(node.url) : null;
|
|
54
|
+
if (!ref) return;
|
|
55
|
+
const key = `${ref.concept}/${ref.id}`;
|
|
56
|
+
if (seen.has(key)) return;
|
|
57
|
+
seen.add(key);
|
|
58
|
+
refs.push(ref);
|
|
59
|
+
});
|
|
60
|
+
return refs;
|
|
61
|
+
}
|