@glw907/cairn-cms 0.40.0 → 0.50.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 +76 -0
- package/README.md +3 -3
- 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 +18 -10
- package/dist/components/ConceptList.svelte.d.ts +4 -8
- package/dist/components/ConfirmPage.svelte +1 -1
- package/dist/components/EditPage.svelte +47 -19
- package/dist/components/EditPage.svelte.d.ts +4 -9
- package/dist/components/EditorToolbar.svelte +4 -0
- 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/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/link-completion.js +10 -3
- package/dist/components/markdown-format.d.ts +0 -8
- package/dist/components/markdown-format.js +0 -28
- package/dist/content/links.d.ts +8 -0
- package/dist/content/links.js +28 -0
- package/dist/content/types.d.ts +2 -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.d.ts +8 -1
- package/dist/diagnostics/conditions.js +68 -1
- package/dist/doctor/bin.d.ts +2 -0
- package/dist/doctor/bin.js +44 -0
- package/dist/doctor/check-send.d.ts +3 -0
- package/dist/doctor/check-send.js +43 -0
- package/dist/doctor/checks-cloudflare.d.ts +5 -0
- package/dist/doctor/checks-cloudflare.js +200 -0
- package/dist/doctor/checks-github.d.ts +2 -0
- package/dist/doctor/checks-github.js +57 -0
- package/dist/doctor/checks-local.d.ts +5 -0
- package/dist/doctor/checks-local.js +112 -0
- package/dist/doctor/cloudflare-api.d.ts +7 -0
- package/dist/doctor/cloudflare-api.js +24 -0
- package/dist/doctor/index.d.ts +23 -0
- package/dist/doctor/index.js +68 -0
- package/dist/doctor/report.d.ts +5 -0
- package/dist/doctor/report.js +21 -0
- package/dist/doctor/run.d.ts +8 -0
- package/dist/doctor/run.js +20 -0
- package/dist/doctor/types.d.ts +41 -0
- package/dist/doctor/types.js +10 -0
- package/dist/doctor/wrangler-config.d.ts +12 -0
- package/dist/doctor/wrangler-config.js +125 -0
- package/dist/email.js +4 -11
- package/dist/env.d.ts +1 -1
- package/dist/env.js +3 -2
- 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/signing.d.ts +3 -1
- package/dist/github/signing.js +13 -5
- package/dist/github/types.d.ts +2 -0
- package/dist/github/types.js +4 -0
- 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 +34 -14
- package/dist/sveltekit/content-routes.js +78 -44
- package/dist/sveltekit/guard.js +15 -3
- 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 +19 -10
- 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/package.json +10 -4
- 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 +18 -10
- package/src/lib/components/ConfirmPage.svelte +1 -1
- package/src/lib/components/EditPage.svelte +47 -19
- package/src/lib/components/EditorToolbar.svelte +4 -0
- package/src/lib/components/LoginPage.svelte +2 -2
- package/src/lib/components/ManageEditors.svelte +4 -3
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/link-completion.ts +10 -3
- package/src/lib/components/markdown-format.ts +0 -27
- package/src/lib/content/links.ts +28 -0
- package/src/lib/content/types.ts +2 -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 +75 -2
- package/src/lib/doctor/bin.ts +45 -0
- package/src/lib/doctor/check-send.ts +43 -0
- package/src/lib/doctor/checks-cloudflare.ts +222 -0
- package/src/lib/doctor/checks-github.ts +63 -0
- package/src/lib/doctor/checks-local.ts +119 -0
- package/src/lib/doctor/cloudflare-api.ts +33 -0
- package/src/lib/doctor/index.ts +93 -0
- package/src/lib/doctor/report.ts +30 -0
- package/src/lib/doctor/run.ts +23 -0
- package/src/lib/doctor/types.ts +52 -0
- package/src/lib/doctor/wrangler-config.ts +142 -0
- package/src/lib/email.ts +4 -11
- package/src/lib/env.ts +3 -2
- package/src/lib/escape.ts +12 -0
- package/src/lib/github/credentials.ts +6 -2
- package/src/lib/github/signing.ts +13 -6
- package/src/lib/github/types.ts +5 -0
- package/src/lib/log/events.ts +2 -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 +121 -55
- package/src/lib/sveltekit/guard.ts +16 -3
- 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 +21 -11
- package/src/lib/sveltekit/static-admin-page.ts +1 -9
- package/src/lib/sveltekit/types.ts +16 -7
- 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
|
@@ -279,7 +279,7 @@ identical on every host regardless of the site's own theme.
|
|
|
279
279
|
<kbd class="ml-auto hidden rounded border border-[var(--cairn-card-border)] px-1.5 text-[0.6875rem] font-medium sm:inline">⌘K</kbd>
|
|
280
280
|
</button>
|
|
281
281
|
</div>
|
|
282
|
-
{#if pendingCount > 0
|
|
282
|
+
{#if pendingCount > 0}
|
|
283
283
|
<div class="flex-none">
|
|
284
284
|
<button type="button" class="btn btn-primary btn-sm" aria-haspopup="dialog" onclick={() => publishAllDialog?.showModal()}>
|
|
285
285
|
Publish site ({pendingCount})
|
|
@@ -349,9 +349,7 @@ identical on every host regardless of the site's own theme.
|
|
|
349
349
|
<form method="dialog" class="modal-backdrop"><button tabindex="-1" aria-label="Close">close</button></form>
|
|
350
350
|
</dialog>
|
|
351
351
|
|
|
352
|
-
|
|
353
|
-
pending ref) must hide the dialog along with its trigger. -->
|
|
354
|
-
{#if pendingCount > 0 && data.concepts.length > 0}
|
|
352
|
+
{#if pendingCount > 0}
|
|
355
353
|
<dialog bind:this={publishAllDialog} class="modal" aria-labelledby="cairn-publish-all-title">
|
|
356
354
|
<div class="modal-box">
|
|
357
355
|
<div class="mb-3 flex items-center justify-between">
|
|
@@ -367,9 +365,9 @@ identical on every host regardless of the site's own theme.
|
|
|
367
365
|
{/each}
|
|
368
366
|
</ul>
|
|
369
367
|
{/each}
|
|
370
|
-
<!-- The publishAll action is
|
|
371
|
-
|
|
372
|
-
<form method="POST" action=
|
|
368
|
+
<!-- The publishAll named action is valid on every authed admin view, so the confirm
|
|
369
|
+
posts to the current page and the topbar works from anywhere. -->
|
|
370
|
+
<form method="POST" action="?/publishAll" class="mt-4 flex justify-end gap-2">
|
|
373
371
|
<CsrfField token={data.csrf} />
|
|
374
372
|
<button type="button" class="btn btn-sm" onclick={() => publishAllDialog?.close()}>Cancel</button>
|
|
375
373
|
<button type="submit" class="btn btn-sm btn-primary">Publish site</button>
|
|
@@ -455,7 +453,7 @@ identical on every host regardless of the site's own theme.
|
|
|
455
453
|
<div class="text-xs capitalize text-[var(--color-subtle)]">{data.user.role}</div>
|
|
456
454
|
</div>
|
|
457
455
|
</div>
|
|
458
|
-
<form method="POST" action="
|
|
456
|
+
<form method="POST" action="?/logout" class="mt-4">
|
|
459
457
|
<CsrfField token={data.csrf} />
|
|
460
458
|
<button type="submit" class="btn btn-ghost btn-sm btn-block justify-start">
|
|
461
459
|
<LogOutIcon class="h-4 w-4" /> Sign out
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The single-mount admin page. A site's catch-all `/admin/[...path]` route renders this one
|
|
4
|
+
component for every admin view, feeding it the discriminated `AdminData` from `createCairnAdmin`'s
|
|
5
|
+
load. It is a pure switcher on `data.view`: the public auth pages mount bare, and the authed views
|
|
6
|
+
mount inside `AdminLayout`. No styling or wrapper elements of its own.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import AdminLayout from './AdminLayout.svelte';
|
|
10
|
+
import LoginPage from './LoginPage.svelte';
|
|
11
|
+
import ConfirmPage from './ConfirmPage.svelte';
|
|
12
|
+
import ConceptList from './ConceptList.svelte';
|
|
13
|
+
import EditPage from './EditPage.svelte';
|
|
14
|
+
import ManageEditors from './ManageEditors.svelte';
|
|
15
|
+
import NavTree from './NavTree.svelte';
|
|
16
|
+
import type { AdminData } from '../sveltekit/cairn-admin.js';
|
|
17
|
+
import type { ContentFormFailure } from '../sveltekit/content-routes.js';
|
|
18
|
+
import type { ComponentRegistry } from '../render/registry.js';
|
|
19
|
+
import type { IconSet } from '../render/glyph.js';
|
|
20
|
+
import type { LinkResolve } from '../content/links.js';
|
|
21
|
+
|
|
22
|
+
interface Props {
|
|
23
|
+
/** The discriminated view data from `createCairnAdmin`'s load. */
|
|
24
|
+
data: AdminData;
|
|
25
|
+
/** The last action's result, forwarded to whichever view rendered: the shared content-action
|
|
26
|
+
* failure family (every failure carries `error`), merged with the auth and editors results,
|
|
27
|
+
* so the route's one `form` export covers every view. */
|
|
28
|
+
form?:
|
|
29
|
+
| (ContentFormFailure & {
|
|
30
|
+
sent?: boolean;
|
|
31
|
+
status?: 'sent' | 'send_error' | 'throttled';
|
|
32
|
+
ok?: boolean;
|
|
33
|
+
})
|
|
34
|
+
| null;
|
|
35
|
+
/** The site's design-accurate render pipeline, for the edit view's preview pane. */
|
|
36
|
+
render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
|
|
37
|
+
/** The site's component registry, for the edit view's insert palette. */
|
|
38
|
+
registry?: ComponentRegistry;
|
|
39
|
+
/** The site's icon set, for the edit view's guided form fields. */
|
|
40
|
+
icons?: IconSet;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let { data, form = null, render, registry, icons }: Props = $props();
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
{#if data.view === 'login'}
|
|
47
|
+
<LoginPage data={data.page} {form} />
|
|
48
|
+
{:else if data.view === 'confirm'}
|
|
49
|
+
<ConfirmPage data={data.page} />
|
|
50
|
+
{:else}
|
|
51
|
+
<AdminLayout data={data.layout}>
|
|
52
|
+
{#if data.view === 'list'}
|
|
53
|
+
<!-- The single mount reuses this component across /admin/posts -> /admin/pages, so the
|
|
54
|
+
concept id keys the list: crossing concepts remounts it and drops the old query,
|
|
55
|
+
sort, page, and dialog state. -->
|
|
56
|
+
{#key data.page.conceptId}
|
|
57
|
+
<ConceptList data={data.page} {form} />
|
|
58
|
+
{/key}
|
|
59
|
+
{:else if data.view === 'edit'}
|
|
60
|
+
<EditPage data={{ ...data.page, siteName: data.layout.siteName }} {render} {registry} {icons} {form} />
|
|
61
|
+
{:else if data.view === 'editors'}
|
|
62
|
+
<ManageEditors data={data.page} {form} />
|
|
63
|
+
{:else if data.view === 'nav'}
|
|
64
|
+
<NavTree data={data.page} />
|
|
65
|
+
{/if}
|
|
66
|
+
</AdminLayout>
|
|
67
|
+
{/if}
|
|
@@ -7,8 +7,7 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
7
7
|
-->
|
|
8
8
|
<script lang="ts">
|
|
9
9
|
import { slugify } from '../content/ids.js';
|
|
10
|
-
import type { EntrySummary, ListData } from '../sveltekit/content-routes.js';
|
|
11
|
-
import type { InboundLink } from '../content/manifest.js';
|
|
10
|
+
import type { DeleteRefusal, EntrySummary, ListData } from '../sveltekit/content-routes.js';
|
|
12
11
|
import CsrfField from './CsrfField.svelte';
|
|
13
12
|
import DeleteDialog from './DeleteDialog.svelte';
|
|
14
13
|
import CairnLogo from './CairnLogo.svelte';
|
|
@@ -17,10 +16,10 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
17
16
|
interface Props {
|
|
18
17
|
/** The list load's data: the concept, its entries, and any inline or form errors. */
|
|
19
18
|
data: ListData;
|
|
20
|
-
/** The `?/delete` action result. A blocked delete returns the
|
|
21
|
-
*
|
|
19
|
+
/** The `?/delete` action result. A blocked delete returns the `DeleteRefusal` payload (the
|
|
20
|
+
* shared `error` summary, the refused entry id, and its inbound linkers), so the list names
|
|
22
21
|
* the blockers and refuses (block-until-clean). */
|
|
23
|
-
form?:
|
|
22
|
+
form?: Partial<DeleteRefusal> | null;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
let { data, form = null }: Props = $props();
|
|
@@ -107,6 +106,14 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
107
106
|
// flex layout and a hover affordance on top of this.
|
|
108
107
|
const headerLabel = 'text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]';
|
|
109
108
|
const sortButton = `inline-flex items-center gap-1 ${headerLabel} hover:text-base-content`;
|
|
109
|
+
|
|
110
|
+
// The publish-all flash. A racing second admin can publish first, leaving this redirect
|
|
111
|
+
// counting zero; say nothing then.
|
|
112
|
+
const publishedAllMessage = $derived(
|
|
113
|
+
data.publishedAll !== null && data.publishedAll > 0
|
|
114
|
+
? `Published ${data.publishedAll} ${data.publishedAll === 1 ? 'entry' : 'entries'}.`
|
|
115
|
+
: '',
|
|
116
|
+
);
|
|
110
117
|
</script>
|
|
111
118
|
|
|
112
119
|
<header class="mb-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
|
@@ -122,11 +129,12 @@ content sizes. The header New button opens a dialog holding the create form.
|
|
|
122
129
|
</div>
|
|
123
130
|
</header>
|
|
124
131
|
|
|
125
|
-
<!--
|
|
126
|
-
{#if
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
<!-- One persistent live region announces the publish-all flash (the EditPage pattern): a
|
|
133
|
+
{#if}-gated role element inserted fresh is announced inconsistently, so the visible alert
|
|
134
|
+
below keeps its styling without a role and the message is announced once. -->
|
|
135
|
+
<div class="sr-only" aria-live="polite">{publishedAllMessage}</div>
|
|
136
|
+
{#if publishedAllMessage}
|
|
137
|
+
<div class="alert alert-success mb-4 text-sm">{publishedAllMessage}</div>
|
|
130
138
|
{/if}
|
|
131
139
|
{#if data.formError}
|
|
132
140
|
<div role="alert" class="alert alert-error mb-4 text-sm">{data.formError}</div>
|
|
@@ -40,7 +40,7 @@ in a hidden field and consumes nothing; only the explicit POST verifies (spec §
|
|
|
40
40
|
{:else}
|
|
41
41
|
<h1 class="text-lg font-semibold">Almost there</h1>
|
|
42
42
|
<p class="mt-1 mb-5 text-sm text-[var(--color-muted)]">Confirm to finish signing in to {data.siteName}.</p>
|
|
43
|
-
<form method="POST">
|
|
43
|
+
<form method="POST" action="?/confirm">
|
|
44
44
|
<input type="hidden" name="token" value={data.token} />
|
|
45
45
|
<CsrfField token={data.csrf} />
|
|
46
46
|
<button type="submit" class="btn btn-primary btn-block">Confirm sign-in</button>
|
|
@@ -14,6 +14,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
14
14
|
<script lang="ts">
|
|
15
15
|
import { untrack } from 'svelte';
|
|
16
16
|
import { beforeNavigate } from '$app/navigation';
|
|
17
|
+
import { page } from '$app/state';
|
|
17
18
|
import CsrfField from './CsrfField.svelte';
|
|
18
19
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
19
20
|
import EditorToolbar from './EditorToolbar.svelte';
|
|
@@ -25,10 +26,10 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
25
26
|
import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
|
|
26
27
|
import { cairnLinkCompletionSource } from './link-completion.js';
|
|
27
28
|
import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
|
|
28
|
-
import { directiveLineKind } from './markdown-directives.js';
|
|
29
|
+
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
29
30
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
30
31
|
import type { IconSet } from '../render/glyph.js';
|
|
31
|
-
import type { EditData } from '../sveltekit/content-routes.js';
|
|
32
|
+
import type { ContentFormFailure, EditData } from '../sveltekit/content-routes.js';
|
|
32
33
|
import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
|
|
33
34
|
import type { LinkResolve } from '../content/links.js';
|
|
34
35
|
import { manifestLinkResolver } from '../content/manifest.js';
|
|
@@ -42,9 +43,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
42
43
|
render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
|
|
43
44
|
/** The site's icon set, for the guided form's icon fields. */
|
|
44
45
|
icons?: IconSet;
|
|
45
|
-
/** The
|
|
46
|
-
*
|
|
47
|
-
form?:
|
|
46
|
+
/** The last content action's failure: the save guard's broken links, the delete guard's
|
|
47
|
+
* inbound linkers, or a rename refusal, each carrying the shared `error` summary. */
|
|
48
|
+
form?: ContentFormFailure | null;
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
let { data, registry, render, icons, form }: Props = $props();
|
|
@@ -101,6 +102,14 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
101
102
|
// navigation passes through because busy flips before it starts, and a non-edit POST's because
|
|
102
103
|
// leaving does.
|
|
103
104
|
beforeNavigate((navigation) => {
|
|
105
|
+
// A full-page unload (refresh, tab close, external link): per SvelteKit semantics, cancel()
|
|
106
|
+
// on a leave navigation is what asks the browser for its native dialog, so no confirm()
|
|
107
|
+
// here or two prompts would stack. The beforeunload listener below is deliberate
|
|
108
|
+
// belt-and-braces, not the dialog's source.
|
|
109
|
+
if (navigation.willUnload) {
|
|
110
|
+
if (dirty && !busy && !leaving) navigation.cancel();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
104
113
|
if (dirty && !busy && !leaving && !confirm('You have unsaved changes. Leave anyway?'))
|
|
105
114
|
navigation.cancel();
|
|
106
115
|
});
|
|
@@ -211,8 +220,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
211
220
|
// not refused. When set, a delete was blocked by a link that appeared since the page loaded.
|
|
212
221
|
const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
|
|
213
222
|
|
|
214
|
-
//
|
|
215
|
-
|
|
223
|
+
// The shared failure summary, rendered only when no richer banner claims the failure: the save
|
|
224
|
+
// and delete guards get their own banners from brokenLinks and inboundLinks below, so this
|
|
225
|
+
// surfaces the rest (a rename refusal, today).
|
|
226
|
+
const formError = $derived(
|
|
227
|
+
form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : '',
|
|
228
|
+
);
|
|
216
229
|
|
|
217
230
|
// The entry this surface is editing. SvelteKit reuses the page component across a same-route
|
|
218
231
|
// navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
|
|
@@ -240,14 +253,12 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
240
253
|
});
|
|
241
254
|
});
|
|
242
255
|
|
|
243
|
-
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>.
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
$
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const drafts = new URLSearchParams(search).get('drafts');
|
|
250
|
-
draftWarning = drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
256
|
+
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
|
|
257
|
+
// is reactive kit state, so a client-side navigation that swaps the search string re-derives
|
|
258
|
+
// this, and the read is SSR-safe.
|
|
259
|
+
const draftWarning = $derived.by(() => {
|
|
260
|
+
const drafts = page.url.searchParams.get('drafts');
|
|
261
|
+
return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
251
262
|
});
|
|
252
263
|
|
|
253
264
|
// The one transient feedback strip under the sticky header. The redirect flags are mutually
|
|
@@ -272,7 +283,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
272
283
|
);
|
|
273
284
|
const assertiveMessage = $derived.by(() => {
|
|
274
285
|
if (data.error) return data.error;
|
|
275
|
-
if (
|
|
286
|
+
if (formError) return formError;
|
|
276
287
|
if (deleteRefusedLinks.length) {
|
|
277
288
|
const count = deleteRefusedLinks.length;
|
|
278
289
|
return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
|
|
@@ -284,12 +295,29 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
284
295
|
return '';
|
|
285
296
|
});
|
|
286
297
|
|
|
298
|
+
// One line of body text reduced to its prose: inline directives drop wholesale, then the
|
|
299
|
+
// markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
|
|
300
|
+
// as two words instead of mashing the link text into its destination, so a link counts its
|
|
301
|
+
// text plus its URL and the count never undercounts prose.
|
|
302
|
+
function proseOnly(line: string): string {
|
|
303
|
+
let out = '';
|
|
304
|
+
let cursor = 0;
|
|
305
|
+
for (const { from, to } of findInlineDirectives(line)) {
|
|
306
|
+
out += line.slice(cursor, from);
|
|
307
|
+
cursor = to;
|
|
308
|
+
}
|
|
309
|
+
out += line.slice(cursor);
|
|
310
|
+
return out.replace(/[*_~`[\]()#]/g, ' ');
|
|
311
|
+
}
|
|
312
|
+
|
|
287
313
|
// The editor footer's word count, over the local body so it tracks every keystroke. Directive
|
|
288
|
-
// machinery lines and table rows are dropped first
|
|
314
|
+
// machinery lines and table rows are dropped first and the inline syntax stripped, so the
|
|
315
|
+
// count reads as the author's prose.
|
|
289
316
|
const countedBody = $derived(
|
|
290
317
|
body
|
|
291
318
|
.split('\n')
|
|
292
319
|
.filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
|
|
320
|
+
.map(proseOnly)
|
|
293
321
|
.join('\n'),
|
|
294
322
|
);
|
|
295
323
|
const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
|
|
@@ -505,8 +533,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
505
533
|
{#if data.error}
|
|
506
534
|
<div class="alert alert-error mb-4 text-sm">{data.error}</div>
|
|
507
535
|
{/if}
|
|
508
|
-
{#if
|
|
509
|
-
<div class="alert alert-error mb-4 text-sm">{
|
|
536
|
+
{#if formError}
|
|
537
|
+
<div class="alert alert-error mb-4 text-sm">{formError}</div>
|
|
510
538
|
{/if}
|
|
511
539
|
{#if deleteRefusedLinks.length}
|
|
512
540
|
<div class="alert alert-error mb-4 flex-col items-start text-sm">
|
|
@@ -113,6 +113,10 @@ are stroke SVG icons in the admin's house style (24x24 viewBox, `currentColor`,
|
|
|
113
113
|
const items = rovingControls();
|
|
114
114
|
if (items.length === 0) return;
|
|
115
115
|
const stop = Math.min(roving, items.length - 1);
|
|
116
|
+
// Write the clamp back so the stored stop never drifts from the displayed one across a
|
|
117
|
+
// Preview round trip. The effect reads roving, so the guarded write re-runs it once and
|
|
118
|
+
// converges (the second pass computes the same stop and writes nothing).
|
|
119
|
+
if (stop !== roving) roving = stop;
|
|
116
120
|
for (const [i, el] of items.entries()) el.setAttribute('tabindex', i === stop ? '0' : '-1');
|
|
117
121
|
});
|
|
118
122
|
|
|
@@ -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>
|
|
@@ -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';
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
// to CodeMirror's CompletionSource. The editor wires the source through a generic completionSources
|
|
4
4
|
// prop, so this stays the only link-aware piece and the seam itself knows nothing about links.
|
|
5
5
|
import type { Completion, CompletionContext, CompletionResult, CompletionSource } from '@codemirror/autocomplete';
|
|
6
|
-
import { syntaxTree } from '@codemirror/language';
|
|
7
6
|
import type { LinkTarget } from '../content/manifest.js';
|
|
8
7
|
import { formatCairnToken, escapeLinkText } from '../content/links.js';
|
|
9
8
|
|
|
9
|
+
// EditPage imports this module statically, so a static @codemirror value import here would pull
|
|
10
|
+
// CodeMirror into a consumer's server bundle. syntaxTree resolves lazily inside the source
|
|
11
|
+
// instead (a CompletionSource may return a Promise), cached after the first completion.
|
|
12
|
+
let langMod: typeof import('@codemirror/language') | null = null;
|
|
13
|
+
|
|
10
14
|
/** The known concepts in display order; an unlisted concept sorts after these under its own name. */
|
|
11
15
|
const CONCEPT_SECTIONS: Record<string, { name: string; rank: number }> = {
|
|
12
16
|
pages: { name: 'Pages', rank: 0 },
|
|
@@ -41,14 +45,17 @@ export function linkCompletions(targets: LinkTarget[], query: string): Completio
|
|
|
41
45
|
* whole `[[query` with the chosen link, and sets filter:false because linkCompletions already
|
|
42
46
|
* filtered by the query (CodeMirror would otherwise re-filter against the literal `[[query`). */
|
|
43
47
|
export function cairnLinkCompletionSource(targets: LinkTarget[]): CompletionSource {
|
|
44
|
-
return (context: CompletionContext): CompletionResult | null => {
|
|
48
|
+
return async (context: CompletionContext): Promise<CompletionResult | null> => {
|
|
45
49
|
const line = context.state.doc.lineAt(context.pos);
|
|
46
50
|
const before = context.state.sliceDoc(line.from, context.pos);
|
|
47
51
|
const trigger = matchCairnTrigger(before);
|
|
48
52
|
if (!trigger) return null;
|
|
49
53
|
// Skip a [[ inside a fenced or inline code node: a cairn link there would be literal text, and
|
|
50
54
|
// the build resolver does not look inside code. The node name carries "Code" for both forms.
|
|
51
|
-
|
|
55
|
+
langMod ??= await import('@codemirror/language');
|
|
56
|
+
// The first completion awaits the import above, so the request may already be stale here.
|
|
57
|
+
if (context.aborted) return null;
|
|
58
|
+
const node = langMod.syntaxTree(context.state).resolveInner(context.pos, -1);
|
|
52
59
|
for (let n: typeof node | null = node; n; n = n.parent) {
|
|
53
60
|
if (/Code/.test(n.name)) return null;
|
|
54
61
|
}
|
|
@@ -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
|
-
}
|
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
|
+
}
|
package/src/lib/content/types.ts
CHANGED
|
@@ -176,8 +176,8 @@ export interface CairnAdapter {
|
|
|
176
176
|
backend: BackendConfig;
|
|
177
177
|
sender: SenderConfig;
|
|
178
178
|
/** The site's one renderer: the editor preview and every public page call it (design decision 4).
|
|
179
|
-
* `resolve` rewrites cairn: links to live permalinks; the build passes a site-
|
|
180
|
-
* preview a manifest one. */
|
|
179
|
+
* `resolve` rewrites cairn: links to live permalinks; the build passes a site-resolver-backed
|
|
180
|
+
* one, the preview a manifest one. */
|
|
181
181
|
render(md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }): string | Promise<string>;
|
|
182
182
|
/** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
|
|
183
183
|
* in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
|
package/src/lib/delivery/data.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// the graph. The full ./delivery barrel re-exports this and adds the route loaders.
|
|
4
4
|
export { createContentIndex, fromGlob } from './content-index.js';
|
|
5
5
|
export type { RawFile, ContentSummary, ContentEntry, ContentIndex, ContentProblem } from './content-index.js';
|
|
6
|
-
export {
|
|
7
|
-
export type {
|
|
6
|
+
export { createSiteResolver, buildLinkResolver } from './site-resolver.js';
|
|
7
|
+
export type { SiteResolver, ConceptIndex } from './site-resolver.js';
|
|
8
8
|
export { createSiteIndexes } from './site-indexes.js';
|
|
9
9
|
export type { SiteIndexes, SiteGlobs } from './site-indexes.js';
|
|
10
10
|
export { siteDescriptors } from './site-descriptors.js';
|
|
@@ -18,9 +18,7 @@ export { buildSeoMeta } from './seo.js';
|
|
|
18
18
|
export type { SeoInput, SeoMeta } from './seo.js';
|
|
19
19
|
export { readSeoFields, resolveImageUrl } from './seo-fields.js';
|
|
20
20
|
export type { SeoFields } from './seo-fields.js';
|
|
21
|
-
export { paginate } from './paginate.js';
|
|
22
|
-
export type { Page } from './paginate.js';
|
|
23
21
|
export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
|
|
24
22
|
export { jsonLdScript } from './json-ld.js';
|
|
25
23
|
export { permalink } from '../content/permalink.js';
|
|
26
|
-
export { buildSiteManifest
|
|
24
|
+
export { buildSiteManifest } from './manifest.js';
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// channel and a list of items, so they unit-test without a render or a network. The caller
|
|
3
3
|
// (a template +server.ts shim) assembles items from the content index and passes absolute
|
|
4
4
|
// URLs built from PUBLIC_ORIGIN.
|
|
5
|
+
import { escapeXml } from './xml.js';
|
|
5
6
|
|
|
6
7
|
/** Feed channel metadata. URLs are absolute. */
|
|
7
8
|
export interface FeedChannel {
|
|
@@ -24,14 +25,6 @@ export interface FeedItem {
|
|
|
24
25
|
tags?: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function escapeXml(value: string): string {
|
|
28
|
-
return value
|
|
29
|
-
.replace(/&/g, '&')
|
|
30
|
-
.replace(/</g, '<')
|
|
31
|
-
.replace(/>/g, '>')
|
|
32
|
-
.replace(/"/g, '"');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
28
|
/** Make a string safe inside a CDATA section by splitting any `]]>` across two sections. */
|
|
36
29
|
function cdataSafe(value: string): string {
|
|
37
30
|
return value.replace(/]]>/g, ']]]]><![CDATA[>');
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
// lives at ./delivery/head. Importing this pulls @sveltejs/kit through the route loaders, so a
|
|
4
4
|
// plain-Node tool imports from ./delivery/data instead.
|
|
5
5
|
export * from './data.js';
|
|
6
|
-
export { createPublicRoutes } from '
|
|
6
|
+
export { createPublicRoutes } from './public-routes.js';
|
|
7
7
|
export type {
|
|
8
8
|
PublicRoutesDeps,
|
|
9
9
|
ListData,
|
|
10
10
|
TagData,
|
|
11
11
|
TagIndexData,
|
|
12
12
|
EntryData,
|
|
13
|
-
} from '
|
|
13
|
+
} from './public-routes.js';
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
// cairn-cms: the build-side manifest builder
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// the build (the backstop). The admin preview uses manifestLinkResolver instead.
|
|
1
|
+
// cairn-cms: the build-side manifest builder (content-graph design). buildSiteManifest mirrors
|
|
2
|
+
// createSiteIndexes: it maps the site descriptors over the per-concept globs and projects each
|
|
3
|
+
// file to a manifest row. The build-time cairn: link resolver lives beside the site resolver in
|
|
4
|
+
// site-resolver.ts; the admin preview uses manifestLinkResolver instead.
|
|
6
5
|
import { siteDescriptors } from './site-descriptors.js';
|
|
7
6
|
import { fromGlob } from './content-index.js';
|
|
8
7
|
import { parseMarkdown } from '../content/frontmatter.js';
|
|
9
8
|
import { emptyManifest, manifestEntryFromFile } from '../content/manifest.js';
|
|
10
9
|
import type { Manifest } from '../content/manifest.js';
|
|
11
|
-
import type { LinkResolve } from '../content/links.js';
|
|
12
|
-
import type { SiteIndex } from './site-index.js';
|
|
13
10
|
import type { SiteConfig } from '../nav/site-config.js';
|
|
14
11
|
import type { CairnAdapter } from '../content/types.js';
|
|
15
12
|
import type { SiteGlobs } from './site-indexes.js';
|
|
@@ -22,7 +19,7 @@ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: Si
|
|
|
22
19
|
for (const descriptor of siteDescriptors(adapter, config)) {
|
|
23
20
|
const record = globRecord[descriptor.id] ?? {};
|
|
24
21
|
for (const file of fromGlob(record)) {
|
|
25
|
-
// Validate the same way createContentIndex does, so the manifest and the site
|
|
22
|
+
// Validate the same way createContentIndex does, so the manifest and the site resolver agree on
|
|
26
23
|
// which entries exist. A validation failure is excluded from both; otherwise the preview would
|
|
27
24
|
// resolve a link the build then rejects as a missing target.
|
|
28
25
|
const { frontmatter, body } = parseMarkdown(file.raw);
|
|
@@ -32,13 +29,3 @@ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: Si
|
|
|
32
29
|
}
|
|
33
30
|
return manifest;
|
|
34
31
|
}
|
|
35
|
-
|
|
36
|
-
/** A resolver backed by the site index, for the build. A miss throws, so a dangling cairn: token
|
|
37
|
-
* fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
|
|
38
|
-
export function buildLinkResolver(site: SiteIndex): LinkResolve {
|
|
39
|
-
return (ref) => {
|
|
40
|
-
const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
|
|
41
|
-
if (!url) throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
|
|
42
|
-
return url;
|
|
43
|
-
};
|
|
44
|
-
}
|