brustjs 0.1.38-alpha → 0.1.40-alpha

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.
@@ -0,0 +1,544 @@
1
+ // Task 2.7 — the md emit step. Per markdown route: render the md body to
2
+ // jinja-safe HTML (render.ts), compile a synthetic wrapper TSX through the
3
+ // SAME napi `compileJsx` the native pipeline uses, splice the md HTML into the
4
+ // compiled template's slot element, merge the island manifest, and bake the
5
+ // client runtime tags in a SINGLE idempotent pass.
6
+ //
7
+ // Pinned order of operations (spec §High-level architecture step 6):
8
+ // compileJsx(wrapper) → splice md HTML → merge manifest → single bake pass.
9
+ //
10
+ // No Rust changes: the integration points are jinja text files and
11
+ // `.islands.json` sidecars, both already consumed by the existing pipeline.
12
+
13
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
+ import path from 'node:path'
15
+ import {
16
+ bakeDirectivesIfUsed,
17
+ buildChainWrapperSource,
18
+ countMainTags,
19
+ emitComponentArtifacts,
20
+ extractLucideIcons,
21
+ gatherChainSources,
22
+ injectDevClientIntoTemplate,
23
+ type ResolvedImport,
24
+ reconcileIslandManifest,
25
+ scanImports,
26
+ } from '../cli/native-routes-emit.ts'
27
+ import { islandChunkBasename } from '../islands/chunk-id.ts'
28
+ import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
29
+ import type { NativeIslandEntry } from '../islands/native-render.ts'
30
+ import { directiveName, isBehaviorSource, scanDirectiveComponents } from '../native/build.ts'
31
+ import { type MdBehaviorUse, type MdComponentResolution, renderMdPage } from './render.ts'
32
+ import { type MdRouteSource, mdManifestFromFlatRoutes, writeMdManifest } from './routes.ts'
33
+ import { type MdFile, scanMdDir } from './scan.ts'
34
+
35
+ /** The slice of a FlatRoute the md emit step reads. The chain holds the route
36
+ * NODES, so the md leaf's `__mdSource` (runtime/md/routes.ts) survives into it.
37
+ * `fullPath` is only read by the manifest derivation (emitMdArtifacts).
38
+ * `Component` admits plain `{ name }` carriers AND real component values
39
+ * (function/class — both expose `.name` via the Function prototype), so a
40
+ * `FlatRoute[]` passes without casts. */
41
+ export interface FlatRouteLike {
42
+ fullPath?: string
43
+ nativeTemplate?: string
44
+ chain?: Array<{
45
+ Component?:
46
+ | { name?: string }
47
+ | ((...args: never[]) => unknown)
48
+ | (abstract new (
49
+ ...args: never[]
50
+ ) => unknown)
51
+ __mdSource?: MdRouteSource
52
+ }>
53
+ }
54
+
55
+ export interface MdEmitOpts {
56
+ /** User's routes entry file (absolute path) — scanned for the default-import
57
+ * idents that resolve embedded component tags, and for app-wide directives. */
58
+ entryFile: string
59
+ /** Flat routes; only chains whose LEAF carries `__mdSource` are emitted. */
60
+ flatRoutes: FlatRouteLike[]
61
+ /** Jinja output dir (same dir `emitNativeTemplates` writes to). */
62
+ outDir: string
63
+ /** Bake the /_brust/dev WS client tag (parity with the native emit's
64
+ * BRUST_DEV injection — md pages render Rust-side and never pass through the
65
+ * React renderer's dev-client injection). */
66
+ withDevClient?: boolean
67
+ /** What to do when a route's md file no longer exists on disk (deleted after
68
+ * the route table was built). emitMdTemplates serves BOTH `brust build` and
69
+ * the dev re-emit, and the two must diverge here:
70
+ * - `'throw'` (build): a missing file is a HARD error — silently skipping
71
+ * ships a dist with the route registered but its template absent.
72
+ * - `'skip-warn'` (default; dev boot/re-emit): skip the route and warn
73
+ * once that a restart is required — the dev route table is frozen at
74
+ * boot, so a crash here would take down the whole hot-reload loop. */
75
+ onMissing?: 'throw' | 'skip-warn'
76
+ }
77
+
78
+ // Once-per-process flag for the add/remove warning: every re-emit (HMR fires
79
+ // on each md edit) would otherwise repeat it.
80
+ let mdRoutesChangedWarned = false
81
+
82
+ function warnMdRoutesChanged(): void {
83
+ if (mdRoutesChangedWarned) return
84
+ mdRoutesChangedWarned = true
85
+ console.warn('[brust dev] md routes changed — restart required')
86
+ }
87
+
88
+ /** Test helper. */
89
+ export function _resetMdRoutesChangedWarnForTests(): void {
90
+ mdRoutesChangedWarned = false
91
+ }
92
+
93
+ /** Raw island entry as the Rust compiler emits it (camelCase JSON). */
94
+ interface RawIslandEntry {
95
+ component: string
96
+ instance: number
97
+ propsPath: string
98
+ ssr: boolean
99
+ hydrate: string
100
+ }
101
+
102
+ /** Minimal shape of the napi addon's `compileJsx`. */
103
+ type CompileJsx = (
104
+ source: string,
105
+ path: string,
106
+ componentSources?: Record<string, string>,
107
+ lucideIcons?: Record<string, string>,
108
+ directiveNames?: Record<string, string>,
109
+ ) => { template: string; islandsJson: string; componentsJson?: string; warnings?: string[] }
110
+
111
+ /** Emit one `.jinja` (+ `.islands.json` sidecar) per md route in `flatRoutes`.
112
+ * Returns the islands referenced from md content (`name → absolute source
113
+ * path`) so the build step can thread them into island-chunk discovery as
114
+ * `extraIslands` (task 2.8). Behaviors are NOT in the map — their chunks are
115
+ * built by `scanDirectiveComponents`, which already walks the routes-entry
116
+ * import graph the registry keeps alive. */
117
+ export async function emitMdTemplates(opts: MdEmitOpts): Promise<{
118
+ mdIslands: Map<string, string>
119
+ }> {
120
+ const mdIslands = new Map<string, string>()
121
+ const mdRoutes = opts.flatRoutes.filter(
122
+ (r) => r.nativeTemplate && r.chain?.[r.chain.length - 1]?.__mdSource,
123
+ )
124
+ if (mdRoutes.length === 0) return { mdIslands }
125
+
126
+ mkdirSync(opts.outDir, { recursive: true })
127
+
128
+ // Same dynamic-import seam as emitNativeTemplates: the napi addon ships
129
+ // compileJsx with every platform package.
130
+ const native = await import('../index.js')
131
+ const compileJsx = (native as { compileJsx?: CompileJsx }).compileJsx
132
+ if (typeof compileJsx !== 'function') {
133
+ throw new Error(
134
+ 'brust: the native addon does not expose compileJsx — rebuild it with ' +
135
+ '`cd runtime && bun run build` (or update brustjs to a build that ships it).',
136
+ )
137
+ }
138
+
139
+ const projectRoot = process.cwd()
140
+ const importMap = scanImports(opts.entryFile)
141
+ // App-wide directive presence — same force rule as emitNativeTemplates: SPA
142
+ // nav swaps <main> without executing scripts, so the directive runtime must
143
+ // already be live on every native page when ANY directive component exists.
144
+ const hasDirectives = scanDirectiveComponents(opts.entryFile).size > 0
145
+
146
+ // Add/remove detection (task 2.9): the dev route table is frozen at boot,
147
+ // so a created/deleted .md file can't become/stop being a route until the
148
+ // dev process restarts. Compare the scanned set against the route set per
149
+ // content dir and tell the operator ONCE — never crash the re-emit.
150
+ const routeRelsByDir = new Map<string, Set<string>>()
151
+ for (const r of mdRoutes) {
152
+ const src = (r.chain as NonNullable<FlatRouteLike['chain']>)[r.chain!.length - 1]
153
+ ?.__mdSource as MdRouteSource
154
+ let rels = routeRelsByDir.get(src.contentDir)
155
+ if (rels === undefined) {
156
+ rels = new Set()
157
+ routeRelsByDir.set(src.contentDir, rels)
158
+ }
159
+ rels.add(src.relPath)
160
+ }
161
+
162
+ // One scan per content dir (md bodies aren't carried on __mdSource).
163
+ const onMissing = opts.onMissing ?? 'skip-warn'
164
+ const mdFilesByDir = new Map<string, Map<string, MdFile>>()
165
+ const mdFileFor = (src: MdRouteSource): MdFile | undefined => {
166
+ let files = mdFilesByDir.get(src.contentDir)
167
+ if (files === undefined) {
168
+ files = new Map(scanMdDir(src.contentDir).map((f) => [f.relPath, f]))
169
+ mdFilesByDir.set(src.contentDir, files)
170
+ const rels = routeRelsByDir.get(src.contentDir) as Set<string>
171
+ const setsMatch = files.size === rels.size && [...rels].every((rel) => files!.has(rel))
172
+ // The "restart required" phrasing only makes sense in dev: in build mode
173
+ // a missing file throws below instead (an extra file is harmless — the
174
+ // build's freshly loaded route table simply doesn't reference it).
175
+ if (!setsMatch && onMissing === 'skip-warn') warnMdRoutesChanged()
176
+ }
177
+ return files.get(src.relPath)
178
+ }
179
+
180
+ // Tag-name → classification, shared across pages (one readFileSync per name).
181
+ const resolutionCache = new Map<string, { res: MdComponentResolution; absPath: string }>()
182
+
183
+ for (const r of mdRoutes) {
184
+ const name = r.nativeTemplate as string
185
+ const chain = r.chain as NonNullable<FlatRouteLike['chain']>
186
+ const src = chain[chain.length - 1]?.__mdSource as MdRouteSource
187
+ const mdFile = mdFileFor(src)
188
+ if (mdFile === undefined) {
189
+ if (onMissing === 'throw') {
190
+ // Build mode: the route is registered but its markdown source is gone —
191
+ // emitting nothing would ship an incomplete dist (template absent).
192
+ throw new Error(
193
+ `md route "${r.fullPath ?? name}" references ${src.absPath}, but the markdown ` +
194
+ 'file no longer exists — delete the route or restore the file, then rebuild',
195
+ )
196
+ }
197
+ // Dev: a removed file is skipped (its stale template keeps serving until
198
+ // the restart the once-warn in mdFileFor asked for); the emit proceeds.
199
+ continue
200
+ }
201
+
202
+ // 1. Resolver: registry key → routes-entry default import → island/behavior.
203
+ const resolve = (tag: string, line: number): MdComponentResolution | null => {
204
+ if (!Object.hasOwn(src.components, tag)) return null // unknown → render.ts errors
205
+ let cached = resolutionCache.get(tag)
206
+ if (cached === undefined) {
207
+ const absPath = importMap.get(tag)
208
+ if (absPath === undefined) {
209
+ // All THREE identities must coincide: the md tag name, the mdRoutes
210
+ // components-registry key, and the routes-entry default-import ident.
211
+ throw new Error(
212
+ `${src.absPath}:${line} — <${tag}> is in the mdRoutes components registry, but the ` +
213
+ `routes entry (${opts.entryFile}) has no matching default import ` +
214
+ `(expected \`import ${tag} from "..."\`). The md tag name, the registry key, ` +
215
+ `and the routes-entry import ident must all be the same name.`,
216
+ )
217
+ }
218
+ const res: MdComponentResolution = isBehaviorSource(readFileSync(absPath, 'utf8'))
219
+ ? { kind: 'behavior', directive: directiveName(absPath, projectRoot) }
220
+ : { kind: 'island', id: islandChunkBasename(tag, absPath) }
221
+ cached = { res, absPath }
222
+ resolutionCache.set(tag, cached)
223
+ }
224
+ if (cached.res.kind === 'island') mdIslands.set(tag, cached.absPath)
225
+ return cached.res
226
+ }
227
+
228
+ // 2. md → jinja-safe HTML with LIVE island/behavior host markers
229
+ // (LOCAL instance numbers — offset below).
230
+ const rendered = await renderMdPage({ body: mdFile.body, absPath: src.absPath, resolve })
231
+
232
+ // 3. Wrapper TSX (in-memory — compileJsx keys off the default export +
233
+ // componentSources; routeSourcePath need not exist on disk).
234
+ let routeSource: string
235
+ let routeSourcePath: string
236
+ let sources: Record<string, string>
237
+ let mergedImports: Map<string, ResolvedImport>
238
+ if (chain.length > 1) {
239
+ // Chained: bare <article> fragment composed via the EXISTING chain path —
240
+ // the synthetic leaf source is injected next to the gathered chain sources.
241
+ const ancestorNames = chain
242
+ .slice(0, -1)
243
+ .map((node) => node.Component?.name)
244
+ .filter((n): n is string => typeof n === 'string' && n.length > 0)
245
+ if (ancestorNames.length !== chain.length - 1) {
246
+ throw new Error(
247
+ `md route "${name}" (${src.absPath}) has an unnamed layout component in its chain — every chain level needs a named component`,
248
+ )
249
+ }
250
+ ;({ sources, mergedImports } = gatherChainSources(ancestorNames, importMap))
251
+ sources[name] = mdChainedLeafSource(name)
252
+ routeSource = buildChainWrapperSource([...ancestorNames, name])
253
+ const firstAncestorPath = importMap.get(ancestorNames[0] as string) as string
254
+ routeSourcePath = path.resolve(path.dirname(firstAncestorPath), `${name}__chain.tsx`)
255
+ } else {
256
+ // Standalone: the wrapper owns the <BrustPage> shell; frontmatter
257
+ // title/description become literal props.
258
+ sources = {}
259
+ mergedImports = new Map()
260
+ routeSource = mdStandaloneSource(name, src.frontmatter)
261
+ routeSourcePath = path.resolve(src.contentDir, `${name}.tsx`)
262
+ }
263
+
264
+ // Lucide + directive maps for inlined chain components — mirrors
265
+ // emitNativeTemplates (the wrapper itself can't carry either).
266
+ const lucideIcons: Record<string, string> = {}
267
+ for (const imp of mergedImports.values()) {
268
+ if (!imp.bare && typeof imp.spec === 'string') {
269
+ Object.assign(lucideIcons, await extractLucideIcons(imp.spec))
270
+ }
271
+ }
272
+ const directiveNames: Record<string, string> = {}
273
+ for (const [ident, text] of Object.entries(sources)) {
274
+ if (!isBehaviorSource(text)) continue
275
+ const ref = mergedImports.get(ident)
276
+ if (ref && !ref.bare && typeof ref.spec === 'string') {
277
+ directiveNames[ident] = directiveName(ref.spec, projectRoot)
278
+ }
279
+ }
280
+
281
+ let compiled: ReturnType<CompileJsx>
282
+ try {
283
+ compiled = compileJsx(routeSource, routeSourcePath, sources, lucideIcons, directiveNames)
284
+ } catch (e) {
285
+ throw new Error(
286
+ `md route "${name}" wrapper failed to compile (${src.absPath}):\n${String(e)}`,
287
+ )
288
+ }
289
+ for (const w of compiled.warnings ?? []) process.stderr.write(`brust: ${w}\n`)
290
+
291
+ // 4. Offset md island instances past the wrapper/layout TSX islands, then
292
+ // splice. Single-pass regex — no replace cascade when offset overlaps
293
+ // the local range. The md HTML is already brace-neutralized with LIVE
294
+ // markers (render.ts), so it is NOT re-neutralized here.
295
+ const tsxIslands = JSON.parse(
296
+ compiled.islandsJson === '' ? '[]' : compiled.islandsJson,
297
+ ) as RawIslandEntry[]
298
+ const offset = tsxIslands.length > 0 ? Math.max(...tsxIslands.map((e) => e.instance)) + 1 : 0
299
+ let mdHtml = rendered.html
300
+ if (offset > 0 && rendered.islands.length > 0) {
301
+ // Anchored on the LIVE-jinja prefix `{{ island_`: after neutralizeBraces
302
+ // every literal `{{` in md content reads `{{ "{{" }}` (next char `"`),
303
+ // so this byte sequence exists ONLY in the injected host markers — prose
304
+ // or code fences that mention island_N_props are never touched. (Same
305
+ // anchoring discipline as reconcileIslandManifest's marker rewrite.)
306
+ mdHtml = mdHtml.replace(
307
+ /\{\{ island_(\d+)_(props|html)/g,
308
+ (_m, n: string, kind: string) => `{{ island_${Number(n) + offset}_${kind}`,
309
+ )
310
+ }
311
+
312
+ // 4b. Behavior hosts: render.ts emitted a unique nonce-bearing placeholder
313
+ // per use (it has no compileJsx access — this module does). Compile
314
+ // each behavior component's BODY through the SAME native-inline path a
315
+ // TSX page uses and substitute the fully inlined markup whole-tag over
316
+ // the placeholder. A bare x-data div would have no children → no
317
+ // x-on-* click targets → the behavior could never do anything.
318
+ for (const [i, use] of rendered.behaviors.entries()) {
319
+ const absPath = (resolutionCache.get(use.name) as { absPath: string }).absPath
320
+ const inlined = await compileMdBehaviorHost(compileJsx, use, absPath, src.absPath, i)
321
+ const at = mdHtml.indexOf(use.marker)
322
+ if (at === -1 || mdHtml.indexOf(use.marker, at + 1) !== -1) {
323
+ throw new Error(
324
+ `md route "${name}": behavior placeholder for <${use.name}> (${src.absPath}:${use.line}) ` +
325
+ 'is not exactly-once in the rendered HTML — emit-pipeline invariant violated',
326
+ )
327
+ }
328
+ mdHtml = mdHtml.slice(0, at) + inlined + mdHtml.slice(at + use.marker.length)
329
+ }
330
+
331
+ const template = spliceMdSlot(compiled.template, name, mdHtml)
332
+ if (countMainTags(template) > 1) {
333
+ process.stderr.write(
334
+ `brust: md route "${name}" has more than one <main> after splice — SPA navigation extracts only the first <main>…</main>.\n`,
335
+ )
336
+ }
337
+ const outPath = path.resolve(opts.outDir, `${name}.jinja`)
338
+ writeFileSync(outPath, template)
339
+
340
+ // 5. Manifest merge: reconcile the compiler's TSX entries (sourcePath
341
+ // enrichment + content-addressed marker-id rewrite — md markers carry
342
+ // offset instances the rewrite map doesn't know, so they pass through
343
+ // untouched), then append the md entries.
344
+ const islandsJsonPath = path.resolve(opts.outDir, `${name}.islands.json`)
345
+ if (tsxIslands.length > 0) {
346
+ writeFileSync(islandsJsonPath, compiled.islandsJson)
347
+ reconcileIslandManifest(outPath, islandsJsonPath, mergedImports, name, {
348
+ bakeBootstrap: false,
349
+ })
350
+ } else if (existsSync(islandsJsonPath)) {
351
+ rmSync(islandsJsonPath, { force: true }) // stale sidecar from a previous emit
352
+ }
353
+ const mdEntries: NativeIslandEntry[] = rendered.islands.map((use) => {
354
+ const absPath = (resolutionCache.get(use.name) as { absPath: string }).absPath
355
+ return {
356
+ component: use.name,
357
+ instance: use.instanceLocal + offset,
358
+ propsPath: '',
359
+ propsLiteral: use.props,
360
+ ssr: !use.csr,
361
+ hydrate: use.hydrate,
362
+ sourcePath: path.relative(projectRoot, absPath).replaceAll('\\', '/'),
363
+ }
364
+ })
365
+ if (mdEntries.length > 0) {
366
+ const existing =
367
+ tsxIslands.length > 0
368
+ ? (JSON.parse(readFileSync(islandsJsonPath, 'utf8')) as NativeIslandEntry[])
369
+ : []
370
+ writeFileSync(islandsJsonPath, JSON.stringify([...existing, ...mdEntries]))
371
+ }
372
+
373
+ // SSR-component sidecars (chained layouts can carry SSR components).
374
+ const compJsonStr = compiled.componentsJson ?? '[]'
375
+ if (compJsonStr !== '[]') {
376
+ emitComponentArtifacts(outPath, compJsonStr, mergedImports, name)
377
+ }
378
+
379
+ // 6. Single idempotent bake pass. Every append below is `includes()`-guarded
380
+ // (reconcile's unguarded bake was skipped above), so re-running emit —
381
+ // or emitComponentArtifacts having baked the bootstrap already — can
382
+ // never double-bake.
383
+ let final = readFileSync(outPath, 'utf8')
384
+ if (tsxIslands.length > 0 || mdEntries.length > 0) {
385
+ const baked = `{% raw %}${ISLANDS_IMPORTMAP_AND_BOOTSTRAP}{% endraw %}`
386
+ if (!final.includes(baked)) final += baked
387
+ }
388
+ final = bakeDirectivesIfUsed(final, hasDirectives)
389
+ if (opts.withDevClient) final = injectDevClientIntoTemplate(final)
390
+ writeFileSync(outPath, final)
391
+ }
392
+
393
+ return { mdIslands }
394
+ }
395
+
396
+ export interface MdArtifactsOpts extends MdEmitOpts {
397
+ /** Dirs to write `md-manifest.json` into when the app has md routes (e.g.
398
+ * `<distDir>` and `<cwd>/.brust` — both "next to" their jinja dirs, where
399
+ * `loadPrebuiltMdManifest` / the staleness check look). Skipped entirely
400
+ * when there are no md routes (zero output difference for md-free apps). */
401
+ manifestDirs?: string[]
402
+ }
403
+
404
+ /** Task 2.8 build-integration seam: emit the md `.jinja` templates AND the
405
+ * frozen `md-manifest.json` (derived from the same flat route table — single
406
+ * source of truth) in one call. All build sites (`brust build`, `brust dev`
407
+ * boot/re-emit, runtime boot, dev HMR island rebuild) go through this so the
408
+ * template, manifest, and returned `mdIslands` chunk inputs can never diverge.
409
+ * Strict no-op for apps without md routes. */
410
+ export async function emitMdArtifacts(opts: MdArtifactsOpts): Promise<{
411
+ mdIslands: Map<string, string>
412
+ }> {
413
+ const { mdIslands } = await emitMdTemplates(opts)
414
+ const manifest = mdManifestFromFlatRoutes(opts.flatRoutes)
415
+ if (manifest !== null) {
416
+ for (const d of opts.manifestDirs ?? []) {
417
+ writeMdManifest(d, manifest.entries, manifest.contentDir)
418
+ }
419
+ }
420
+ return { mdIslands }
421
+ }
422
+
423
+ /** Standalone wrapper: the md page owns the document shell. Frontmatter
424
+ * title/description thread in as literal BrustPage props — JSON.stringify
425
+ * yields a valid JS string literal for the JSX expression container, so
426
+ * quotes/backslashes/newlines in frontmatter can't break the synthetic source. */
427
+ function mdStandaloneSource(name: string, frontmatter: MdRouteSource['frontmatter']): string {
428
+ const title =
429
+ typeof frontmatter.title === 'string' ? ` title={${JSON.stringify(frontmatter.title)}}` : ''
430
+ const description =
431
+ typeof frontmatter.description === 'string'
432
+ ? ` description={${JSON.stringify(frontmatter.description)}}`
433
+ : ''
434
+ return `export default function ${name}() { return <BrustPage${title}${description}><main data-brust-md-slot="${name}"></main></BrustPage>; }`
435
+ }
436
+
437
+ /** Compile one md behavior use into its fully inlined host markup.
438
+ *
439
+ * A synthetic wrapper (`<Name native …/>`) goes through the SAME `compileJsx`
440
+ * napi call as TSX pages, with the component source + the canonical directive
441
+ * name threaded exactly like emitNativeTemplates does (componentSources + the
442
+ * 5th `directiveNames` arg — that arg is what makes the compiler auto-inject
443
+ * `x-data="<directive>"` onto the inlined root). md tag props are literals,
444
+ * validated string/number in render.ts; both are passed as JS STRING literal
445
+ * expression containers (`label={"…"}`):
446
+ * - strings inline-substitute fully static WITH proper HTML escaping
447
+ * (verified empirically — plain `p="…"` attrs can't carry quotes, and
448
+ * bare `n={42}` leaves live `{{ (42) | e }}` jinja in the template);
449
+ * - numbers stringify output-equivalently (the body renders them as text,
450
+ * and the inline path rejects arithmetic anyway).
451
+ *
452
+ * The result must be FULLY STATIC: any remaining `{{` means the body
453
+ * references something non-literal (a prop the tag didn't pass, route data,
454
+ * an island, an SSR component) → hard build error. Literal-only control flow
455
+ * (`{% if "yes" %}`) is allowed through — it renders correctly when the page
456
+ * template passes through minijinja. */
457
+ async function compileMdBehaviorHost(
458
+ compileJsx: CompileJsx,
459
+ use: MdBehaviorUse,
460
+ componentPath: string,
461
+ mdAbsPath: string,
462
+ index: number,
463
+ ): Promise<string> {
464
+ const attrs = Object.entries(use.props)
465
+ .map(([k, v]) => ` ${k}={${JSON.stringify(String(v))}}`)
466
+ .join('')
467
+ const wrapperSource = `export default function MdBehaviorHost_${index}() { return <${use.name} native${attrs} /> }`
468
+ // Synthetic path — compileJsx keys off the default export + componentSources.
469
+ const wrapperPath = path.resolve(path.dirname(componentPath), `__MdBehaviorHost_${index}.tsx`)
470
+ // The behavior component's own file may import lucide icons (they become
471
+ // static SVG in native-inlined bodies) — same extraction as the chain path.
472
+ const lucideIcons = await extractLucideIcons(componentPath)
473
+
474
+ let compiled: ReturnType<CompileJsx>
475
+ try {
476
+ compiled = compileJsx(
477
+ wrapperSource,
478
+ wrapperPath,
479
+ { [use.name]: readFileSync(componentPath, 'utf8') },
480
+ lucideIcons,
481
+ { [use.name]: use.directive },
482
+ )
483
+ } catch (e) {
484
+ throw new Error(
485
+ `${mdAbsPath}:${use.line} — <${use.name}> failed to compile as an inlined md behavior host:\n${String(e)}`,
486
+ )
487
+ }
488
+ for (const w of compiled.warnings ?? []) process.stderr.write(`brust: ${w}\n`)
489
+
490
+ if (compiled.template.includes('{{')) {
491
+ throw new Error(
492
+ `${mdAbsPath}:${use.line} — <${use.name}> body references non-literal data; ` +
493
+ 'md behavior components must be fully static (every value the body renders ' +
494
+ 'must come from a literal prop on the md tag)',
495
+ )
496
+ }
497
+ if (!compiled.template.includes(`x-data="${use.directive}"`)) {
498
+ // Auto-injection puts the canonical directive name on the inlined root; a
499
+ // literal author x-data would win over it and desync from the built
500
+ // `<directive>.directive.js` chunk this md use was resolved against.
501
+ throw new Error(
502
+ `${mdAbsPath}:${use.line} — <${use.name}> compiled without x-data="${use.directive}" on its ` +
503
+ 'root element (a literal x-data override on the component root is not supported in md)',
504
+ )
505
+ }
506
+ return compiled.template
507
+ }
508
+
509
+ /** Chained leaf wrapper: a bare fragment — the layout owns the document shell
510
+ * and the single <main> (a nested <main> truncates SPA-nav payloads; a nested
511
+ * BrustPage emits a nested <html> document). */
512
+ function mdChainedLeafSource(name: string): string {
513
+ return `export default function ${name}() { return <article data-brust-md-slot="${name}"></article>; }`
514
+ }
515
+
516
+ /** Replace the slot element's inner content with the md HTML. The slot attr
517
+ * itself stays (hydration-neutral, useful for tests). Exactly one slot must
518
+ * exist and it must be empty — both are emit-pipeline invariants, so a
519
+ * violation is a hard error, not a soft skip. `name` is a generated template
520
+ * name (`[A-Za-z0-9_]` only), so interpolating it into the regex is safe. */
521
+ export function spliceMdSlot(template: string, name: string, mdHtml: string): string {
522
+ if (!/^[A-Za-z0-9_]+$/.test(name)) {
523
+ throw new Error(`md route template name "${name}" is not a valid generated name`)
524
+ }
525
+ // The JSX compiler emits paired tags for empty elements (<main></main>,
526
+ // never <main/>) — HTML output, not XML. The empty-slot check below relies
527
+ // on that contract.
528
+ const openRe = new RegExp(`<(main|article)\\b[^>]*\\bdata-brust-md-slot="${name}"[^>]*>`, 'g')
529
+ const matches = [...template.matchAll(openRe)]
530
+ if (matches.length !== 1) {
531
+ throw new Error(
532
+ `md route "${name}": expected exactly one data-brust-md-slot="${name}" element in the compiled template, found ${matches.length}`,
533
+ )
534
+ }
535
+ const m = matches[0] as RegExpMatchArray & { index: number }
536
+ const insertAt = m.index + m[0].length
537
+ const closeTag = `</${m[1]}>`
538
+ if (!template.startsWith(closeTag, insertAt)) {
539
+ throw new Error(
540
+ `md route "${name}": the data-brust-md-slot element must compile empty (expected ${closeTag} immediately after the open tag)`,
541
+ )
542
+ }
543
+ return template.slice(0, insertAt) + mdHtml + template.slice(insertAt)
544
+ }