@glw907/cairn-cms 0.41.0 → 0.51.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/CHANGELOG.md +82 -0
- package/README.md +2 -2
- package/dist/ambient.d.ts +9 -0
- package/dist/ambient.js +1 -0
- package/dist/components/AdminLayout.svelte +6 -8
- package/dist/components/CairnAdmin.svelte +67 -0
- package/dist/components/CairnAdmin.svelte.d.ts +35 -0
- package/dist/components/ConceptList.svelte +4 -5
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +107 -25
- package/dist/components/EditPage.svelte.d.ts +8 -10
- package/dist/components/EditorToolbar.svelte +79 -8
- package/dist/components/EditorToolbar.svelte.d.ts +10 -2
- package/dist/components/LoginPage.svelte +2 -2
- package/dist/components/LoginPage.svelte.d.ts +1 -1
- package/dist/components/ManageEditors.svelte +4 -3
- package/dist/components/ManageEditors.svelte.d.ts +2 -1
- package/dist/components/MarkdownEditor.svelte +20 -2
- package/dist/components/cairn-admin.css +57 -9
- package/dist/components/editor-highlight.d.ts +1 -0
- package/dist/components/editor-highlight.js +31 -8
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +10 -0
- package/dist/components/markdown-directives.js +54 -1
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- package/dist/components/preview-doc.d.ts +27 -0
- package/dist/components/preview-doc.js +64 -0
- package/dist/content/compose.js +1 -0
- package/dist/content/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +35 -2
- package/dist/delivery/data.d.ts +3 -5
- package/dist/delivery/data.js +2 -3
- package/dist/delivery/feeds.js +1 -7
- package/dist/delivery/index.d.ts +2 -2
- package/dist/delivery/index.js +1 -1
- package/dist/delivery/manifest.d.ts +0 -5
- package/dist/delivery/manifest.js +5 -16
- package/dist/{sveltekit → delivery}/public-routes.d.ts +4 -4
- package/dist/{sveltekit → delivery}/public-routes.js +7 -7
- package/dist/delivery/site-indexes.d.ts +3 -3
- package/dist/delivery/site-indexes.js +3 -3
- package/dist/delivery/{site-index.d.ts → site-resolver.d.ts} +7 -3
- package/dist/delivery/{site-index.js → site-resolver.js} +13 -3
- package/dist/delivery/sitemap.js +1 -3
- package/dist/delivery/xml.d.ts +2 -0
- package/dist/delivery/xml.js +11 -0
- package/dist/diagnostics/conditions.js +24 -0
- package/dist/doctor/bin.js +30 -12
- package/dist/doctor/check-floors.d.ts +15 -0
- package/dist/doctor/check-floors.js +107 -0
- package/dist/doctor/check-probe.d.ts +3 -0
- package/dist/doctor/check-probe.js +123 -0
- package/dist/doctor/checks-github.js +1 -1
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +28 -2
- package/dist/doctor/cloudflare-api.js +2 -2
- package/dist/doctor/index.d.ts +28 -3
- package/dist/doctor/index.js +47 -6
- package/dist/doctor/types.d.ts +2 -0
- package/dist/doctor/wrangler-config.d.ts +4 -0
- package/dist/doctor/wrangler-config.js +11 -0
- package/dist/email.js +4 -11
- package/dist/env.d.ts +3 -2
- package/dist/env.js +12 -6
- package/dist/escape.d.ts +2 -0
- package/dist/escape.js +11 -0
- package/dist/github/credentials.d.ts +2 -1
- package/dist/github/credentials.js +10 -2
- package/dist/github/types.d.ts +2 -0
- package/dist/github/types.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +2 -0
- package/dist/nav/site-config.js +2 -0
- package/dist/sveltekit/admin-dispatch.d.ts +28 -0
- package/dist/sveltekit/admin-dispatch.js +62 -0
- package/dist/sveltekit/cairn-admin.d.ts +94 -0
- package/dist/sveltekit/cairn-admin.js +126 -0
- package/dist/sveltekit/condition-response.d.ts +1 -0
- package/dist/sveltekit/condition-response.js +25 -0
- package/dist/sveltekit/content-routes.d.ts +39 -15
- package/dist/sveltekit/content-routes.js +84 -50
- package/dist/sveltekit/guard.d.ts +8 -2
- package/dist/sveltekit/guard.js +18 -4
- package/dist/sveltekit/https-required-page.js +2 -1
- package/dist/sveltekit/index.d.ts +3 -1
- package/dist/sveltekit/index.js +2 -0
- package/dist/sveltekit/nav-routes.d.ts +3 -1
- package/dist/sveltekit/nav-routes.js +22 -19
- package/dist/sveltekit/static-admin-page.d.ts +0 -2
- package/dist/sveltekit/static-admin-page.js +1 -8
- package/dist/sveltekit/types.d.ts +18 -11
- package/dist/vite/index.d.ts +16 -0
- package/dist/vite/index.js +57 -13
- package/package.json +6 -2
- package/src/lib/ambient.ts +19 -0
- package/src/lib/components/AdminLayout.svelte +6 -8
- package/src/lib/components/CairnAdmin.svelte +67 -0
- package/src/lib/components/ConceptList.svelte +4 -5
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +107 -25
- package/src/lib/components/EditorToolbar.svelte +79 -8
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- package/src/lib/components/MarkdownEditor.svelte +20 -2
- package/src/lib/components/cairn-admin.css +59 -0
- package/src/lib/components/editor-highlight.ts +32 -7
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +51 -1
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/components/preview-doc.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +34 -2
- package/src/lib/delivery/data.ts +3 -5
- package/src/lib/delivery/feeds.ts +1 -8
- package/src/lib/delivery/index.ts +2 -2
- package/src/lib/delivery/manifest.ts +5 -18
- package/src/lib/{sveltekit → delivery}/public-routes.ts +11 -11
- package/src/lib/delivery/site-indexes.ts +6 -6
- package/src/lib/delivery/{site-index.ts → site-resolver.ts} +20 -8
- package/src/lib/delivery/sitemap.ts +1 -4
- package/src/lib/delivery/xml.ts +12 -0
- package/src/lib/diagnostics/conditions.ts +24 -0
- package/src/lib/doctor/bin.ts +35 -10
- package/src/lib/doctor/check-floors.ts +124 -0
- package/src/lib/doctor/check-probe.ts +138 -0
- package/src/lib/doctor/checks-github.ts +3 -1
- package/src/lib/doctor/checks-local.ts +28 -2
- package/src/lib/doctor/cloudflare-api.ts +4 -2
- package/src/lib/doctor/index.ts +67 -6
- package/src/lib/doctor/types.ts +2 -0
- package/src/lib/doctor/wrangler-config.ts +11 -0
- package/src/lib/email.ts +4 -11
- package/src/lib/env.ts +12 -6
- package/src/lib/escape.ts +12 -0
- package/src/lib/github/credentials.ts +6 -2
- package/src/lib/github/types.ts +5 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +1 -0
- package/src/lib/nav/site-config.ts +3 -0
- package/src/lib/sveltekit/admin-dispatch.ts +75 -0
- package/src/lib/sveltekit/cairn-admin.ts +177 -0
- package/src/lib/sveltekit/condition-response.ts +27 -1
- package/src/lib/sveltekit/content-routes.ts +131 -62
- package/src/lib/sveltekit/guard.ts +20 -5
- package/src/lib/sveltekit/https-required-page.ts +2 -1
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/nav-routes.ts +24 -21
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- package/src/lib/vite/index.ts +71 -17
- package/dist/delivery/paginate.d.ts +0 -12
- package/dist/delivery/paginate.js +0 -20
- package/dist/render/index.d.ts +0 -5
- package/dist/render/index.js +0 -8
- package/src/lib/delivery/paginate.ts +0 -32
- package/src/lib/render/index.ts +0 -8
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
The editor card's instrument strip. Three button groups divided by hairlines (Text, Structure with a
|
|
4
4
|
More overflow menu, then the host's Insert controls) and the Write/Preview segmented control pinned
|
|
5
5
|
right. Format buttons ask the host to transform the editor's current selection; the host supplies the
|
|
6
|
-
Insert group through the `insertControls` snippet so the strip stays free of picker wiring.
|
|
7
|
-
|
|
6
|
+
Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
|
|
7
|
+
Preview shows, a device trigger joins the segmented capsule and opens a popover menu of preview
|
|
8
|
+
widths, reported to the host through `onDevice`. The glyphs are stroke SVG icons in the admin's
|
|
9
|
+
house style (24x24 viewBox, `currentColor`, round caps).
|
|
8
10
|
-->
|
|
9
11
|
<script lang="ts">
|
|
10
12
|
import type { Snippet } from 'svelte';
|
|
11
13
|
import type { FormatKind } from './markdown-format.js';
|
|
14
|
+
import { deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
|
|
12
15
|
|
|
13
16
|
interface Props {
|
|
14
17
|
/** Apply a markdown transform to the editor's current selection. */
|
|
@@ -17,11 +20,16 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
|
|
|
17
20
|
mode: 'write' | 'preview';
|
|
18
21
|
/** Ask the host to switch panes. */
|
|
19
22
|
onMode: (m: 'write' | 'preview') => void;
|
|
23
|
+
/** The active preview-frame device, shown on the device trigger. Desktop when absent. */
|
|
24
|
+
device?: PreviewDeviceId;
|
|
25
|
+
/** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
|
|
26
|
+
* while Preview shows. */
|
|
27
|
+
onDevice?: (id: PreviewDeviceId) => void;
|
|
20
28
|
/** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
|
|
21
29
|
insertControls?: Snippet;
|
|
22
30
|
}
|
|
23
31
|
|
|
24
|
-
let { format, mode, onMode, insertControls }: Props = $props();
|
|
32
|
+
let { format, mode, onMode, device = 'desktop', onDevice, insertControls }: Props = $props();
|
|
25
33
|
|
|
26
34
|
// Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
|
|
27
35
|
// markup stays declarative (no per-icon raw html). Paths follow the house outline style.
|
|
@@ -89,6 +97,19 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
|
|
|
89
97
|
if (moreMenu?.matches(':popover-open')) moreMenu.hidePopover();
|
|
90
98
|
}
|
|
91
99
|
|
|
100
|
+
// The device menu's popover element and its open state, mirrored from the toggle event into
|
|
101
|
+
// aria-expanded on the trigger (the More menu's pattern).
|
|
102
|
+
let deviceMenu = $state<HTMLUListElement | null>(null);
|
|
103
|
+
let deviceOpen = $state(false);
|
|
104
|
+
const activeDevice = $derived(previewDevice(device));
|
|
105
|
+
// Whether the device trigger renders as the capsule's third segment.
|
|
106
|
+
const showDeviceTrigger = $derived(mode === 'preview' && !!onDevice);
|
|
107
|
+
|
|
108
|
+
function pickDevice(id: PreviewDeviceId) {
|
|
109
|
+
onDevice?.(id);
|
|
110
|
+
if (deviceMenu?.matches(':popover-open')) deviceMenu.hidePopover();
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
let toolbarEl = $state<HTMLDivElement | null>(null);
|
|
93
114
|
// The roving tab stop's position among the strip's enabled top-level controls. The Write/Preview
|
|
94
115
|
// tabs join the toolbar's roving order instead of managing their own arrow keys: the ARIA toolbar
|
|
@@ -156,13 +177,20 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
|
|
|
156
177
|
{/snippet}
|
|
157
178
|
|
|
158
179
|
{#snippet tab(m: 'write' | 'preview', label: string)}
|
|
180
|
+
<!-- The capsule look is manual rounding, not daisyUI's .join: join radii follow direct
|
|
181
|
+
children, and the device trigger must sit outside the tablist (ARIA required children),
|
|
182
|
+
so the segments square their shared edges themselves. Preview squares its right edge only
|
|
183
|
+
while the trigger extends the capsule. -->
|
|
159
184
|
<button
|
|
160
185
|
type="button"
|
|
161
186
|
role="tab"
|
|
162
187
|
id={`cairn-tab-${m}`}
|
|
163
188
|
aria-selected={mode === m}
|
|
164
189
|
aria-controls={`cairn-pane-${m}`}
|
|
165
|
-
class="
|
|
190
|
+
class="btn btn-sm {mode === m ? 'btn-active' : 'btn-ghost'}"
|
|
191
|
+
class:rounded-r-none={m === 'write' || showDeviceTrigger}
|
|
192
|
+
class:rounded-l-none={m === 'preview'}
|
|
193
|
+
class:-ml-px={m === 'preview'}
|
|
166
194
|
onclick={() => onMode(m)}
|
|
167
195
|
>
|
|
168
196
|
{label}
|
|
@@ -230,9 +258,52 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
|
|
|
230
258
|
{/if}
|
|
231
259
|
|
|
232
260
|
<!-- The host renders the matching tabpanels (#cairn-pane-write and #cairn-pane-preview) below
|
|
233
|
-
the strip inside the same editor card.
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
261
|
+
the strip inside the same editor card. The tablist wrapper holds ONLY the two tabs (ARIA
|
|
262
|
+
required children: anything else in a tablist makes assistive tech miscount the tabs).
|
|
263
|
+
While Preview shows, the device trigger reads as the capsule's third segment from the
|
|
264
|
+
flex row right after the wrapper; it is a plain button, not a tab. -->
|
|
265
|
+
<div class="ml-auto flex items-center">
|
|
266
|
+
<div role="tablist" aria-label="Editor view" class="flex items-center">
|
|
267
|
+
{@render tab('write', 'Write')}
|
|
268
|
+
{@render tab('preview', 'Preview')}
|
|
269
|
+
</div>
|
|
270
|
+
{#if showDeviceTrigger}
|
|
271
|
+
<button
|
|
272
|
+
type="button"
|
|
273
|
+
class="btn btn-sm btn-ghost gap-1 rounded-l-none -ml-px"
|
|
274
|
+
title="Preview width"
|
|
275
|
+
aria-expanded={deviceOpen}
|
|
276
|
+
popovertarget="cairn-preview-device-menu"
|
|
277
|
+
style="anchor-name:--cairn-preview-device"
|
|
278
|
+
>
|
|
279
|
+
<span class="sr-only">Preview width:</span>
|
|
280
|
+
{activeDevice.label}
|
|
281
|
+
{@render strokeIcon(['m6 9 6 6 6-6'])}
|
|
282
|
+
</button>
|
|
283
|
+
{/if}
|
|
237
284
|
</div>
|
|
285
|
+
{#if showDeviceTrigger}
|
|
286
|
+
<!-- The device list mirrors the More menu exactly: a DaisyUI v5 popover dropdown of plain
|
|
287
|
+
buttons, with the active pick carried by aria-pressed and the check glyph. Deliberately
|
|
288
|
+
NOT the ARIA menu pattern: menu roles promise interactions this list does not have. -->
|
|
289
|
+
<ul
|
|
290
|
+
bind:this={deviceMenu}
|
|
291
|
+
popover="auto"
|
|
292
|
+
id="cairn-preview-device-menu"
|
|
293
|
+
style="position-anchor:--cairn-preview-device"
|
|
294
|
+
ontoggle={(e) => (deviceOpen = e.newState === 'open')}
|
|
295
|
+
class="dropdown dropdown-end menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
|
|
296
|
+
>
|
|
297
|
+
{#each previewDevices as d (d.id)}
|
|
298
|
+
<li>
|
|
299
|
+
<button type="button" aria-pressed={device === d.id} onclick={() => pickDevice(d.id)}>
|
|
300
|
+
<span class="grow">{deviceLabel(d)}</span>
|
|
301
|
+
{#if device === d.id}
|
|
302
|
+
{@render strokeIcon(['M20 6 9 17l-5-5'])}
|
|
303
|
+
{/if}
|
|
304
|
+
</button>
|
|
305
|
+
</li>
|
|
306
|
+
{/each}
|
|
307
|
+
</ul>
|
|
308
|
+
{/if}
|
|
238
309
|
</div>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component
|
|
3
|
-
The magic-link sign-in page. A plain form POST to the
|
|
3
|
+
The magic-link sign-in page. A plain form POST to the named `?/request` action (the engine's
|
|
4
4
|
`requestAction`); no client SDK. The success message is identical whether or not the email is on
|
|
5
5
|
the allowlist, so the page never leaks membership (spec §7.1).
|
|
6
6
|
-->
|
|
@@ -101,7 +101,7 @@ the allowlist, so the page never leaks membership (spec §7.1).
|
|
|
101
101
|
{#if data.error && !form?.status}
|
|
102
102
|
<div role="alert" class="alert alert-error mb-3 text-sm">That link expired. Request a new one below.</div>
|
|
103
103
|
{/if}
|
|
104
|
-
<form method="POST" class="flex flex-col gap-3">
|
|
104
|
+
<form method="POST" action="?/request" class="flex flex-col gap-3">
|
|
105
105
|
<CsrfField token={data.csrf} />
|
|
106
106
|
<label class="flex flex-col gap-1">
|
|
107
107
|
<span class="text-sm font-medium">Email</span>
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
The owner-gated editor management surface: a table of editors with role-flip and remove actions,
|
|
4
4
|
and an add-editor form. The acting owner's own row disables its destructive controls; the
|
|
5
5
|
last-owner anti-lockout rule itself is enforced server-side (editors-routes). Actions post to the
|
|
6
|
-
named `?/setRole`, `?/
|
|
6
|
+
named `?/setRole`, `?/removeEditor`, and `?/addEditor` actions, the names the single-mount
|
|
7
|
+
dispatcher defines.
|
|
7
8
|
-->
|
|
8
9
|
<script lang="ts">
|
|
9
10
|
import CsrfField from './CsrfField.svelte';
|
|
@@ -53,7 +54,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
|
|
|
53
54
|
{editor.role === 'owner' ? 'Make editor' : 'Make owner'}
|
|
54
55
|
</button>
|
|
55
56
|
</form>
|
|
56
|
-
<form method="POST" action="?/
|
|
57
|
+
<form method="POST" action="?/removeEditor">
|
|
57
58
|
<CsrfField />
|
|
58
59
|
<input type="hidden" name="email" value={editor.email} />
|
|
59
60
|
<button type="submit" class="btn btn-ghost btn-xs text-error" disabled={isSelf} aria-label={`Remove ${editor.displayName}`}>
|
|
@@ -67,7 +68,7 @@ named `?/setRole`, `?/remove`, and `?/add` actions.
|
|
|
67
68
|
</table>
|
|
68
69
|
</div>
|
|
69
70
|
|
|
70
|
-
<form method="POST" action="?/
|
|
71
|
+
<form method="POST" action="?/addEditor" class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 grid gap-3 p-4 shadow-[var(--cairn-shadow)] sm:grid-cols-[1fr_1fr_auto_auto] sm:items-end">
|
|
71
72
|
<CsrfField />
|
|
72
73
|
<label class="flex flex-col gap-1">
|
|
73
74
|
<span class="text-sm font-medium">Name</span>
|
|
@@ -61,9 +61,16 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
61
61
|
// Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
|
|
62
62
|
// tooltip above all) renders dark-on-dark instead of light-on-dark.
|
|
63
63
|
const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
|
|
64
|
-
// The directive machinery
|
|
64
|
+
// The directive machinery treatment. The fence bands and content rails step their alpha by
|
|
65
|
+
// nesting depth through the per-theme vars in cairn-admin.css; the fallbacks are the light
|
|
66
|
+
// values, so the editor still renders sensibly outside an admin theme wrapper. The deeper
|
|
67
|
+
// bands swap in a darker ink (--cairn-directive-ink-N) to hold AA on their own tint.
|
|
68
|
+
const band = (depth: number, fallback: string) =>
|
|
69
|
+
`color-mix(in oklab, var(--color-accent) var(--cairn-directive-band-${depth}, ${fallback}), transparent)`;
|
|
70
|
+
const rail = (depth: number, fallback: string) =>
|
|
71
|
+
`inset 2px 0 0 0 color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${depth}, ${fallback}), transparent)`;
|
|
65
72
|
const directiveInk = {
|
|
66
|
-
backgroundColor:
|
|
73
|
+
backgroundColor: band(1, '8%'),
|
|
67
74
|
color: 'var(--color-accent)',
|
|
68
75
|
};
|
|
69
76
|
const theme = EditorView.theme(
|
|
@@ -90,6 +97,17 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
90
97
|
},
|
|
91
98
|
'.cm-line': { padding: '0' },
|
|
92
99
|
'.cm-cairn-directive-fence': directiveInk,
|
|
100
|
+
'.cm-cairn-directive-fence.cm-cairn-depth-2': {
|
|
101
|
+
backgroundColor: band(2, '14%'),
|
|
102
|
+
color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
|
|
103
|
+
},
|
|
104
|
+
'.cm-cairn-directive-fence.cm-cairn-depth-3': {
|
|
105
|
+
backgroundColor: band(3, '20%'),
|
|
106
|
+
color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))',
|
|
107
|
+
},
|
|
108
|
+
'.cm-cairn-directive-content.cm-cairn-depth-1': { boxShadow: rail(1, '75%') },
|
|
109
|
+
'.cm-cairn-directive-content.cm-cairn-depth-2': { boxShadow: rail(2, '82%') },
|
|
110
|
+
'.cm-cairn-directive-content.cm-cairn-depth-3': { boxShadow: rail(3, '90%') },
|
|
93
111
|
'.cm-cairn-directive-leaf': directiveInk,
|
|
94
112
|
'.cm-cairn-directive-inline': directiveInk,
|
|
95
113
|
},
|
|
@@ -28,6 +28,23 @@
|
|
|
28
28
|
tint (4.75:1); 58% failed the tint at 4.04:1. A locked margin, like the dark nav pair. */
|
|
29
29
|
--color-accent: oklch(54% 0.16 300);
|
|
30
30
|
--color-accent-content: oklch(98% 0.012 300);
|
|
31
|
+
|
|
32
|
+
/* The editor's nested-directive depth scale: machinery bands step the accent tint by nesting
|
|
33
|
+
depth, content lines carry an accent rail at the same steps, and the deeper bands darken
|
|
34
|
+
their ink so it keeps AA on its own tint. Locked pairs (ink on band, computed against
|
|
35
|
+
base-100): depth 1 = 54% on 8% (4.75:1), depth 2 = 50% on 14% (5.20:1), depth 3 = 48% on
|
|
36
|
+
20% (5.21:1). Do not raise a band or lighten its ink without re-checking. */
|
|
37
|
+
--cairn-directive-band-1: 8%;
|
|
38
|
+
--cairn-directive-band-2: 14%;
|
|
39
|
+
--cairn-directive-band-3: 20%;
|
|
40
|
+
--cairn-directive-ink-2: oklch(50% 0.16 300);
|
|
41
|
+
--cairn-directive-ink-3: oklch(48% 0.16 300);
|
|
42
|
+
/* The content rail is a 2px non-text cue, so its composited color must clear the 3:1 floor
|
|
43
|
+
against base-100 (WCAG 1.4.11). Locked margins (rail vs base-100): depth 1 = 75% (3.25:1),
|
|
44
|
+
depth 2 = 82% (3.71:1), depth 3 = 90% (4.34:1). Do not lower an alpha without re-checking. */
|
|
45
|
+
--cairn-directive-rail-1: 75%;
|
|
46
|
+
--cairn-directive-rail-2: 82%;
|
|
47
|
+
--cairn-directive-rail-3: 90%;
|
|
31
48
|
--color-neutral: oklch(32% 0.012 75);
|
|
32
49
|
--color-neutral-content: oklch(96% 0.004 75);
|
|
33
50
|
|
|
@@ -89,6 +106,22 @@
|
|
|
89
106
|
--color-secondary-content: oklch(20% 0.008 75);
|
|
90
107
|
--color-accent: oklch(70% 0.14 300);
|
|
91
108
|
--color-accent-content: oklch(20% 0.04 300);
|
|
109
|
+
|
|
110
|
+
/* The nested-directive depth scale on dark: slightly stronger bands (a tint reads quieter on a
|
|
111
|
+
dark base), with the deeper inks lightened to keep AA on their own tint. Locked pairs (ink on
|
|
112
|
+
band, computed against base-100): depth 1 = 70% on 10% (5.03:1), depth 2 = 74% on 16%
|
|
113
|
+
(5.27:1), depth 3 = 78% on 22% (5.42:1). Do not raise a band or darken its ink without
|
|
114
|
+
re-checking. */
|
|
115
|
+
--cairn-directive-band-1: 10%;
|
|
116
|
+
--cairn-directive-band-2: 16%;
|
|
117
|
+
--cairn-directive-band-3: 22%;
|
|
118
|
+
--cairn-directive-ink-2: oklch(74% 0.14 300);
|
|
119
|
+
--cairn-directive-ink-3: oklch(78% 0.14 300);
|
|
120
|
+
/* The content rails hold the same 3:1 non-text floor on dark. Locked margins (rail vs
|
|
121
|
+
base-100): depth 1 = 65% (3.27:1), depth 2 = 75% (3.90:1), depth 3 = 85% (4.62:1). */
|
|
122
|
+
--cairn-directive-rail-1: 65%;
|
|
123
|
+
--cairn-directive-rail-2: 75%;
|
|
124
|
+
--cairn-directive-rail-3: 85%;
|
|
92
125
|
--color-neutral: oklch(80% 0.01 75);
|
|
93
126
|
--color-neutral-content: oklch(22% 0.008 75);
|
|
94
127
|
|
|
@@ -196,6 +229,20 @@
|
|
|
196
229
|
outline-offset: -1px;
|
|
197
230
|
}
|
|
198
231
|
|
|
232
|
+
/* Menu items come as anchors or buttons, and the omitted Preflight is what made them match:
|
|
233
|
+
without it a button keeps the UA chrome (outset border, gray fill, centered system-font
|
|
234
|
+
text) while its anchor siblings render flat. This scoped substitute levels the buttons to
|
|
235
|
+
the anchor baseline. The components layer still beats the UA stylesheet, so daisyUI's
|
|
236
|
+
utilities-layer menu rules (the hover tint, the menu-sm sizing) and any text utility such
|
|
237
|
+
as text-error keep winning. A .btn inside a menu keeps its full DaisyUI chrome. */
|
|
238
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .menu li > button:not(.btn) {
|
|
239
|
+
border: 0 solid;
|
|
240
|
+
background-color: transparent;
|
|
241
|
+
font: inherit;
|
|
242
|
+
color: inherit;
|
|
243
|
+
text-align: start;
|
|
244
|
+
}
|
|
245
|
+
|
|
199
246
|
/* The admin topbar plus the edit page's sticky action header stack about 120px of veil over the
|
|
200
247
|
top of the content column, and a control the browser scrolls into view could land hidden
|
|
201
248
|
beneath them (WCAG 2.4.11 Focus Not Obscured). The scroll margin keeps any focus or fragment
|
|
@@ -205,6 +252,18 @@
|
|
|
205
252
|
}
|
|
206
253
|
}
|
|
207
254
|
|
|
255
|
+
/* DaisyUI v5's .menu quiets keyboard focus on its items (`outline-style: none` on
|
|
256
|
+
:focus-visible), and the compiled sheet carries that rule in the utilities layer, where it
|
|
257
|
+
beats the components-layer focus ring above: cascade layers resolve before specificity, and
|
|
258
|
+
utilities is the last layer. This override is deliberately UNLAYERED (the same mechanism that
|
|
259
|
+
lets the theme blocks win), because no layered rule can outrank a later layer; it restores a
|
|
260
|
+
visible focus indicator on the popover and nav menu items. The negative offset draws the ring
|
|
261
|
+
inside the item, clear of the menu panel's clipped corners. */
|
|
262
|
+
:where([data-theme='cairn-admin'], [data-theme='cairn-admin-dark']) .menu li > :is(button, a):focus-visible {
|
|
263
|
+
outline: 2px solid var(--color-primary);
|
|
264
|
+
outline-offset: -2px;
|
|
265
|
+
}
|
|
266
|
+
|
|
208
267
|
/* Respect a reduced-motion preference inside the admin. DaisyUI's modal, drawer, and the admin's
|
|
209
268
|
own hover transitions otherwise animate regardless. Scoped to the admin roots, so it never
|
|
210
269
|
reaches the host's pages. */
|
|
@@ -5,7 +5,7 @@ import { HighlightStyle } from '@codemirror/language';
|
|
|
5
5
|
import { tags } from '@lezer/highlight';
|
|
6
6
|
import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from '@codemirror/view';
|
|
7
7
|
import { RangeSetBuilder } from '@codemirror/state';
|
|
8
|
-
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
8
|
+
import { directiveLineKind, fenceDepths, findInlineDirectives } from './markdown-directives.js';
|
|
9
9
|
|
|
10
10
|
/** Markdown token colors over the admin theme variables. */
|
|
11
11
|
export function cairnHighlightStyle(): HighlightStyle {
|
|
@@ -27,19 +27,40 @@ export function cairnHighlightStyle(): HighlightStyle {
|
|
|
27
27
|
// learns what the line is without leaving the page.
|
|
28
28
|
const MACHINERY_HINT = 'Layout marker. Edit the text between these lines and leave this line as it is.';
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
// Nesting deeper than three steps shares the third visual step; the depth model itself is unbounded.
|
|
31
|
+
const DEPTH_STEPS = [1, 2, 3];
|
|
32
|
+
|
|
33
|
+
const fenceLines = DEPTH_STEPS.map((d) =>
|
|
34
|
+
Decoration.line({ class: `cm-cairn-directive-fence cm-cairn-depth-${d}`, attributes: { title: MACHINERY_HINT } }),
|
|
35
|
+
);
|
|
36
|
+
const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
|
|
31
37
|
const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
|
|
32
38
|
const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
|
|
33
39
|
|
|
34
|
-
|
|
40
|
+
// Depth needs the whole document, since a visible line's containers can open above the viewport.
|
|
41
|
+
// One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
|
|
42
|
+
// is well under a millisecond. The plugin caches the result, so the scan reruns only when the
|
|
43
|
+
// document changes and a scroll rebuilds the viewport decorations from the cached array.
|
|
44
|
+
function docDepths(view: EditorView): (number | null)[] {
|
|
45
|
+
const doc = view.state.doc;
|
|
46
|
+
const lines: string[] = [];
|
|
47
|
+
for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
|
|
48
|
+
return fenceDepths(lines);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildDirectiveDecorations(view: EditorView, depths: (number | null)[]): DecorationSet {
|
|
35
52
|
const builder = new RangeSetBuilder<Decoration>();
|
|
36
53
|
for (const { from, to } of view.visibleRanges) {
|
|
37
54
|
for (let pos = from; pos <= to; ) {
|
|
38
55
|
const line = view.state.doc.lineAt(pos);
|
|
39
56
|
const kind = directiveLineKind(line.text);
|
|
40
|
-
|
|
57
|
+
const depth = Math.min(depths[line.number - 1] ?? 0, DEPTH_STEPS.length);
|
|
58
|
+
// A fence-shaped line at depth 0 is one the depth scan disowned (a documented example
|
|
59
|
+
// inside a code block, outside any container); it gets no machinery treatment.
|
|
60
|
+
if (kind === 'fence' && depth > 0) builder.add(line.from, line.from, fenceLines[depth - 1]);
|
|
41
61
|
else if (kind === 'leaf') builder.add(line.from, line.from, leafLine);
|
|
42
|
-
else {
|
|
62
|
+
else if (kind === null) {
|
|
63
|
+
if (depth > 0) builder.add(line.from, line.from, contentLines[depth - 1]);
|
|
43
64
|
for (const r of findInlineDirectives(line.text)) {
|
|
44
65
|
builder.add(line.from + r.from, line.from + r.to, inlineMark);
|
|
45
66
|
}
|
|
@@ -55,11 +76,15 @@ export function cairnDirectivePlugin() {
|
|
|
55
76
|
return ViewPlugin.fromClass(
|
|
56
77
|
class {
|
|
57
78
|
decorations: DecorationSet;
|
|
79
|
+
depths: (number | null)[];
|
|
58
80
|
constructor(view: EditorView) {
|
|
59
|
-
this.
|
|
81
|
+
this.depths = docDepths(view);
|
|
82
|
+
this.decorations = buildDirectiveDecorations(view, this.depths);
|
|
60
83
|
}
|
|
61
84
|
update(update: ViewUpdate) {
|
|
62
|
-
if (update.docChanged
|
|
85
|
+
if (update.docChanged) this.depths = docDepths(update.view);
|
|
86
|
+
if (update.docChanged || update.viewportChanged)
|
|
87
|
+
this.decorations = buildDirectiveDecorations(update.view, this.depths);
|
|
63
88
|
}
|
|
64
89
|
},
|
|
65
90
|
{ decorations: (v) => v.decorations },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Admin Svelte components (Plan 05). The Warm Stone theme ships as a CSS side effect imported
|
|
2
2
|
// by the components that set `data-theme="cairn-admin"`.
|
|
3
|
+
export { default as CairnAdmin } from './CairnAdmin.svelte';
|
|
3
4
|
export { default as AdminLayout } from './AdminLayout.svelte';
|
|
4
5
|
export { default as LoginPage } from './LoginPage.svelte';
|
|
5
6
|
export { default as ConfirmPage } from './ConfirmPage.svelte';
|
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
// styled distinctly so an editor can tell component scaffolding from prose). Pure functions; the
|
|
3
3
|
// CodeMirror decoration plugin wraps them.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// A container fence: three or more colons, then an optional name, an optional [label], and
|
|
6
|
+
// optional {attrs}, in remark-directive order. The name is captured so the depth scan below can
|
|
7
|
+
// tell an opener (named) from a closer (bare colons). Matching is tolerant of stray whitespace,
|
|
8
|
+
// the same posture as the leaf form: a slightly off fence should still read as machinery.
|
|
9
|
+
const FENCE = /^\s{0,3}:{3,}\s*([\w-]*)\s*(\[[^\]]*\])?\s*(\{[^}]*\})?\s*$/;
|
|
6
10
|
const LEAF = /^\s{0,3}::[\w-]+(\[[^\]]*\])?(\{[^}]*\})?\s*$/;
|
|
7
11
|
const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
|
|
8
12
|
|
|
13
|
+
// A fenced code block's delimiter: three or more backticks or tildes, indent-tolerant like the
|
|
14
|
+
// directive forms. The depth scan tracks these so a documented ::: example inside a code block
|
|
15
|
+
// never opens a real container.
|
|
16
|
+
const CODE_FENCE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
17
|
+
|
|
9
18
|
/** Classify a whole line as a container fence, a leaf directive, or neither. */
|
|
10
19
|
export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
|
|
11
20
|
if (FENCE.test(line)) return 'fence';
|
|
@@ -13,6 +22,47 @@ export function directiveLineKind(line: string): 'fence' | 'leaf' | null {
|
|
|
13
22
|
return null;
|
|
14
23
|
}
|
|
15
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The 1-based container depth each line sits at, or null outside any container. A named fence
|
|
27
|
+
* opens a container; a bare fence closes the most recent one (colon counts are not trusted for
|
|
28
|
+
* pairing, since authors vary them). An opener and its closer share the opener's depth, and a
|
|
29
|
+
* line between them carries the depth of its innermost container. Lines inside a fenced code
|
|
30
|
+
* block are plain content, so a documented ::: example cannot open a phantom container running
|
|
31
|
+
* to end of document. Author errors are tolerated: an unmatched closer reads as depth 1 and the
|
|
32
|
+
* count never goes below zero.
|
|
33
|
+
*/
|
|
34
|
+
export function fenceDepths(lines: string[]): (number | null)[] {
|
|
35
|
+
const depths: (number | null)[] = [];
|
|
36
|
+
let open = 0;
|
|
37
|
+
// The marker character that opened the current code block, or null outside one. Only a line
|
|
38
|
+
// opening with the same character closes it, so tildes inside a backtick block stay literal.
|
|
39
|
+
let codeMarker: string | null = null;
|
|
40
|
+
for (const line of lines) {
|
|
41
|
+
const code = CODE_FENCE.exec(line);
|
|
42
|
+
if (code) {
|
|
43
|
+
if (codeMarker === null) codeMarker = code[1][0];
|
|
44
|
+
else if (code[1][0] === codeMarker) codeMarker = null;
|
|
45
|
+
depths.push(open > 0 ? open : null);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (codeMarker !== null) {
|
|
49
|
+
depths.push(open > 0 ? open : null);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const fence = FENCE.exec(line);
|
|
53
|
+
if (!fence) {
|
|
54
|
+
depths.push(open > 0 ? open : null);
|
|
55
|
+
} else if (fence[1]) {
|
|
56
|
+
open += 1;
|
|
57
|
+
depths.push(open);
|
|
58
|
+
} else {
|
|
59
|
+
depths.push(Math.max(open, 1));
|
|
60
|
+
if (open > 0) open -= 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return depths;
|
|
64
|
+
}
|
|
65
|
+
|
|
16
66
|
/** Inline directive ranges (`:name[...]{...}`) within a line of text. */
|
|
17
67
|
export function findInlineDirectives(text: string): { from: number; to: number }[] {
|
|
18
68
|
const out: { from: number; to: number }[] = [];
|
|
@@ -197,30 +197,3 @@ export function unwrapCairnLink(doc: string, href: string): string {
|
|
|
197
197
|
}
|
|
198
198
|
return out;
|
|
199
199
|
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
|
|
203
|
-
* the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
|
|
204
|
-
* inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
|
|
205
|
-
* span is not a link node and is never touched. Each matching node's source span is rewritten from
|
|
206
|
-
* last to first, replacing only the `](oldHref` run so the label and title stay exact.
|
|
207
|
-
*/
|
|
208
|
-
export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
|
|
209
|
-
const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
|
|
210
|
-
const spans: { start: number; end: number }[] = [];
|
|
211
|
-
visit(tree, 'link', (node: Link) => {
|
|
212
|
-
if (node.url !== oldHref) return;
|
|
213
|
-
const start = node.position?.start?.offset;
|
|
214
|
-
const end = node.position?.end?.offset;
|
|
215
|
-
if (start == null || end == null) return;
|
|
216
|
-
spans.push({ start, end });
|
|
217
|
-
});
|
|
218
|
-
spans.sort((a, b) => b.start - a.start);
|
|
219
|
-
let out = doc;
|
|
220
|
-
for (const span of spans) {
|
|
221
|
-
const src = out.slice(span.start, span.end);
|
|
222
|
-
const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
|
|
223
|
-
out = out.slice(0, span.start) + rewritten + out.slice(span.end);
|
|
224
|
-
}
|
|
225
|
-
return out;
|
|
226
|
-
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// cairn-cms: the edit page's preview-frame document. The admin's chrome isolation keeps the
|
|
2
|
+
// site's CSS out of the admin document, so EditPage renders the preview inside a sandboxed
|
|
3
|
+
// iframe whose document links the site's own stylesheets from the adapter's preview knob. This
|
|
4
|
+
// module builds that iframe's srcdoc as one pure string, so its shape is unit-testable, and it
|
|
5
|
+
// carries the device table the frame's width control offers.
|
|
6
|
+
|
|
7
|
+
import { escapeHtml } from '../escape.js';
|
|
8
|
+
import type { ResolvedPreview } from '../content/types.js';
|
|
9
|
+
|
|
10
|
+
/** One width the preview frame can take. */
|
|
11
|
+
export interface PreviewDevice {
|
|
12
|
+
id: 'desktop' | 'tablet' | 'phone' | 'small';
|
|
13
|
+
/** The device menu label, also the frame caption's first half. */
|
|
14
|
+
label: string;
|
|
15
|
+
/** Frame width in CSS pixels; null fills the pane (Desktop). */
|
|
16
|
+
width: number | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A preview device's id, the value the page persists. */
|
|
20
|
+
export type PreviewDeviceId = PreviewDevice['id'];
|
|
21
|
+
|
|
22
|
+
/** The four widths the device menu offers, in menu order. Desktop leads as the default. */
|
|
23
|
+
export const previewDevices: PreviewDevice[] = [
|
|
24
|
+
{ id: 'desktop', label: 'Desktop', width: null },
|
|
25
|
+
{ id: 'tablet', label: 'Tablet', width: 768 },
|
|
26
|
+
{ id: 'phone', label: 'Phone', width: 390 },
|
|
27
|
+
{ id: 'small', label: 'Small phone', width: 320 },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** The table row for a device id. The id type makes a miss impossible; the fallback satisfies find. */
|
|
31
|
+
export function previewDevice(id: PreviewDeviceId): PreviewDevice {
|
|
32
|
+
return previewDevices.find((d) => d.id === id) ?? previewDevices[0];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A device's user-facing text, shared by the toolbar's menu items and the frame caption: the
|
|
36
|
+
* label with its width when one is fixed, so the value reaches assistive tech at pick time. */
|
|
37
|
+
export function deviceLabel(d: PreviewDevice): string {
|
|
38
|
+
return d.width === null ? d.label : `${d.label} · ${d.width} px`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the preview iframe's srcdoc: a complete document linking the site's stylesheets around
|
|
43
|
+
* the rendered entry html. The html comes from the site's floored render pipeline, which already
|
|
44
|
+
* stripped scripts and event handlers, so it embeds unescaped; the frame's empty `sandbox` is
|
|
45
|
+
* belt and braces over that floor. The parameter is the flat `ResolvedPreview` shape `editLoad`
|
|
46
|
+
* ships, so the per-concept map can never reach the frame document by construction.
|
|
47
|
+
* `preview` null (a site without the adapter knob) yields a styleless but complete document.
|
|
48
|
+
*/
|
|
49
|
+
export function buildPreviewDoc(html: string, preview: ResolvedPreview | null): string {
|
|
50
|
+
const links = (preview?.stylesheets ?? [])
|
|
51
|
+
.map((href) => `<link rel="stylesheet" href="${escapeHtml(href)}">`)
|
|
52
|
+
.join('\n');
|
|
53
|
+
const bodyAttrs = preview?.bodyClass ? ` class="${escapeHtml(preview.bodyClass)}"` : '';
|
|
54
|
+
const content = preview?.containerClass
|
|
55
|
+
? `<div class="${escapeHtml(preview.containerClass)}">${html}</div>`
|
|
56
|
+
: html;
|
|
57
|
+
// The reset sits BEFORE the site links so the site's CSS wins every collision: it only clears
|
|
58
|
+
// the default body margin and pins a white ground for sheets that assume one.
|
|
59
|
+
//
|
|
60
|
+
// The base tag is what makes links inert. The empty sandbox alone does not: a sandboxed
|
|
61
|
+
// context may still navigate itself, and a srcdoc document resolves relative hrefs against the
|
|
62
|
+
// parent's base URL, so a clicked fragment or root link could render the admin login inside
|
|
63
|
+
// the frame. Targeting every link at a new tab turns each click into a popup, and the sandbox
|
|
64
|
+
// (which grants no allow-popups) blocks it, so a proofing click goes nowhere.
|
|
65
|
+
return [
|
|
66
|
+
'<!doctype html>',
|
|
67
|
+
'<html>',
|
|
68
|
+
'<head>',
|
|
69
|
+
'<meta charset="utf-8">',
|
|
70
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
71
|
+
'<base target="_blank">',
|
|
72
|
+
'<style>body{margin:0;background:#fff}</style>',
|
|
73
|
+
links,
|
|
74
|
+
'</head>',
|
|
75
|
+
`<body${bodyAttrs}>`,
|
|
76
|
+
content,
|
|
77
|
+
'</body>',
|
|
78
|
+
'</html>',
|
|
79
|
+
]
|
|
80
|
+
.filter((line) => line !== '')
|
|
81
|
+
.join('\n');
|
|
82
|
+
}
|
package/src/lib/content/links.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { unified } from 'unified';
|
|
|
6
6
|
import remarkParse from 'remark-parse';
|
|
7
7
|
import remarkGfm from 'remark-gfm';
|
|
8
8
|
import { visit } from 'unist-util-visit';
|
|
9
|
+
import type { Link } from 'mdast';
|
|
9
10
|
import { isValidId } from './ids.js';
|
|
10
11
|
|
|
11
12
|
/** A resolved reference to a content entry by its concept and permanent id. */
|
|
@@ -59,3 +60,30 @@ export function extractCairnLinks(body: string): CairnRef[] {
|
|
|
59
60
|
});
|
|
60
61
|
return refs;
|
|
61
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Rewrite every cairn: link whose href is exactly `oldHref` so its href becomes `newHref`, keeping
|
|
66
|
+
* the display text and any link title byte-for-byte. Rename calls this to repoint a renamed entry's
|
|
67
|
+
* inbound tokens. Parsed with the same remark pipeline as extractCairnLinks, so a token inside a code
|
|
68
|
+
* span is not a link node and is never touched. Each matching node's source span is rewritten from
|
|
69
|
+
* last to first, replacing only the `](oldHref` run so the label and title stay exact.
|
|
70
|
+
*/
|
|
71
|
+
export function rewriteCairnLink(doc: string, oldHref: string, newHref: string): string {
|
|
72
|
+
const tree = unified().use(remarkParse).use(remarkGfm).parse(doc);
|
|
73
|
+
const spans: { start: number; end: number }[] = [];
|
|
74
|
+
visit(tree, 'link', (node: Link) => {
|
|
75
|
+
if (node.url !== oldHref) return;
|
|
76
|
+
const start = node.position?.start?.offset;
|
|
77
|
+
const end = node.position?.end?.offset;
|
|
78
|
+
if (start == null || end == null) return;
|
|
79
|
+
spans.push({ start, end });
|
|
80
|
+
});
|
|
81
|
+
spans.sort((a, b) => b.start - a.start);
|
|
82
|
+
let out = doc;
|
|
83
|
+
for (const span of spans) {
|
|
84
|
+
const src = out.slice(span.start, span.end);
|
|
85
|
+
const rewritten = src.replace(`](${oldHref}`, `](${newHref}`);
|
|
86
|
+
out = out.slice(0, span.start) + rewritten + out.slice(span.end);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|