@glw907/cairn-cms 0.21.0 → 0.26.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.
Files changed (75) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +50 -37
  3. package/dist/content/compose.d.ts +15 -4
  4. package/dist/content/compose.d.ts.map +1 -1
  5. package/dist/content/compose.js +9 -4
  6. package/dist/content/concepts.d.ts.map +1 -1
  7. package/dist/content/concepts.js +7 -0
  8. package/dist/content/frontmatter.d.ts +8 -0
  9. package/dist/content/frontmatter.d.ts.map +1 -1
  10. package/dist/content/frontmatter.js +19 -0
  11. package/dist/content/manifest.d.ts +20 -3
  12. package/dist/content/manifest.d.ts.map +1 -1
  13. package/dist/content/manifest.js +49 -6
  14. package/dist/content/types.d.ts +6 -0
  15. package/dist/content/types.d.ts.map +1 -1
  16. package/dist/content/validate.d.ts +4 -1
  17. package/dist/content/validate.d.ts.map +1 -1
  18. package/dist/content/validate.js +12 -2
  19. package/dist/delivery/content-index.d.ts +11 -0
  20. package/dist/delivery/content-index.d.ts.map +1 -1
  21. package/dist/delivery/content-index.js +7 -0
  22. package/dist/delivery/data.d.ts +24 -0
  23. package/dist/delivery/data.d.ts.map +1 -0
  24. package/dist/delivery/data.js +18 -0
  25. package/dist/delivery/head.d.ts +2 -0
  26. package/dist/delivery/head.d.ts.map +1 -0
  27. package/dist/delivery/head.js +4 -0
  28. package/dist/delivery/index.d.ts +1 -24
  29. package/dist/delivery/index.d.ts.map +1 -1
  30. package/dist/delivery/index.js +5 -21
  31. package/dist/index.d.ts +7 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -2
  34. package/dist/render/pipeline.d.ts +6 -2
  35. package/dist/render/pipeline.d.ts.map +1 -1
  36. package/dist/render/pipeline.js +5 -2
  37. package/dist/render/registry.d.ts +1 -1
  38. package/dist/render/registry.d.ts.map +1 -1
  39. package/dist/render/rehype-dispatch.d.ts +5 -0
  40. package/dist/render/rehype-dispatch.d.ts.map +1 -1
  41. package/dist/render/rehype-dispatch.js +12 -1
  42. package/dist/render/remark-directives.d.ts.map +1 -1
  43. package/dist/render/remark-directives.js +15 -6
  44. package/dist/render/sanitize-schema.d.ts +4 -3
  45. package/dist/render/sanitize-schema.d.ts.map +1 -1
  46. package/dist/render/sanitize-schema.js +6 -5
  47. package/dist/sveltekit/public-routes.d.ts +1 -0
  48. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  49. package/dist/sveltekit/public-routes.js +1 -1
  50. package/dist/vite/bin.d.ts +3 -0
  51. package/dist/vite/bin.d.ts.map +1 -0
  52. package/dist/vite/bin.js +9 -0
  53. package/dist/vite/index.d.ts +33 -0
  54. package/dist/vite/index.d.ts.map +1 -0
  55. package/dist/vite/index.js +178 -0
  56. package/package.json +26 -4
  57. package/src/lib/content/compose.ts +18 -9
  58. package/src/lib/content/concepts.ts +9 -0
  59. package/src/lib/content/frontmatter.ts +21 -0
  60. package/src/lib/content/manifest.ts +63 -7
  61. package/src/lib/content/types.ts +6 -0
  62. package/src/lib/content/validate.ts +10 -2
  63. package/src/lib/delivery/content-index.ts +17 -0
  64. package/src/lib/delivery/data.ts +26 -0
  65. package/src/lib/delivery/head.ts +4 -0
  66. package/src/lib/delivery/index.ts +5 -29
  67. package/src/lib/index.ts +10 -1
  68. package/src/lib/render/pipeline.ts +11 -3
  69. package/src/lib/render/registry.ts +1 -1
  70. package/src/lib/render/rehype-dispatch.ts +12 -1
  71. package/src/lib/render/remark-directives.ts +16 -5
  72. package/src/lib/render/sanitize-schema.ts +6 -5
  73. package/src/lib/sveltekit/public-routes.ts +2 -1
  74. package/src/lib/vite/bin.ts +10 -0
  75. package/src/lib/vite/index.ts +213 -0
@@ -13,7 +13,7 @@ import { buildSanitizeSchema, rehypeAnchorRel } from './sanitize-schema.js';
13
13
  import { remarkDirectiveStamp } from './remark-directives.js';
14
14
  import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
15
15
  import { rehypeDispatch } from './rehype-dispatch.js';
16
- import type { ComponentRegistry } from './registry.js';
16
+ import { defineRegistry, type ComponentRegistry } from './registry.js';
17
17
  import type { LinkResolve } from '../content/links.js';
18
18
 
19
19
  export interface RendererOptions {
@@ -30,12 +30,19 @@ export interface RendererOptions {
30
30
  * vector the floor closes, so it is only for a site whose content is fully developer-controlled.
31
31
  * It is a code-level adapter decision, never an editor-facing setting. */
32
32
  unsafeDisableSanitize?: boolean;
33
+ /** The `rel` value forced on every `target="_blank"` anchor, applied last so it also covers
34
+ * component-built anchors. Defaults to `'noopener noreferrer'`. Set a different string to change
35
+ * it, or `false` to disable the injection (a site that owns its own anchor hardening). */
36
+ anchorRel?: string | false;
33
37
  }
34
38
 
35
39
  /** Compose a site's render pipeline from its component registry: directive syntax to
36
40
  * stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
37
41
  * rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
38
- export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
42
+ export function createRenderer(
43
+ registry: ComponentRegistry = defineRegistry({ components: [] }),
44
+ options: RendererOptions = {},
45
+ ) {
39
46
  const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry], remarkResolveCairnLinks];
40
47
  // The sanitize floor runs after rehype-raw (so author raw HTML is parsed, then cleaned) and
41
48
  // before the dispatch (so the site's trusted build() output and its inline SVG icons are never
@@ -43,13 +50,14 @@ export function createRenderer(registry: ComponentRegistry, options: RendererOpt
43
50
  const floor: PluggableList = options.unsafeDisableSanitize
44
51
  ? []
45
52
  : [[rehypeSanitize, buildSanitizeSchema(registry, options.sanitizeSchema)]];
53
+ const rel = options.anchorRel ?? 'noopener noreferrer';
46
54
  const rehypePlugins: PluggableList = [
47
55
  rehypeRaw,
48
56
  ...floor,
49
57
  [rehypeDispatch, registry, options.stagger],
50
58
  rehypeSlug,
51
- rehypeAnchorRel,
52
59
  ];
60
+ if (rel !== false) rehypePlugins.push([rehypeAnchorRel, rel]);
53
61
  const processor = unified()
54
62
  .use(remarkParse)
55
63
  .use(remarkGfm)
@@ -19,7 +19,7 @@ export interface AttributeField {
19
19
  /** Initial value; a string for text/select/icon, a boolean for boolean. */
20
20
  default?: string | boolean;
21
21
  /** Allowed values for `type: 'select'`. */
22
- options?: string[];
22
+ options?: readonly string[];
23
23
  /** Helper text shown under the field. */
24
24
  help?: string;
25
25
  }
@@ -7,7 +7,7 @@ export function isElement(node: ElementContent | undefined): node is Element {
7
7
  }
8
8
 
9
9
  // hast Properties values are PropertyValue (string | number | boolean | array | null).
10
- // Directive markers (dataIcon/dataRole/dataPrimitive) are always stamped as strings;
10
+ // Directive markers (dataPrimitive/dataRole/dataAttr<Key>) are always stamped as strings;
11
11
  // this reads them back with that guarantee instead of casting at each call site.
12
12
  export function strProp(node: Element, name: string): string | undefined {
13
13
  const value = node.properties?.[name];
@@ -28,6 +28,17 @@ export function cardShell(classes: string[], body: ElementContent[]): Element {
28
28
  return h('section', { className: classes }, [h('div', { className: ['card-body'] }, body)]);
29
29
  }
30
30
 
31
+ /** Card head row: `<div class="ec-head">[icon]<h2 class="card-title">{title}</h2></div>`.
32
+ * Pass the title's inline children and an optional pre-built icon element, the way `cardShell`
33
+ * takes already-built body content. This factors the icon-plus-heading head that a titled
34
+ * component build would otherwise rebuild by hand (the shape the removed `splitHead` produced). */
35
+ export function headRow(title: ElementContent[], icon?: Element): Element {
36
+ const children: ElementContent[] = [];
37
+ if (icon) children.push(icon);
38
+ children.push(h('h2', { className: ['card-title'] }, title));
39
+ return h('div', { className: ['ec-head'] }, children);
40
+ }
41
+
31
42
  /** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
32
43
  * text nodes so the bare list serializes without newlines. Returns that <ul>. */
33
44
  export function markFirstList(children: ElementContent[]): Element | undefined {
@@ -59,17 +59,22 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
59
59
  const def = registry.get(node.name);
60
60
  const attrs = node.attributes ?? {};
61
61
  const role = attrs.role || undefined;
62
- let icon = attrs.icon || undefined;
62
+ const iconField = def?.attributes?.find((field) => field.type === 'icon');
63
+ const iconKey = iconField?.key ?? 'icon';
64
+ let icon = attrs[iconKey] || undefined;
63
65
  if (!icon && role) icon = registry.defaultIcon(node.name, role);
64
66
 
65
67
  const properties: Record<string, string> = { dataPrimitive: node.name };
66
- if (icon) properties.dataIcon = icon;
67
68
  if (role) properties.dataRole = role;
68
69
  // Carry every declared attribute to hast so the dispatch partitioner can build the
69
- // component context. data-attr-<key> survives to the element; build() consumes it and
70
- // returns a fresh element, so the marker never reaches the published DOM.
70
+ // component context. The icon attribute uses the already-resolved `icon` (the author value
71
+ // coerced through the same empty-is-absent rule above, or the defaultIconByRole default), so
72
+ // a role default reaches the build through the one declared path and a blank `icon=` falls
73
+ // back to that default the same way a missing one does. data-attr-<key> survives to the
74
+ // element; build() consumes it and returns a fresh element, so the marker never reaches the
75
+ // published DOM.
71
76
  for (const field of def?.attributes ?? []) {
72
- const raw = attrs[field.key];
77
+ const raw = field === iconField ? icon : attrs[field.key];
73
78
  if (raw != null) properties[dataAttrProp(field.key)] = raw;
74
79
  }
75
80
 
@@ -91,6 +96,12 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
91
96
  markSlot(child, (child as { name: string }).name);
92
97
  }
93
98
  }
99
+
100
+ // A directive [label] that the component has no `title` slot to claim would otherwise fall
101
+ // through as body content and render as a stray paragraph. Drop it.
102
+ if (!slotNames.has('title')) {
103
+ node.children = node.children.filter((child) => !isDirectiveLabel(child)) as typeof node.children;
104
+ }
94
105
  });
95
106
 
96
107
  visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
@@ -5,7 +5,7 @@ import { dataAttrProp, type ComponentRegistry } from './registry.js';
5
5
 
6
6
  // The fixed directive markers the stamp writes and the dispatch reads. They are inert data
7
7
  // attributes, never a script vector, and must survive the floor so the dispatch still runs.
8
- const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataIcon', 'dataRole', 'dataRise'];
8
+ const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataRole', 'dataRise'];
9
9
 
10
10
  /**
11
11
  * Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
@@ -51,15 +51,16 @@ export function buildSanitizeSchema(
51
51
  }
52
52
 
53
53
  /**
54
- * Force rel="noopener noreferrer" on every target="_blank" anchor, to prevent reverse-tabnabbing.
54
+ * Force a `rel` value on every target="_blank" anchor, to prevent reverse-tabnabbing.
55
55
  * hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
56
- * DOMPurify preview pass enforced, now on the delivered output as well.
56
+ * DOMPurify preview pass enforced, now on the delivered output as well. The value is the renderer's
57
+ * `anchorRel` option (default `noopener noreferrer`); a site can override it or disable it entirely.
57
58
  */
58
- export function rehypeAnchorRel() {
59
+ export function rehypeAnchorRel(rel: string) {
59
60
  return (tree: Root) => {
60
61
  visit(tree, 'element', (node: Element) => {
61
62
  if (node.tagName === 'a' && node.properties?.target === '_blank') {
62
- node.properties.rel = 'noopener noreferrer';
63
+ node.properties.rel = rel;
63
64
  }
64
65
  });
65
66
  };
@@ -45,6 +45,7 @@ export interface TagIndexData {
45
45
 
46
46
  /** One entry's data: the detail entry, its rendered html, and its canonical URL. */
47
47
  export interface EntryData {
48
+ concept: string;
48
49
  entry: ContentEntry;
49
50
  html: string;
50
51
  canonicalUrl: string;
@@ -87,7 +88,7 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
87
88
  ...(fields.author ? { author: fields.author } : {}),
88
89
  ...(entry.date ? { feeds } : {}),
89
90
  });
90
- return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
91
+ return { concept: entry.concept, entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
91
92
  }
92
93
 
93
94
  /** The chronological archive for one concept: every non-draft summary, newest-first. */
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // cairn-manifest: the regenerate command. It evaluates the cairnManifest virtual module in write mode
3
+ // through the consumer's own Vite resolution and writes the canonical content manifest. A thin shell
4
+ // over writeManifest so the write logic stays testable apart from the CLI.
5
+ import { writeManifest } from './index.js';
6
+
7
+ writeManifest(process.cwd()).catch((err) => {
8
+ console.error(err instanceof Error ? err.message : String(err));
9
+ process.exit(1);
10
+ });
@@ -0,0 +1,213 @@
1
+ // cairn-cms: the cairnManifest Vite plugin (@glw907/cairn-cms/vite). It owns a virtual module that
2
+ // runs import.meta.glob over the content dirs inside the app's own Vite graph, builds the manifest
3
+ // with the engine builder, and verifies it against the committed file. The verify runs in the
4
+ // plugin's buildStart hook through a nested Vite SSR module load, so a drift throws there and fails
5
+ // the build as a hard build error, outside the prerender request lifecycle (where handleHttpError
6
+ // could downgrade it). The same virtual module in write mode produces the serialized manifest, which
7
+ // the cairn-manifest bin uses to regenerate. See the design spec, locked decision 1.
8
+ import type { Plugin, PluginOption } from 'vite';
9
+ import { writeFile, mkdir } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+
12
+ /** The key the cairnManifest plugin stashes its options under, so the write path can read them off the
13
+ * plugin instance in the consumer's loaded config without re-parsing the config file. */
14
+ const CAIRN_OPTIONS = Symbol.for('cairn-cms.manifest-options');
15
+
16
+ /** A cairnManifest plugin instance with its options stashed for the write path to read. */
17
+ type CairnManifestPlugin = Plugin & { [CAIRN_OPTIONS]?: CairnManifestOptions };
18
+
19
+ /** Options for {@link cairnManifest}. Paths are app-root-absolute (the form `import.meta.glob` wants),
20
+ * so they match the build's own resolution. */
21
+ export interface CairnManifestOptions {
22
+ /** The module exporting the `cairn` adapter and the parsed `siteConfig`, app-root-absolute. */
23
+ configModule: string;
24
+ /** Per-concept content globs, keyed by concept id, app-root-absolute. */
25
+ content: Record<string, string>;
26
+ /** The committed manifest path, app-root-absolute. Defaults to `/src/content/.cairn/index.json`. */
27
+ manifestPath?: string;
28
+ }
29
+
30
+ const VIRTUAL_ID = 'virtual:cairn-manifest';
31
+ const RESOLVED_ID = '\0' + VIRTUAL_ID;
32
+
33
+ /** The default committed manifest path, app-root-absolute. */
34
+ const DEFAULT_MANIFEST_PATH = '/src/content/.cairn/index.json';
35
+
36
+ /** Build the virtual module source. In verify mode it throws on drift; in write mode it exports the
37
+ * serialized manifest as `result`. The module runs in the app graph, so its `import.meta.glob`,
38
+ * package, and `?raw` resolution is the build's own. */
39
+ function virtualSource(opts: CairnManifestOptions, mode: 'verify' | 'write'): string {
40
+ const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH;
41
+ const globEntries = Object.entries(opts.content)
42
+ .map(
43
+ ([id, pattern]) =>
44
+ ` ${JSON.stringify(id)}: import.meta.glob(${JSON.stringify(pattern)}, { query: '?raw', import: 'default', eager: true }),`,
45
+ )
46
+ .join('\n');
47
+ // In write mode the committed file may not exist yet, so do not import it.
48
+ const committedImport = mode === 'verify' ? `import committed from ${JSON.stringify(manifestPath + '?raw')};` : '';
49
+ const resultExpr =
50
+ mode === 'write' ? 'serializeManifest(built)' : '(verifyManifest(built, committed), "ok")';
51
+ return `
52
+ import { buildSiteManifest } from '@glw907/cairn-cms/delivery/data';
53
+ import { serializeManifest, verifyManifest } from '@glw907/cairn-cms';
54
+ import { cairn, siteConfig } from ${JSON.stringify(opts.configModule)};
55
+ ${committedImport}
56
+ const globs = {
57
+ ${globEntries}
58
+ };
59
+ const built = buildSiteManifest(cairn, siteConfig, globs);
60
+ export const result = ${resultExpr};
61
+ `;
62
+ }
63
+
64
+ /** Evaluate the virtual module in the given mode inside the consumer's own Vite resolution, then
65
+ * return the module's `result`. It reuses the consumer's loaded config (so `$lib`, the config
66
+ * module, `import.meta.glob`, and `?raw` resolve exactly as the build does) and strips the
67
+ * cairnManifest plugin from the nested server's plugin list, so its buildStart never recurses.
68
+ * This runs at build time and in the bin, never in the request lifecycle. */
69
+ async function evalVirtual(
70
+ opts: CairnManifestOptions,
71
+ mode: 'verify' | 'write',
72
+ root: string,
73
+ ): Promise<string> {
74
+ const { createServer, loadConfigFromFile } = await import('vite');
75
+ // Load the consumer's real Vite config so the nested server inherits SvelteKit's resolution
76
+ // (the $lib alias, the app root, the ?raw and import.meta.glob handling). Drop cairnManifest from
77
+ // it so the nested server's buildStart does not recurse, and add a plugin that serves only the
78
+ // virtual module in the requested mode.
79
+ const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, root);
80
+ const inlineConfig = loaded?.config ?? {};
81
+ const server = await createServer({
82
+ ...inlineConfig,
83
+ root,
84
+ configFile: false,
85
+ logLevel: 'silent',
86
+ server: { middlewareMode: true, hmr: false, watch: null },
87
+ plugins: [...stripCairnManifest(inlineConfig.plugins ?? []), cairnVirtualOnly(opts, mode)],
88
+ });
89
+ try {
90
+ const mod = (await server.ssrLoadModule(VIRTUAL_ID)) as { result: string };
91
+ return mod.result;
92
+ } finally {
93
+ await server.close();
94
+ }
95
+ }
96
+
97
+ /** True for any plugin object whose name is the cairnManifest plugin, so the nested server drops it
98
+ * and cannot recurse into another buildStart. The consumer's plugin list may nest arrays and hold
99
+ * falsy slots, so guard the shape. */
100
+ function isCairnManifestPlugin(p: unknown): boolean {
101
+ return !!p && typeof p === 'object' && 'name' in p && (p as { name?: unknown }).name === 'cairn-manifest';
102
+ }
103
+
104
+ /** Flatten the consumer's plugins option and drop the cairnManifest plugin at any nesting depth, so
105
+ * the nested verify server can never re-enter its buildStart. Vite supports (and flattens) nested
106
+ * plugin arrays, and findCairnOptions recurses into them, so a flat single-level filter would miss a
107
+ * cairnManifest nested inside a shared preset's sub-array and let it survive into the nested server.
108
+ * This mirrors findCairnOptions's recursion. Falsy slots pass through, which Vite tolerates. */
109
+ export function stripCairnManifest(plugins: PluginOption | PluginOption[]): PluginOption[] {
110
+ if (Array.isArray(plugins)) return plugins.flatMap(stripCairnManifest);
111
+ if (isCairnManifestPlugin(plugins)) return [];
112
+ return [plugins];
113
+ }
114
+
115
+ /** Verify the committed manifest against the corpus from a Vite context, throwing on drift. The bin
116
+ * and the plugin share this; the spike proved it runs cleanly inside the consumer's config. */
117
+ export async function verifyManifestFromVite(opts: CairnManifestOptions, root: string): Promise<void> {
118
+ await evalVirtual(opts, 'verify', root);
119
+ }
120
+
121
+ /** Regenerate the serialized manifest from the corpus in a Vite context, sharing the build's
122
+ * resolution. The cairn-manifest bin (a later task) will call this and write the result. */
123
+ export async function buildManifestFromVite(opts: CairnManifestOptions, root: string): Promise<string> {
124
+ return evalVirtual(opts, 'write', root);
125
+ }
126
+
127
+ /** The cairnManifest plugin. It serves the verify virtual module to the app graph and, in
128
+ * buildStart, evaluates it through a nested Vite SSR load so a manifest drift fails the build. */
129
+ export function cairnManifest(opts: CairnManifestOptions): Plugin {
130
+ let root = process.cwd();
131
+ const plugin: CairnManifestPlugin = {
132
+ name: 'cairn-manifest',
133
+ configResolved(config) {
134
+ // Capture the resolved app root so the nested server loads the same config the build did.
135
+ root = config.root;
136
+ },
137
+ resolveId(id) {
138
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
139
+ },
140
+ load(id) {
141
+ if (id === RESOLVED_ID) return virtualSource(opts, 'verify');
142
+ },
143
+ async buildStart() {
144
+ try {
145
+ await verifyManifestFromVite(opts, root);
146
+ } catch (err) {
147
+ this.error(err instanceof Error ? err.message : String(err));
148
+ }
149
+ },
150
+ };
151
+ // Stash the options on the instance so the cairn-manifest bin's writeManifest can read the content
152
+ // globs, config module, and manifest path off the plugin in the consumer's loaded config, sharing
153
+ // exactly the options the build verifies with.
154
+ plugin[CAIRN_OPTIONS] = opts;
155
+ return plugin;
156
+ }
157
+
158
+ /** Regenerate the committed manifest from the consumer's corpus and write it to the configured
159
+ * manifestPath. It loads the consumer's Vite config from `cwd`, reads the cairnManifest plugin's
160
+ * options off the instance, evaluates the write-mode virtual module through the build's own
161
+ * resolution, and writes the serialized manifest. The cairn-manifest bin calls this; it is exported
162
+ * so the write logic is testable apart from the CLI shell. */
163
+ export async function writeManifest(cwd: string = process.cwd()): Promise<void> {
164
+ const { loadConfigFromFile } = await import('vite');
165
+ const loaded = await loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, cwd);
166
+ if (!loaded) {
167
+ throw new Error(`cairn-manifest: no Vite config found in ${cwd}`);
168
+ }
169
+ const opts = findCairnOptions(loaded.config.plugins);
170
+ if (!opts) {
171
+ throw new Error(
172
+ 'cairn-manifest: the Vite config has no cairnManifest() plugin. Add it so the bin shares the build options.',
173
+ );
174
+ }
175
+ const serialized = await buildManifestFromVite(opts, cwd);
176
+ const manifestPath = opts.manifestPath ?? DEFAULT_MANIFEST_PATH;
177
+ // The manifest path is app-root-absolute (a leading slash relative to the project), so resolve it
178
+ // against cwd, not the filesystem root.
179
+ const outPath = join(cwd, manifestPath.replace(/^\//, ''));
180
+ await mkdir(dirname(outPath), { recursive: true });
181
+ await writeFile(outPath, serialized);
182
+ }
183
+
184
+ /** Walk a Vite plugins option (which may nest arrays, hold falsy slots, or be a thenable) and return
185
+ * the stashed cairnManifest options from the first matching plugin, or null if there is none. */
186
+ function findCairnOptions(plugins: unknown): CairnManifestOptions | null {
187
+ if (!plugins) return null;
188
+ if (Array.isArray(plugins)) {
189
+ for (const p of plugins) {
190
+ const found = findCairnOptions(p);
191
+ if (found) return found;
192
+ }
193
+ return null;
194
+ }
195
+ if (typeof plugins === 'object' && CAIRN_OPTIONS in plugins) {
196
+ return (plugins as CairnManifestPlugin)[CAIRN_OPTIONS] ?? null;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ /** A minimal plugin that serves only the virtual module in one mode, for the nested SSR load. It
202
+ * carries no buildStart, so the nested server never recurses into the verify. */
203
+ function cairnVirtualOnly(opts: CairnManifestOptions, mode: 'verify' | 'write'): Plugin {
204
+ return {
205
+ name: 'cairn-manifest-virtual',
206
+ resolveId(id) {
207
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
208
+ },
209
+ load(id) {
210
+ if (id === RESOLVED_ID) return virtualSource(opts, mode);
211
+ },
212
+ };
213
+ }