@glw907/cairn-cms 0.50.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 +40 -0
- package/dist/components/EditPage.svelte +94 -16
- package/dist/components/EditPage.svelte.d.ts +4 -1
- package/dist/components/EditorToolbar.svelte +79 -8
- package/dist/components/EditorToolbar.svelte.d.ts +10 -2
- 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/markdown-directives.d.ts +10 -0
- package/dist/components/markdown-directives.js +54 -1
- 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/types.d.ts +33 -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/env.d.ts +2 -1
- package/dist/env.js +9 -4
- package/dist/index.d.ts +1 -1
- package/dist/sveltekit/content-routes.d.ts +5 -1
- package/dist/sveltekit/content-routes.js +25 -17
- package/dist/sveltekit/guard.d.ts +8 -2
- package/dist/sveltekit/guard.js +3 -1
- package/dist/sveltekit/nav-routes.js +3 -9
- package/dist/vite/index.d.ts +16 -0
- package/dist/vite/index.js +57 -13
- package/package.json +2 -2
- package/src/lib/components/EditPage.svelte +94 -16
- package/src/lib/components/EditorToolbar.svelte +79 -8
- 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/markdown-directives.ts +51 -1
- package/src/lib/components/preview-doc.ts +82 -0
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/types.ts +32 -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/env.ts +9 -4
- package/src/lib/index.ts +2 -0
- package/src/lib/sveltekit/content-routes.ts +29 -17
- package/src/lib/sveltekit/guard.ts +4 -2
- package/src/lib/sveltekit/nav-routes.ts +3 -10
- package/src/lib/vite/index.ts +71 -17
|
@@ -8,13 +8,7 @@ import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
|
|
|
8
8
|
import { isConflict } from '../github/types.js';
|
|
9
9
|
import { log } from '../log/index.js';
|
|
10
10
|
import { parseSiteConfig, extractMenu, validateNavTree, setMenu } from '../nav/site-config.js';
|
|
11
|
-
|
|
12
|
-
function sessionOf(event) {
|
|
13
|
-
const editor = event.locals.editor;
|
|
14
|
-
if (!editor)
|
|
15
|
-
throw redirect(303, '/admin/login');
|
|
16
|
-
return editor;
|
|
17
|
-
}
|
|
11
|
+
import { requireSession } from './guard.js';
|
|
18
12
|
export function createNavRoutes(runtime, deps = {}) {
|
|
19
13
|
const mintToken = deps.mintToken ?? ((env) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
20
14
|
/** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
|
|
@@ -33,7 +27,7 @@ export function createNavRoutes(runtime, deps = {}) {
|
|
|
33
27
|
}
|
|
34
28
|
/** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
|
|
35
29
|
async function navLoad(event) {
|
|
36
|
-
|
|
30
|
+
requireSession(event);
|
|
37
31
|
const config = runtime.navMenu;
|
|
38
32
|
if (!config)
|
|
39
33
|
throw error(404, 'No navigation menu configured');
|
|
@@ -79,7 +73,7 @@ export function createNavRoutes(runtime, deps = {}) {
|
|
|
79
73
|
}
|
|
80
74
|
/** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
|
|
81
75
|
async function navSave(event) {
|
|
82
|
-
const editor =
|
|
76
|
+
const editor = requireSession(event);
|
|
83
77
|
const config = runtime.navMenu;
|
|
84
78
|
if (!config)
|
|
85
79
|
throw error(404, 'No navigation menu configured');
|
package/dist/vite/index.d.ts
CHANGED
|
@@ -30,3 +30,19 @@ export declare function cairnManifest(opts: CairnManifestOptions): Plugin;
|
|
|
30
30
|
* resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
|
|
31
31
|
* so the write logic is testable apart from the CLI shell. */
|
|
32
32
|
export declare function writeManifest(cwd?: string): Promise<void>;
|
|
33
|
+
/** The repo and sender facts cairn-doctor derives off the consumer's adapter. */
|
|
34
|
+
export interface AdapterFacts {
|
|
35
|
+
/** `cairn.backend.owner`. */
|
|
36
|
+
owner?: string;
|
|
37
|
+
/** `cairn.backend.repo`. */
|
|
38
|
+
repo?: string;
|
|
39
|
+
/** `cairn.sender.from`. */
|
|
40
|
+
from?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
|
|
43
|
+
* through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
|
|
44
|
+
* cairn-doctor calls this to fill inputs the operator did not pass. Derivation is best-effort:
|
|
45
|
+
* any failure (no Vite config, no cairnManifest plugin, a config module that throws) returns
|
|
46
|
+
* null, so the doctor degrades to flags instead of crashing. This runs only on the bin path,
|
|
47
|
+
* never in a Worker. */
|
|
48
|
+
export declare function readAdapterFacts(cwd?: string): Promise<AdapterFacts | null>;
|
package/dist/vite/index.js
CHANGED
|
@@ -30,17 +30,17 @@ const built = buildSiteManifest(cairn, siteConfig, globs);
|
|
|
30
30
|
export const result = ${resultExpr};
|
|
31
31
|
`;
|
|
32
32
|
}
|
|
33
|
-
/** Evaluate
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
async function evalVirtual(
|
|
33
|
+
/** Evaluate a virtual module source inside the consumer's own Vite resolution, then return the
|
|
34
|
+
* module's `result`. It reuses the consumer's loaded config (so `$lib`, the config module,
|
|
35
|
+
* `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the cairnManifest
|
|
36
|
+
* plugin from the nested server's plugin list, so its buildStart never recurses. This runs at
|
|
37
|
+
* build time and in the bins, never in the request lifecycle. */
|
|
38
|
+
async function evalVirtual(source, root) {
|
|
39
39
|
const { createServer, loadConfigFromFile } = await import('vite');
|
|
40
40
|
// Load the consumer's real Vite config so the nested server inherits SvelteKit's resolution
|
|
41
41
|
// (the $lib alias, the app root, the ?raw and import.meta.glob handling). Drop cairnManifest from
|
|
42
42
|
// it so the nested server's buildStart does not recurse, and add a plugin that serves only the
|
|
43
|
-
// virtual module
|
|
43
|
+
// given virtual module source.
|
|
44
44
|
const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, root);
|
|
45
45
|
const inlineConfig = loaded?.config ?? {};
|
|
46
46
|
const server = await createServer({
|
|
@@ -49,7 +49,7 @@ async function evalVirtual(opts, mode, root) {
|
|
|
49
49
|
configFile: false,
|
|
50
50
|
logLevel: 'silent',
|
|
51
51
|
server: { middlewareMode: true, hmr: false, watch: null },
|
|
52
|
-
plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(
|
|
52
|
+
plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(source)],
|
|
53
53
|
});
|
|
54
54
|
try {
|
|
55
55
|
const mod = (await server.ssrLoadModule(VIRTUAL_ID));
|
|
@@ -80,12 +80,12 @@ export function stripCairnManifest(plugins) {
|
|
|
80
80
|
/** Verify the committed manifest against the corpus from a Vite context, throwing on drift. The bin
|
|
81
81
|
* and the plugin share this; the spike proved it runs cleanly inside the consumer's config. */
|
|
82
82
|
export async function verifyManifestFromVite(opts, root) {
|
|
83
|
-
await evalVirtual(opts, 'verify', root);
|
|
83
|
+
await evalVirtual(virtualSource(opts, 'verify'), root);
|
|
84
84
|
}
|
|
85
85
|
/** Regenerate the serialized manifest from the corpus in a Vite context, sharing the build's
|
|
86
86
|
* resolution. The cairn-manifest bin (a later task) will call this and write the result. */
|
|
87
87
|
export async function buildManifestFromVite(opts, root) {
|
|
88
|
-
return evalVirtual(opts, 'write', root);
|
|
88
|
+
return evalVirtual(virtualSource(opts, 'write'), root);
|
|
89
89
|
}
|
|
90
90
|
/** The cairnManifest plugin. It serves the verify virtual module to the app graph and, in
|
|
91
91
|
* buildStart, evaluates it through a nested Vite SSR load so a manifest drift fails the build. */
|
|
@@ -161,9 +161,9 @@ function findCairnOptions(plugins) {
|
|
|
161
161
|
}
|
|
162
162
|
return null;
|
|
163
163
|
}
|
|
164
|
-
/** A minimal plugin that serves only the virtual module
|
|
164
|
+
/** A minimal plugin that serves only the given virtual module source, for the nested SSR load. It
|
|
165
165
|
* carries no buildStart, so the nested server never recurses into the verify. */
|
|
166
|
-
function cairnVirtualOnly(
|
|
166
|
+
function cairnVirtualOnly(source) {
|
|
167
167
|
return {
|
|
168
168
|
name: 'cairn-manifest-virtual',
|
|
169
169
|
resolveId(id) {
|
|
@@ -172,7 +172,51 @@ function cairnVirtualOnly(opts, mode) {
|
|
|
172
172
|
},
|
|
173
173
|
load(id) {
|
|
174
174
|
if (id === RESOLVED_ID)
|
|
175
|
-
return
|
|
175
|
+
return source;
|
|
176
176
|
},
|
|
177
177
|
};
|
|
178
178
|
}
|
|
179
|
+
/** Build the virtual module that reads only the adapter facts the doctor derives. It imports the
|
|
180
|
+
* configured config module and exports the string-typed `owner`, `repo`, and `from` as JSON, so
|
|
181
|
+
* nothing else of the adapter (least of all a secret) crosses the boundary. */
|
|
182
|
+
function adapterFactsSource(opts) {
|
|
183
|
+
return `
|
|
184
|
+
import { cairn } from ${JSON.stringify(opts.configModule)};
|
|
185
|
+
const backend = cairn?.backend ?? {};
|
|
186
|
+
const sender = cairn?.sender ?? {};
|
|
187
|
+
const facts = {};
|
|
188
|
+
if (typeof backend.owner === 'string') facts.owner = backend.owner;
|
|
189
|
+
if (typeof backend.repo === 'string') facts.repo = backend.repo;
|
|
190
|
+
if (typeof sender.from === 'string') facts.from = sender.from;
|
|
191
|
+
export const result = JSON.stringify(facts);
|
|
192
|
+
`;
|
|
193
|
+
}
|
|
194
|
+
/** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
|
|
195
|
+
* through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
|
|
196
|
+
* cairn-doctor calls this to fill inputs the operator did not pass. Derivation is best-effort:
|
|
197
|
+
* any failure (no Vite config, no cairnManifest plugin, a config module that throws) returns
|
|
198
|
+
* null, so the doctor degrades to flags instead of crashing. This runs only on the bin path,
|
|
199
|
+
* never in a Worker. */
|
|
200
|
+
export async function readAdapterFacts(cwd = process.cwd()) {
|
|
201
|
+
try {
|
|
202
|
+
const { loadConfigFromFile } = await import('vite');
|
|
203
|
+
const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, cwd, 'silent');
|
|
204
|
+
if (!loaded)
|
|
205
|
+
return null;
|
|
206
|
+
const opts = findCairnOptions(loaded.config.plugins);
|
|
207
|
+
if (!opts)
|
|
208
|
+
return null;
|
|
209
|
+
const parsed = JSON.parse(await evalVirtual(adapterFactsSource(opts), cwd));
|
|
210
|
+
const facts = {};
|
|
211
|
+
if (typeof parsed.owner === 'string')
|
|
212
|
+
facts.owner = parsed.owner;
|
|
213
|
+
if (typeof parsed.repo === 'string')
|
|
214
|
+
facts.repo = parsed.repo;
|
|
215
|
+
if (typeof parsed.from === 'string')
|
|
216
|
+
facts.from = parsed.from;
|
|
217
|
+
return facts;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
],
|
|
98
98
|
"peerDependencies": {
|
|
99
99
|
"@sveltejs/kit": "^2.12",
|
|
100
|
-
"svelte": "^5.
|
|
100
|
+
"svelte": "^5.56.3"
|
|
101
101
|
},
|
|
102
102
|
"dependencies": {
|
|
103
103
|
"@codemirror/autocomplete": "^6.20.2",
|
|
@@ -6,13 +6,16 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
6
6
|
remaining fields group in the sidebar under Details, Visibility (the draft boolean as the Hidden
|
|
7
7
|
toggle), and Address (the slug with the Change URL trigger). The toolbar's Write/Preview tabs
|
|
8
8
|
swap the editing surface for the rendered preview inside the same card; every visit lands on
|
|
9
|
-
Write.
|
|
9
|
+
Write. Preview renders inside a sandboxed iframe that links the site's own stylesheets (the
|
|
10
|
+
adapter's `preview` knob), takes the full content width (the sidebar hides until Write), and
|
|
11
|
+
sizes to a persisted device width picked from the toolbar's capsule. A sticky glass header
|
|
12
|
+
carries the breadcrumb, the status badges, the save-state indicator,
|
|
10
13
|
and the lifecycle actions: Save, Publish (riding the same form via formaction while edits are
|
|
11
14
|
pending), and an overflow menu for Discard and Delete. One feedback strip under the header carries the
|
|
12
15
|
transient flashes, and the editor card's footer holds the word count and the Markdown help.
|
|
13
16
|
-->
|
|
14
17
|
<script lang="ts">
|
|
15
|
-
import { untrack } from 'svelte';
|
|
18
|
+
import { flushSync, untrack } from 'svelte';
|
|
16
19
|
import { beforeNavigate } from '$app/navigation';
|
|
17
20
|
import { page } from '$app/state';
|
|
18
21
|
import CsrfField from './CsrfField.svelte';
|
|
@@ -26,6 +29,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
26
29
|
import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
|
|
27
30
|
import { cairnLinkCompletionSource } from './link-completion.js';
|
|
28
31
|
import { unwrapCairnLink, type FormatKind } from './markdown-format.js';
|
|
32
|
+
import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices, type PreviewDeviceId } from './preview-doc.js';
|
|
29
33
|
import { directiveLineKind, findInlineDirectives } from './markdown-directives.js';
|
|
30
34
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
31
35
|
import type { IconSet } from '../render/glyph.js';
|
|
@@ -97,6 +101,16 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
97
101
|
// The edit form element, for the Ctrl/Cmd+S shortcut's requestSubmit.
|
|
98
102
|
let editForm = $state<HTMLFormElement | null>(null);
|
|
99
103
|
|
|
104
|
+
// A required sidebar field hidden by Preview cannot take the browser's validation report: an
|
|
105
|
+
// invisible control is unfocusable, so the browser cancels the save silently with no message.
|
|
106
|
+
// This capture-phase invalid listener flips back to Write first, and flushSync forces the pane
|
|
107
|
+
// swap inside the event, so the report that follows the invalid events lands on a visible
|
|
108
|
+
// control and the author sees what blocked the save.
|
|
109
|
+
function onFormInvalid() {
|
|
110
|
+
if (mode === 'write') return;
|
|
111
|
+
flushSync(() => (mode = 'write'));
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
// The SvelteKit half of the leave guard. Registered at component init (beforeNavigate wraps
|
|
101
115
|
// onMount, so it must run synchronously here) and auto-unregistered on destroy. A submit's own
|
|
102
116
|
// navigation passes through because busy flips before it starts, and a non-edit POST's because
|
|
@@ -150,6 +164,24 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
150
164
|
let previewHtml = $state('');
|
|
151
165
|
// True after a render call threw, so the preview pane can say so instead of going blank.
|
|
152
166
|
let previewFailed = $state(false);
|
|
167
|
+
// The preview frame's device width, a per-browser preference under its own key (the legacy
|
|
168
|
+
// 'cairn-admin:preview' key from the removed split-pane preview stays untouched). Desktop is
|
|
169
|
+
// the default; the storage read sits in an effect so it never runs during SSR, and it tracks
|
|
170
|
+
// nothing reactive, so it runs once.
|
|
171
|
+
const deviceStorageKey = 'cairn-editor-preview-device';
|
|
172
|
+
let device = $state<PreviewDeviceId>('desktop');
|
|
173
|
+
$effect(() => {
|
|
174
|
+
const stored = localStorage.getItem(deviceStorageKey);
|
|
175
|
+
if (previewDevices.some((d) => d.id === stored)) device = stored as PreviewDeviceId;
|
|
176
|
+
});
|
|
177
|
+
function setDevice(id: PreviewDeviceId) {
|
|
178
|
+
device = id;
|
|
179
|
+
localStorage.setItem(deviceStorageKey, id);
|
|
180
|
+
}
|
|
181
|
+
const activeDevice = $derived(previewDevice(device));
|
|
182
|
+
// The iframe document around the rendered html: the site's stylesheets from the adapter's
|
|
183
|
+
// preview knob, or a styleless document (behind the hint below) when the site sets none.
|
|
184
|
+
const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
|
|
153
185
|
let insert = $state.raw<(text: string) => void>(() => {});
|
|
154
186
|
let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
|
|
155
187
|
// The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
|
|
@@ -388,7 +420,13 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
388
420
|
}
|
|
389
421
|
}
|
|
390
422
|
}, 150);
|
|
391
|
-
return () =>
|
|
423
|
+
return () => {
|
|
424
|
+
clearTimeout(handle);
|
|
425
|
+
// Every re-run and the final teardown invalidate the in-flight render. The entry-key reset
|
|
426
|
+
// above cannot reach this counter, so without the bump a slow render for entry A could
|
|
427
|
+
// resolve after a same-route hop and write A's html into entry B's pane.
|
|
428
|
+
previewRun++;
|
|
429
|
+
};
|
|
392
430
|
});
|
|
393
431
|
|
|
394
432
|
// Coerce a frontmatter value to a string for text/date/textarea inputs.
|
|
@@ -575,7 +613,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
575
613
|
bind:this={editForm}
|
|
576
614
|
onsubmit={onEditSubmit}
|
|
577
615
|
oninput={onFormInput}
|
|
578
|
-
|
|
616
|
+
oninvalidcapture={onFormInvalid}
|
|
617
|
+
class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6'}
|
|
579
618
|
>
|
|
580
619
|
<CsrfField />
|
|
581
620
|
{#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
|
|
@@ -602,7 +641,7 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
602
641
|
role="group"
|
|
603
642
|
aria-label="Editor"
|
|
604
643
|
>
|
|
605
|
-
<EditorToolbar {format} {mode} onMode={setMode}>
|
|
644
|
+
<EditorToolbar {format} {mode} onMode={setMode} {device} onDevice={setDevice}>
|
|
606
645
|
{#snippet insertControls()}
|
|
607
646
|
<!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
|
|
608
647
|
dialogs themselves mount outside the edit form at the bottom of this component. -->
|
|
@@ -668,16 +707,53 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
668
707
|
/>
|
|
669
708
|
</div>
|
|
670
709
|
{#if mode === 'preview'}
|
|
671
|
-
<!--
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
710
|
+
<!-- The preview ground: recessed under the floating frame card so the page reads as a
|
|
711
|
+
sheet on the desk. tabindex 0 only while a message shows in place of the iframe;
|
|
712
|
+
with the iframe up the frame itself is the pane's focusable content (the tabpanel
|
|
713
|
+
pattern's completeness requirement). -->
|
|
714
|
+
<div
|
|
715
|
+
id="cairn-pane-preview"
|
|
716
|
+
role="tabpanel"
|
|
717
|
+
aria-labelledby="cairn-tab-preview"
|
|
718
|
+
tabindex={previewHtml && !previewFailed ? undefined : 0}
|
|
719
|
+
class="bg-base-200 px-4 py-6 lg:px-8"
|
|
720
|
+
>
|
|
721
|
+
<!-- The frame column: centered, sized by the picked device (capped at the pane), with
|
|
722
|
+
the width eased; the admin sheet's prefers-reduced-motion rule squashes the move. -->
|
|
723
|
+
<div
|
|
724
|
+
class="cairn-preview-frame mx-auto max-w-full transition-[width] duration-300"
|
|
725
|
+
style:width={activeDevice.width === null ? '100%' : `${activeDevice.width}px`}
|
|
726
|
+
>
|
|
727
|
+
{#if activeDevice.width !== null}
|
|
728
|
+
<p class="mb-2 text-right text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">
|
|
729
|
+
{deviceLabel(activeDevice)}
|
|
730
|
+
</p>
|
|
731
|
+
{/if}
|
|
732
|
+
{#if !data.preview}
|
|
733
|
+
<p class="mb-2 text-xs text-[var(--color-muted)]">
|
|
734
|
+
Preview shows unstyled markup until the adapter's preview option names the site's stylesheets.
|
|
735
|
+
</p>
|
|
736
|
+
{/if}
|
|
737
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
|
|
738
|
+
{#if previewFailed}
|
|
739
|
+
<p class="p-4 text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
|
|
740
|
+
{:else if !previewHtml}
|
|
741
|
+
<p class="p-4 text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
|
|
742
|
+
{:else}
|
|
743
|
+
<!-- The site's render pipeline already sanitized the html (the floor strips
|
|
744
|
+
scripts and handlers); the empty sandbox is belt and braces on top. The
|
|
745
|
+
frame document's base tag targets every link at a new tab, which the
|
|
746
|
+
sandbox (no allow-popups) blocks, so a proofing click never navigates the
|
|
747
|
+
admin or the frame itself. tabindex 0 keeps the scrollable preview
|
|
748
|
+
keyboard-reachable (an iframe is not a sequential tab stop by itself); on
|
|
749
|
+
a link-heavy page that one inert Tab stop is a deliberate tradeoff. The
|
|
750
|
+
a11y rule reads any tabindex on a non-interactive element as a smell, but
|
|
751
|
+
a scrollable region is the recognized exception. -->
|
|
752
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
753
|
+
<iframe sandbox="" tabindex="0" title="Page preview" srcdoc={previewDoc} class="block h-[70vh] w-full"></iframe>
|
|
754
|
+
{/if}
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
681
757
|
</div>
|
|
682
758
|
{/if}
|
|
683
759
|
<!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
|
|
@@ -696,7 +772,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
696
772
|
</div>
|
|
697
773
|
</div>
|
|
698
774
|
|
|
699
|
-
|
|
775
|
+
<!-- Preview takes the full surface: the sidebar hides (never unmounts, so the uncontrolled
|
|
776
|
+
field edits survive the round trip) and the editor column above spans the whole width. -->
|
|
777
|
+
<aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
|
|
700
778
|
<!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
|
|
701
779
|
a real legend that screen readers announce with the fields it holds. -->
|
|
702
780
|
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">
|
|
@@ -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>
|
|
@@ -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. */
|