@glw907/cairn-cms 0.50.0 → 0.52.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 +61 -0
- package/dist/components/EditPage.svelte +125 -16
- package/dist/components/EditPage.svelte.d.ts +4 -1
- package/dist/components/EditorToolbar.svelte +135 -10
- package/dist/components/EditorToolbar.svelte.d.ts +19 -2
- package/dist/components/MarkdownEditor.svelte +112 -6
- package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
- package/dist/components/cairn-admin.css +69 -9
- package/dist/components/editor-highlight.d.ts +2 -0
- package/dist/components/editor-highlight.js +79 -15
- package/dist/components/editor-modes.d.ts +26 -0
- package/dist/components/editor-modes.js +92 -0
- package/dist/components/fonts/iAWriterMono-OFL.txt +100 -0
- package/dist/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
- package/dist/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
- package/dist/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
- package/dist/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
- package/dist/components/markdown-directives.d.ts +51 -0
- package/dist/components/markdown-directives.js +130 -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 +125 -16
- package/src/lib/components/EditorToolbar.svelte +135 -10
- package/src/lib/components/MarkdownEditor.svelte +112 -6
- package/src/lib/components/cairn-admin.css +95 -5
- package/src/lib/components/editor-highlight.ts +91 -14
- package/src/lib/components/editor-modes.ts +106 -0
- package/src/lib/components/fonts/iAWriterMono-OFL.txt +100 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
- package/src/lib/components/markdown-directives.ts +151 -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
|
@@ -16,12 +16,19 @@ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest,
|
|
|
16
16
|
import { isConflict } from '../github/types.js';
|
|
17
17
|
import { log } from '../log/index.js';
|
|
18
18
|
import { issueCsrfToken } from './csrf.js';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
import { requireSession } from './guard.js';
|
|
20
|
+
/** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
|
|
21
|
+
* nullish coalescing so an override key that is present but undefined keeps the top-level value.
|
|
22
|
+
* Stylesheets are always shared, and the `byConcept` map never reaches the client. */
|
|
23
|
+
function resolvePreview(preview, conceptId) {
|
|
24
|
+
if (!preview)
|
|
25
|
+
return null;
|
|
26
|
+
const override = preview.byConcept?.[conceptId];
|
|
27
|
+
return {
|
|
28
|
+
stylesheets: preview.stylesheets,
|
|
29
|
+
bodyClass: override?.bodyClass ?? preview.bodyClass,
|
|
30
|
+
containerClass: override?.containerClass ?? preview.containerClass,
|
|
31
|
+
};
|
|
25
32
|
}
|
|
26
33
|
/** Look up the concept named by the `[concept]` route param, or a 404. */
|
|
27
34
|
function conceptOf(runtime, params) {
|
|
@@ -53,7 +60,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
53
60
|
/** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
54
61
|
* and the pending entries behind the topbar's publish-all action. */
|
|
55
62
|
async function layoutLoad(event) {
|
|
56
|
-
const editor =
|
|
63
|
+
const editor = requireSession(event);
|
|
57
64
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
58
65
|
const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
59
66
|
const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
|
|
@@ -137,7 +144,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
137
144
|
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
138
145
|
* to an inline error, not a thrown 500. */
|
|
139
146
|
async function listLoad(event) {
|
|
140
|
-
|
|
147
|
+
requireSession(event);
|
|
141
148
|
const concept = conceptOf(runtime, event.params);
|
|
142
149
|
const formError = event.url.searchParams.get('error');
|
|
143
150
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
@@ -181,7 +188,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
181
188
|
}
|
|
182
189
|
/** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
|
|
183
190
|
async function createAction(event) {
|
|
184
|
-
|
|
191
|
+
requireSession(event);
|
|
185
192
|
const concept = conceptOf(runtime, event.params);
|
|
186
193
|
const form = await event.request.formData();
|
|
187
194
|
const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
|
|
@@ -228,7 +235,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
228
235
|
}
|
|
229
236
|
/** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
|
|
230
237
|
async function editLoad(event) {
|
|
231
|
-
|
|
238
|
+
requireSession(event);
|
|
232
239
|
const concept = conceptOf(runtime, event.params);
|
|
233
240
|
const id = event.params.id ?? '';
|
|
234
241
|
if (!isValidId(id))
|
|
@@ -289,6 +296,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
289
296
|
published,
|
|
290
297
|
publishedFlash: event.url.searchParams.get('published') === '1',
|
|
291
298
|
discardedFlash: event.url.searchParams.get('discarded') === '1',
|
|
299
|
+
preview: resolvePreview(runtime.preview, concept.id),
|
|
292
300
|
};
|
|
293
301
|
}
|
|
294
302
|
/** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
@@ -386,7 +394,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
386
394
|
/** Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
387
395
|
* as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
|
|
388
396
|
async function saveAction(event) {
|
|
389
|
-
const editor =
|
|
397
|
+
const editor = requireSession(event);
|
|
390
398
|
const concept = conceptOf(runtime, event.params);
|
|
391
399
|
const id = event.params.id ?? '';
|
|
392
400
|
// Confine the commit path to the concept dir, built from a validated id (the App token can
|
|
@@ -408,7 +416,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
408
416
|
* The branch is deleted only when its head still matches the commit this action made; a
|
|
409
417
|
* concurrent save moved it, so the entry stays pending and the next publish picks it up. */
|
|
410
418
|
async function publishAction(event) {
|
|
411
|
-
const editor =
|
|
419
|
+
const editor = requireSession(event);
|
|
412
420
|
const concept = conceptOf(runtime, event.params);
|
|
413
421
|
const id = event.params.id ?? '';
|
|
414
422
|
if (!isValidId(id))
|
|
@@ -442,7 +450,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
442
450
|
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
443
451
|
* concept param is ignored and the redirect lands on the first configured concept. */
|
|
444
452
|
async function publishAllAction(event) {
|
|
445
|
-
const editor =
|
|
453
|
+
const editor = requireSession(event);
|
|
446
454
|
const first = runtime.concepts[0];
|
|
447
455
|
if (!first)
|
|
448
456
|
throw error(404, 'No content types configured');
|
|
@@ -521,7 +529,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
521
529
|
/** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
522
530
|
* the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
|
|
523
531
|
async function discardAction(event) {
|
|
524
|
-
const editor =
|
|
532
|
+
const editor = requireSession(event);
|
|
525
533
|
const concept = conceptOf(runtime, event.params);
|
|
526
534
|
const id = event.params.id ?? '';
|
|
527
535
|
if (!isValidId(id))
|
|
@@ -588,7 +596,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
588
596
|
}
|
|
589
597
|
/** Delete an entry from its editor. The id comes from the route param. */
|
|
590
598
|
async function deleteAction(event) {
|
|
591
|
-
const editor =
|
|
599
|
+
const editor = requireSession(event);
|
|
592
600
|
const concept = conceptOf(runtime, event.params);
|
|
593
601
|
const id = event.params.id ?? '';
|
|
594
602
|
if (!isValidId(id))
|
|
@@ -597,7 +605,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
597
605
|
}
|
|
598
606
|
/** Delete an entry from the concept list. The id comes from the form body. */
|
|
599
607
|
async function listDeleteAction(event) {
|
|
600
|
-
const editor =
|
|
608
|
+
const editor = requireSession(event);
|
|
601
609
|
const concept = conceptOf(runtime, event.params);
|
|
602
610
|
const form = await event.request.formData();
|
|
603
611
|
const id = String(form.get('id') ?? '');
|
|
@@ -610,7 +618,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
610
618
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
611
619
|
* caught by the build's fail-closed backstop. */
|
|
612
620
|
async function renameAction(event) {
|
|
613
|
-
const editor =
|
|
621
|
+
const editor = requireSession(event);
|
|
614
622
|
const concept = conceptOf(runtime, event.params);
|
|
615
623
|
const id = event.params.id ?? '';
|
|
616
624
|
if (!isValidId(id))
|
|
@@ -2,7 +2,13 @@ import type { Editor } from '../auth/types.js';
|
|
|
2
2
|
import type { HandleInput, RequestContext } from './types.js';
|
|
3
3
|
/** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
|
|
4
4
|
export declare function createAuthGuard(): ({ event, resolve }: HandleInput) => Promise<Response>;
|
|
5
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
6
|
-
|
|
5
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
6
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
7
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
8
|
+
export declare function requireSession(event: {
|
|
9
|
+
locals: {
|
|
10
|
+
editor?: Editor | null;
|
|
11
|
+
};
|
|
12
|
+
}): Editor;
|
|
7
13
|
/** For the management surface: a signed-in owner, or 403 for an editor. */
|
|
8
14
|
export declare function requireOwner(event: RequestContext): Editor;
|
package/dist/sveltekit/guard.js
CHANGED
|
@@ -80,7 +80,9 @@ export function createAuthGuard() {
|
|
|
80
80
|
return response;
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
83
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
84
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
85
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
84
86
|
export function requireSession(event) {
|
|
85
87
|
const editor = event.locals.editor;
|
|
86
88
|
if (!editor)
|
|
@@ -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.52.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,43 @@ 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
|
+
// The writing modes (focus, typewriter), per-browser preferences on the device pick's pattern:
|
|
182
|
+
// off by default, read in an effect so SSR never touches localStorage, written by the
|
|
183
|
+
// toolbar's toggles. The effect tracks nothing reactive, so it runs once.
|
|
184
|
+
const focusStorageKey = 'cairn-editor-focus-mode';
|
|
185
|
+
const typewriterStorageKey = 'cairn-editor-typewriter';
|
|
186
|
+
let focusMode = $state(false);
|
|
187
|
+
let typewriter = $state(false);
|
|
188
|
+
$effect(() => {
|
|
189
|
+
focusMode = localStorage.getItem(focusStorageKey) === 'true';
|
|
190
|
+
typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
|
|
191
|
+
});
|
|
192
|
+
function setFocusMode(on: boolean) {
|
|
193
|
+
focusMode = on;
|
|
194
|
+
localStorage.setItem(focusStorageKey, String(on));
|
|
195
|
+
}
|
|
196
|
+
function setTypewriter(on: boolean) {
|
|
197
|
+
typewriter = on;
|
|
198
|
+
localStorage.setItem(typewriterStorageKey, String(on));
|
|
199
|
+
}
|
|
200
|
+
const activeDevice = $derived(previewDevice(device));
|
|
201
|
+
// The iframe document around the rendered html: the site's stylesheets from the adapter's
|
|
202
|
+
// preview knob, or a styleless document (behind the hint below) when the site sets none.
|
|
203
|
+
const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
|
|
153
204
|
let insert = $state.raw<(text: string) => void>(() => {});
|
|
154
205
|
let insertLink = $state.raw<(href: string, title: string) => void>(() => {});
|
|
155
206
|
// The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
|
|
@@ -388,7 +439,13 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
388
439
|
}
|
|
389
440
|
}
|
|
390
441
|
}, 150);
|
|
391
|
-
return () =>
|
|
442
|
+
return () => {
|
|
443
|
+
clearTimeout(handle);
|
|
444
|
+
// Every re-run and the final teardown invalidate the in-flight render. The entry-key reset
|
|
445
|
+
// above cannot reach this counter, so without the bump a slow render for entry A could
|
|
446
|
+
// resolve after a same-route hop and write A's html into entry B's pane.
|
|
447
|
+
previewRun++;
|
|
448
|
+
};
|
|
392
449
|
});
|
|
393
450
|
|
|
394
451
|
// Coerce a frontmatter value to a string for text/date/textarea inputs.
|
|
@@ -575,7 +632,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
575
632
|
bind:this={editForm}
|
|
576
633
|
onsubmit={onEditSubmit}
|
|
577
634
|
oninput={onFormInput}
|
|
578
|
-
|
|
635
|
+
oninvalidcapture={onFormInvalid}
|
|
636
|
+
class={mode === 'preview' ? '' : 'lg:grid lg:grid-cols-[1fr_20rem] lg:gap-6'}
|
|
579
637
|
>
|
|
580
638
|
<CsrfField />
|
|
581
639
|
{#if data.isNew}<input type="hidden" name="new" value="1" />{/if}
|
|
@@ -602,7 +660,17 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
602
660
|
role="group"
|
|
603
661
|
aria-label="Editor"
|
|
604
662
|
>
|
|
605
|
-
<EditorToolbar
|
|
663
|
+
<EditorToolbar
|
|
664
|
+
{format}
|
|
665
|
+
{mode}
|
|
666
|
+
onMode={setMode}
|
|
667
|
+
{device}
|
|
668
|
+
onDevice={setDevice}
|
|
669
|
+
{focusMode}
|
|
670
|
+
onFocusMode={setFocusMode}
|
|
671
|
+
{typewriter}
|
|
672
|
+
onTypewriter={setTypewriter}
|
|
673
|
+
>
|
|
606
674
|
{#snippet insertControls()}
|
|
607
675
|
<!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
|
|
608
676
|
dialogs themselves mount outside the edit form at the bottom of this component. -->
|
|
@@ -665,19 +733,58 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
665
733
|
registerGetSelection={(fn) => (getSelection = fn)}
|
|
666
734
|
registerFormat={(fn) => (format = fn)}
|
|
667
735
|
{completionSources}
|
|
736
|
+
{focusMode}
|
|
737
|
+
{typewriter}
|
|
668
738
|
/>
|
|
669
739
|
</div>
|
|
670
740
|
{#if mode === 'preview'}
|
|
671
|
-
<!--
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
741
|
+
<!-- The preview ground: recessed under the floating frame card so the page reads as a
|
|
742
|
+
sheet on the desk. tabindex 0 only while a message shows in place of the iframe;
|
|
743
|
+
with the iframe up the frame itself is the pane's focusable content (the tabpanel
|
|
744
|
+
pattern's completeness requirement). -->
|
|
745
|
+
<div
|
|
746
|
+
id="cairn-pane-preview"
|
|
747
|
+
role="tabpanel"
|
|
748
|
+
aria-labelledby="cairn-tab-preview"
|
|
749
|
+
tabindex={previewHtml && !previewFailed ? undefined : 0}
|
|
750
|
+
class="bg-base-200 px-4 py-6 lg:px-8"
|
|
751
|
+
>
|
|
752
|
+
<!-- The frame column: centered, sized by the picked device (capped at the pane), with
|
|
753
|
+
the width eased; the admin sheet's prefers-reduced-motion rule squashes the move. -->
|
|
754
|
+
<div
|
|
755
|
+
class="cairn-preview-frame mx-auto max-w-full transition-[width] duration-300"
|
|
756
|
+
style:width={activeDevice.width === null ? '100%' : `${activeDevice.width}px`}
|
|
757
|
+
>
|
|
758
|
+
{#if activeDevice.width !== null}
|
|
759
|
+
<p class="mb-2 text-right text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]">
|
|
760
|
+
{deviceLabel(activeDevice)}
|
|
761
|
+
</p>
|
|
762
|
+
{/if}
|
|
763
|
+
{#if !data.preview}
|
|
764
|
+
<p class="mb-2 text-xs text-[var(--color-muted)]">
|
|
765
|
+
Preview shows unstyled markup until the adapter's preview option names the site's stylesheets.
|
|
766
|
+
</p>
|
|
767
|
+
{/if}
|
|
768
|
+
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 overflow-hidden shadow-[var(--cairn-shadow)]">
|
|
769
|
+
{#if previewFailed}
|
|
770
|
+
<p class="p-4 text-sm text-[var(--color-muted)]">The preview could not render this content.</p>
|
|
771
|
+
{:else if !previewHtml}
|
|
772
|
+
<p class="p-4 text-sm text-[var(--color-muted)]">Nothing to preview yet.</p>
|
|
773
|
+
{:else}
|
|
774
|
+
<!-- The site's render pipeline already sanitized the html (the floor strips
|
|
775
|
+
scripts and handlers); the empty sandbox is belt and braces on top. The
|
|
776
|
+
frame document's base tag targets every link at a new tab, which the
|
|
777
|
+
sandbox (no allow-popups) blocks, so a proofing click never navigates the
|
|
778
|
+
admin or the frame itself. tabindex 0 keeps the scrollable preview
|
|
779
|
+
keyboard-reachable (an iframe is not a sequential tab stop by itself); on
|
|
780
|
+
a link-heavy page that one inert Tab stop is a deliberate tradeoff. The
|
|
781
|
+
a11y rule reads any tabindex on a non-interactive element as a smell, but
|
|
782
|
+
a scrollable region is the recognized exception. -->
|
|
783
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
784
|
+
<iframe sandbox="" tabindex="0" title="Page preview" srcdoc={previewDoc} class="block h-[70vh] w-full"></iframe>
|
|
785
|
+
{/if}
|
|
786
|
+
</div>
|
|
787
|
+
</div>
|
|
681
788
|
</div>
|
|
682
789
|
{/if}
|
|
683
790
|
<!-- The card footer, part of the same instrument frame. It stays up in Preview too, so the
|
|
@@ -696,7 +803,9 @@ transient flashes, and the editor card's footer holds the word count and the Mar
|
|
|
696
803
|
</div>
|
|
697
804
|
</div>
|
|
698
805
|
|
|
699
|
-
|
|
806
|
+
<!-- Preview takes the full surface: the sidebar hides (never unmounts, so the uncontrolled
|
|
807
|
+
field edits survive the round trip) and the editor column above spans the whole width. -->
|
|
808
|
+
<aside class="lg:order-2 mt-4 lg:mt-0" class:hidden={mode === 'preview'}>
|
|
700
809
|
<!-- One sidebar card, three labeled groups. Each group is its own fieldset so its eyebrow is
|
|
701
810
|
a real legend that screen readers announce with the fields it holds. -->
|
|
702
811
|
<div class="rounded-box border border-[var(--cairn-card-border)] bg-base-100 flex flex-col gap-5 p-4 shadow-[var(--cairn-shadow)]">
|