@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
package/src/lib/env.ts
CHANGED
|
@@ -7,25 +7,30 @@ import { CairnError } from './diagnostics/index.js';
|
|
|
7
7
|
* The origin is always config-derived, never read from a request header, so a
|
|
8
8
|
* forged Host header cannot redirect a magic link (spec 7.1, risk H3).
|
|
9
9
|
*
|
|
10
|
-
* @throws
|
|
10
|
+
* @throws CairnError (`config.public-origin-invalid`) when `PUBLIC_ORIGIN` is unset or
|
|
11
|
+
* empty, fails to parse as a URL, or uses http on a non-local host.
|
|
11
12
|
*/
|
|
12
13
|
export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
|
|
13
14
|
const origin = env.PUBLIC_ORIGIN;
|
|
14
15
|
if (!origin) {
|
|
15
|
-
throw new
|
|
16
|
+
throw new CairnError('config.public-origin-invalid', { message: 'PUBLIC_ORIGIN is not configured' });
|
|
16
17
|
}
|
|
17
18
|
let hostname: string;
|
|
18
19
|
try {
|
|
19
20
|
hostname = new URL(origin).hostname;
|
|
20
21
|
} catch {
|
|
21
|
-
throw new
|
|
22
|
+
throw new CairnError('config.public-origin-invalid', {
|
|
23
|
+
message: `PUBLIC_ORIGIN is not a valid URL, got ${origin}`,
|
|
24
|
+
});
|
|
22
25
|
}
|
|
23
26
|
// The magic-link origin must be https in production so the link and the __Host- cookie are
|
|
24
27
|
// origin-bound. http is allowed only for local dev on localhost or 127.0.0.1, matched exactly so
|
|
25
28
|
// a lookalike host like localhost.example.com cannot skip the https requirement.
|
|
26
29
|
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
|
|
27
30
|
if (!origin.startsWith('https://') && !isLocal) {
|
|
28
|
-
throw new
|
|
31
|
+
throw new CairnError('config.public-origin-invalid', {
|
|
32
|
+
message: `PUBLIC_ORIGIN must be https in production, got ${origin}`,
|
|
33
|
+
});
|
|
29
34
|
}
|
|
30
35
|
return origin;
|
|
31
36
|
}
|
package/src/lib/index.ts
CHANGED
|
@@ -16,8 +16,9 @@ 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
|
+
import { requireSession } from './guard.js';
|
|
19
20
|
import type { CookieJar, EventBase } from './types.js';
|
|
20
|
-
import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
|
|
21
|
+
import type { CairnRuntime, ConceptDescriptor, FrontmatterField, PreviewConfig, ResolvedPreview } from '../content/types.js';
|
|
21
22
|
import type { Editor, Role } from '../auth/types.js';
|
|
22
23
|
|
|
23
24
|
/** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
|
|
@@ -100,6 +101,10 @@ export interface EditData {
|
|
|
100
101
|
publishedFlash: boolean;
|
|
101
102
|
/** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
|
|
102
103
|
discardedFlash: boolean;
|
|
104
|
+
/** The adapter's preview knob resolved for this entry's concept (its `byConcept` override,
|
|
105
|
+
* when one exists, applied over the top-level values); null when the site sets none, which
|
|
106
|
+
* leaves the frame rendering unstyled markup behind a hint. */
|
|
107
|
+
preview: ResolvedPreview | null;
|
|
103
108
|
}
|
|
104
109
|
|
|
105
110
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
@@ -148,11 +153,17 @@ export interface RenameFailure {
|
|
|
148
153
|
* keys identify which guard refused. */
|
|
149
154
|
export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure>;
|
|
150
155
|
|
|
151
|
-
/**
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return
|
|
156
|
+
/** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
|
|
157
|
+
* nullish coalescing so an override key that is present but undefined keeps the top-level value.
|
|
158
|
+
* Stylesheets are always shared, and the `byConcept` map never reaches the client. */
|
|
159
|
+
function resolvePreview(preview: PreviewConfig | undefined, conceptId: string): ResolvedPreview | null {
|
|
160
|
+
if (!preview) return null;
|
|
161
|
+
const override = preview.byConcept?.[conceptId];
|
|
162
|
+
return {
|
|
163
|
+
stylesheets: preview.stylesheets,
|
|
164
|
+
bodyClass: override?.bodyClass ?? preview.bodyClass,
|
|
165
|
+
containerClass: override?.containerClass ?? preview.containerClass,
|
|
166
|
+
};
|
|
156
167
|
}
|
|
157
168
|
|
|
158
169
|
/** Look up the concept named by the `[concept]` route param, or a 404. */
|
|
@@ -188,7 +199,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
188
199
|
/** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
189
200
|
* and the pending entries behind the topbar's publish-all action. */
|
|
190
201
|
async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
|
|
191
|
-
const editor =
|
|
202
|
+
const editor = requireSession(event);
|
|
192
203
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
193
204
|
const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
194
205
|
const cookieCollapsed = event.cookies?.get('cairn-admin-nav-collapsed');
|
|
@@ -282,7 +293,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
282
293
|
* with no manifest row appends a `new` row read from its branch. A listing failure degrades
|
|
283
294
|
* to an inline error, not a thrown 500. */
|
|
284
295
|
async function listLoad(event: ContentEvent): Promise<ListData> {
|
|
285
|
-
|
|
296
|
+
requireSession(event);
|
|
286
297
|
const concept = conceptOf(runtime, event.params);
|
|
287
298
|
const formError = event.url.searchParams.get('error');
|
|
288
299
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
@@ -333,7 +344,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
333
344
|
|
|
334
345
|
/** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
|
|
335
346
|
async function createAction(event: ContentEvent): Promise<never> {
|
|
336
|
-
|
|
347
|
+
requireSession(event);
|
|
337
348
|
const concept = conceptOf(runtime, event.params);
|
|
338
349
|
const form = await event.request.formData();
|
|
339
350
|
const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
|
|
@@ -378,7 +389,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
378
389
|
|
|
379
390
|
/** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
|
|
380
391
|
async function editLoad(event: ContentEvent): Promise<EditData> {
|
|
381
|
-
|
|
392
|
+
requireSession(event);
|
|
382
393
|
const concept = conceptOf(runtime, event.params);
|
|
383
394
|
const id = event.params.id ?? '';
|
|
384
395
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -440,6 +451,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
440
451
|
published,
|
|
441
452
|
publishedFlash: event.url.searchParams.get('published') === '1',
|
|
442
453
|
discardedFlash: event.url.searchParams.get('discarded') === '1',
|
|
454
|
+
preview: resolvePreview(runtime.preview, concept.id),
|
|
443
455
|
};
|
|
444
456
|
}
|
|
445
457
|
|
|
@@ -578,7 +590,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
578
590
|
/** Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
579
591
|
* as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
|
|
580
592
|
async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
581
|
-
const editor =
|
|
593
|
+
const editor = requireSession(event);
|
|
582
594
|
const concept = conceptOf(runtime, event.params);
|
|
583
595
|
const id = event.params.id ?? '';
|
|
584
596
|
// Confine the commit path to the concept dir, built from a validated id (the App token can
|
|
@@ -599,7 +611,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
599
611
|
* The branch is deleted only when its head still matches the commit this action made; a
|
|
600
612
|
* concurrent save moved it, so the entry stays pending and the next publish picks it up. */
|
|
601
613
|
async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
602
|
-
const editor =
|
|
614
|
+
const editor = requireSession(event);
|
|
603
615
|
const concept = conceptOf(runtime, event.params);
|
|
604
616
|
const id = event.params.id ?? '';
|
|
605
617
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -638,7 +650,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
638
650
|
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
639
651
|
* concept param is ignored and the redirect lands on the first configured concept. */
|
|
640
652
|
async function publishAllAction(event: ContentEvent): Promise<never> {
|
|
641
|
-
const editor =
|
|
653
|
+
const editor = requireSession(event);
|
|
642
654
|
const first = runtime.concepts[0];
|
|
643
655
|
if (!first) throw error(404, 'No content types configured');
|
|
644
656
|
const token = await mintToken(event.platform?.env ?? {});
|
|
@@ -725,7 +737,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
725
737
|
/** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
726
738
|
* the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
|
|
727
739
|
async function discardAction(event: ContentEvent): Promise<never> {
|
|
728
|
-
const editor =
|
|
740
|
+
const editor = requireSession(event);
|
|
729
741
|
const concept = conceptOf(runtime, event.params);
|
|
730
742
|
const id = event.params.id ?? '';
|
|
731
743
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -806,7 +818,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
806
818
|
|
|
807
819
|
/** Delete an entry from its editor. The id comes from the route param. */
|
|
808
820
|
async function deleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
809
|
-
const editor =
|
|
821
|
+
const editor = requireSession(event);
|
|
810
822
|
const concept = conceptOf(runtime, event.params);
|
|
811
823
|
const id = event.params.id ?? '';
|
|
812
824
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -815,7 +827,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
815
827
|
|
|
816
828
|
/** Delete an entry from the concept list. The id comes from the form body. */
|
|
817
829
|
async function listDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
818
|
-
const editor =
|
|
830
|
+
const editor = requireSession(event);
|
|
819
831
|
const concept = conceptOf(runtime, event.params);
|
|
820
832
|
const form = await event.request.formData();
|
|
821
833
|
const id = String(form.get('id') ?? '');
|
|
@@ -828,7 +840,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
828
840
|
* are the authoritative gate. The same last-writer-wins manifest race as save and delete applies,
|
|
829
841
|
* caught by the build's fail-closed backstop. */
|
|
830
842
|
async function renameAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
831
|
-
const editor =
|
|
843
|
+
const editor = requireSession(event);
|
|
832
844
|
const concept = conceptOf(runtime, event.params);
|
|
833
845
|
const id = event.params.id ?? '';
|
|
834
846
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
@@ -93,8 +93,10 @@ export function createAuthGuard() {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
97
|
-
|
|
96
|
+
/** For a protected load/action: the session the guard already resolved, or a login redirect.
|
|
97
|
+
* The parameter is the minimal structural need (just `locals`), so every engine event shape
|
|
98
|
+
* (RequestContext, the content routes' ContentEvent) and a real RequestEvent all satisfy it. */
|
|
99
|
+
export function requireSession(event: { locals: { editor?: Editor | null } }): Editor {
|
|
98
100
|
const editor = event.locals.editor;
|
|
99
101
|
if (!editor) throw redirect(303, '/admin/login');
|
|
100
102
|
return editor;
|
|
@@ -8,9 +8,9 @@ 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, type NavNode } from '../nav/site-config.js';
|
|
11
|
+
import { requireSession } from './guard.js';
|
|
11
12
|
import type { CairnRuntime } from '../content/types.js';
|
|
12
13
|
import type { ContentEvent } from './content-routes.js';
|
|
13
|
-
import type { Editor } from '../auth/types.js';
|
|
14
14
|
|
|
15
15
|
/** One page option for the URL picker datalist. */
|
|
16
16
|
export interface NavPageOption {
|
|
@@ -34,13 +34,6 @@ export interface NavRoutesDeps {
|
|
|
34
34
|
mintToken?: (env: GithubKeyEnv) => string | Promise<string>;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** The signed-in editor the guard resolved, or a login redirect. */
|
|
38
|
-
function sessionOf(event: ContentEvent): Editor {
|
|
39
|
-
const editor = event.locals.editor;
|
|
40
|
-
if (!editor) throw redirect(303, '/admin/login');
|
|
41
|
-
return editor;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
37
|
export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
|
|
45
38
|
const mintToken =
|
|
46
39
|
deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
@@ -63,7 +56,7 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
|
|
|
63
56
|
|
|
64
57
|
/** Load the nav editor. A missing or unparsable config degrades to an empty tree so it still opens. */
|
|
65
58
|
async function navLoad(event: ContentEvent): Promise<NavLoadData> {
|
|
66
|
-
|
|
59
|
+
requireSession(event);
|
|
67
60
|
const config = runtime.navMenu;
|
|
68
61
|
if (!config) throw error(404, 'No navigation menu configured');
|
|
69
62
|
const maxDepth = config.maxDepth ?? 2;
|
|
@@ -109,7 +102,7 @@ export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {})
|
|
|
109
102
|
|
|
110
103
|
/** Save the nav tree: validate, then read-modify-commit the one menu with the session editor as author. */
|
|
111
104
|
async function navSave(event: ContentEvent): Promise<never> {
|
|
112
|
-
const editor =
|
|
105
|
+
const editor = requireSession(event);
|
|
113
106
|
const config = runtime.navMenu;
|
|
114
107
|
if (!config) throw error(404, 'No navigation menu configured');
|
|
115
108
|
const maxDepth = config.maxDepth ?? 2;
|
package/src/lib/vite/index.ts
CHANGED
|
@@ -61,21 +61,17 @@ export const result = ${resultExpr};
|
|
|
61
61
|
`;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
/** Evaluate
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
async function evalVirtual(
|
|
70
|
-
opts: CairnManifestOptions,
|
|
71
|
-
mode: 'verify' | 'write',
|
|
72
|
-
root: string,
|
|
73
|
-
): Promise<string> {
|
|
64
|
+
/** Evaluate a virtual module source inside the consumer's own Vite resolution, then return the
|
|
65
|
+
* module's `result`. It reuses the consumer's loaded config (so `$lib`, the config module,
|
|
66
|
+
* `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the cairnManifest
|
|
67
|
+
* plugin from the nested server's plugin list, so its buildStart never recurses. This runs at
|
|
68
|
+
* build time and in the bins, never in the request lifecycle. */
|
|
69
|
+
async function evalVirtual(source: string, root: string): Promise<string> {
|
|
74
70
|
const { createServer, loadConfigFromFile } = await import('vite');
|
|
75
71
|
// Load the consumer's real Vite config so the nested server inherits SvelteKit's resolution
|
|
76
72
|
// (the $lib alias, the app root, the ?raw and import.meta.glob handling). Drop cairnManifest from
|
|
77
73
|
// it so the nested server's buildStart does not recurse, and add a plugin that serves only the
|
|
78
|
-
// virtual module
|
|
74
|
+
// given virtual module source.
|
|
79
75
|
const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, root);
|
|
80
76
|
const inlineConfig = loaded?.config ?? {};
|
|
81
77
|
const server = await createServer({
|
|
@@ -84,7 +80,7 @@ async function evalVirtual(
|
|
|
84
80
|
configFile: false,
|
|
85
81
|
logLevel: 'silent',
|
|
86
82
|
server: { middlewareMode: true, hmr: false, watch: null },
|
|
87
|
-
plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(
|
|
83
|
+
plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(source)],
|
|
88
84
|
});
|
|
89
85
|
try {
|
|
90
86
|
const mod = (await server.ssrLoadModule(VIRTUAL_ID)) as { result: string };
|
|
@@ -115,13 +111,13 @@ export function stripCairnManifest(plugins: PluginOption | PluginOption[]): Plug
|
|
|
115
111
|
/** Verify the committed manifest against the corpus from a Vite context, throwing on drift. The bin
|
|
116
112
|
* and the plugin share this; the spike proved it runs cleanly inside the consumer's config. */
|
|
117
113
|
export async function verifyManifestFromVite(opts: CairnManifestOptions, root: string): Promise<void> {
|
|
118
|
-
await evalVirtual(opts, 'verify', root);
|
|
114
|
+
await evalVirtual(virtualSource(opts, 'verify'), root);
|
|
119
115
|
}
|
|
120
116
|
|
|
121
117
|
/** Regenerate the serialized manifest from the corpus in a Vite context, sharing the build's
|
|
122
118
|
* resolution. The cairn-manifest bin (a later task) will call this and write the result. */
|
|
123
119
|
export async function buildManifestFromVite(opts: CairnManifestOptions, root: string): Promise<string> {
|
|
124
|
-
return evalVirtual(opts, 'write', root);
|
|
120
|
+
return evalVirtual(virtualSource(opts, 'write'), root);
|
|
125
121
|
}
|
|
126
122
|
|
|
127
123
|
/** The cairnManifest plugin. It serves the verify virtual module to the app graph and, in
|
|
@@ -198,16 +194,74 @@ function findCairnOptions(plugins: unknown): CairnManifestOptions | null {
|
|
|
198
194
|
return null;
|
|
199
195
|
}
|
|
200
196
|
|
|
201
|
-
/** A minimal plugin that serves only the virtual module
|
|
197
|
+
/** A minimal plugin that serves only the given virtual module source, for the nested SSR load. It
|
|
202
198
|
* carries no buildStart, so the nested server never recurses into the verify. */
|
|
203
|
-
function cairnVirtualOnly(
|
|
199
|
+
function cairnVirtualOnly(source: string): Plugin {
|
|
204
200
|
return {
|
|
205
201
|
name: 'cairn-manifest-virtual',
|
|
206
202
|
resolveId(id) {
|
|
207
203
|
if (id === VIRTUAL_ID) return RESOLVED_ID;
|
|
208
204
|
},
|
|
209
205
|
load(id) {
|
|
210
|
-
if (id === RESOLVED_ID) return
|
|
206
|
+
if (id === RESOLVED_ID) return source;
|
|
211
207
|
},
|
|
212
208
|
};
|
|
213
209
|
}
|
|
210
|
+
|
|
211
|
+
/** The repo and sender facts cairn-doctor derives off the consumer's adapter. */
|
|
212
|
+
export interface AdapterFacts {
|
|
213
|
+
/** `cairn.backend.owner`. */
|
|
214
|
+
owner?: string;
|
|
215
|
+
/** `cairn.backend.repo`. */
|
|
216
|
+
repo?: string;
|
|
217
|
+
/** `cairn.sender.from`. */
|
|
218
|
+
from?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Build the virtual module that reads only the adapter facts the doctor derives. It imports the
|
|
222
|
+
* configured config module and exports the string-typed `owner`, `repo`, and `from` as JSON, so
|
|
223
|
+
* nothing else of the adapter (least of all a secret) crosses the boundary. */
|
|
224
|
+
function adapterFactsSource(opts: CairnManifestOptions): string {
|
|
225
|
+
return `
|
|
226
|
+
import { cairn } from ${JSON.stringify(opts.configModule)};
|
|
227
|
+
const backend = cairn?.backend ?? {};
|
|
228
|
+
const sender = cairn?.sender ?? {};
|
|
229
|
+
const facts = {};
|
|
230
|
+
if (typeof backend.owner === 'string') facts.owner = backend.owner;
|
|
231
|
+
if (typeof backend.repo === 'string') facts.repo = backend.repo;
|
|
232
|
+
if (typeof sender.from === 'string') facts.from = sender.from;
|
|
233
|
+
export const result = JSON.stringify(facts);
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Read `{ owner, repo, from }` off the consumer's adapter by evaluating a tiny virtual module
|
|
238
|
+
* through the consumer's own Vite resolution, the same machinery the cairn-manifest bin uses.
|
|
239
|
+
* cairn-doctor calls this to fill inputs the operator did not pass. Derivation is best-effort:
|
|
240
|
+
* any failure (no Vite config, no cairnManifest plugin, a config module that throws) returns
|
|
241
|
+
* null, so the doctor degrades to flags instead of crashing. This runs only on the bin path,
|
|
242
|
+
* never in a Worker. */
|
|
243
|
+
export async function readAdapterFacts(cwd: string = process.cwd()): Promise<AdapterFacts | null> {
|
|
244
|
+
try {
|
|
245
|
+
const { loadConfigFromFile } = await import('vite');
|
|
246
|
+
const loaded = await loadConfigFromFile(
|
|
247
|
+
{ command: 'build', mode: 'production' },
|
|
248
|
+
undefined,
|
|
249
|
+
cwd,
|
|
250
|
+
'silent',
|
|
251
|
+
);
|
|
252
|
+
if (!loaded) return null;
|
|
253
|
+
const opts = findCairnOptions(loaded.config.plugins);
|
|
254
|
+
if (!opts) return null;
|
|
255
|
+
const parsed = JSON.parse(await evalVirtual(adapterFactsSource(opts), cwd)) as Record<
|
|
256
|
+
string,
|
|
257
|
+
unknown
|
|
258
|
+
>;
|
|
259
|
+
const facts: AdapterFacts = {};
|
|
260
|
+
if (typeof parsed.owner === 'string') facts.owner = parsed.owner;
|
|
261
|
+
if (typeof parsed.repo === 'string') facts.repo = parsed.repo;
|
|
262
|
+
if (typeof parsed.from === 'string') facts.from = parsed.from;
|
|
263
|
+
return facts;
|
|
264
|
+
} catch {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|