brustjs 0.1.39-alpha → 0.1.41-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.
package/README.md CHANGED
@@ -79,6 +79,7 @@ the empirically-found limits.
79
79
  brustjs dev <entry> # dev mode: watcher + WS reload + browser auto-reload
80
80
  brustjs build <entry> --out-dir D # prebuilt ./dist/ — run from the project (bun run dist/index.js)
81
81
  --target <auto|all|TARGET[,…]> # which native binary to bundle (default: auto = host platform)
82
+ --ssg [--ssg-out D] # prerender static routes (incl. markdown pages) to HTML
82
83
  brustjs new <name> # scaffold a project (partial — see Status)
83
84
  ```
84
85
 
@@ -111,6 +112,10 @@ brustjs new <name> # scaffold a project (partial — see Status)
111
112
  infers the whole API from the server types (no codegen) and returns
112
113
  `{ data, error, status, headers }` (never throws). Standard Schema (zod)
113
114
  validation, JSON / urlencoded / multipart bodies.
115
+ - **Markdown pages** — `mdRoutes()` compiles a directory of `.md` files
116
+ (GFM + frontmatter, optional shiki highlighting) into native jinja routes at
117
+ build time, with React islands and behavior components embeddable straight
118
+ from markdown.
114
119
  - **SSE & WebSockets** as first-class route shapes.
115
120
  - Nested routes + dynamic params, per-route typed loaders, request-scoped middleware,
116
121
  SPA-style navigation, in-process LRU response cache + island ISR cache, Tailwind v4 + CSS Modules.
@@ -119,6 +124,35 @@ brustjs new <name> # scaffold a project (partial — see Status)
119
124
  same validation + middleware as an HTTP request, so agents drive the app
120
125
  without scraping.
121
126
 
127
+ ## Markdown pages
128
+
129
+ Mount a content directory as routes — each `.md` file becomes a `native: true`
130
+ page compiled to a jinja template at build time (no React on the server, no
131
+ markdown parsing at request time):
132
+
133
+ ```tsx
134
+ // routes.tsx
135
+ import { defineRoutes, mdRoutes } from 'brustjs/routes'
136
+ import DocsLayout from './components/DocsLayout' // owns <BrustPage> + <main><Outlet/></main>
137
+ import Counter from './components/Counter'
138
+
139
+ export const routes = defineRoutes([
140
+ ...mdRoutes('content/docs', {
141
+ prefix: '/docs', // content/docs/query/where.md → /docs/query/where
142
+ layout: DocsLayout, // optional; head comes from frontmatter via `__md`
143
+ components: { Counter }, // usable as `<Counter start={5} />` in markdown
144
+ }),
145
+ ])
146
+ ```
147
+
148
+ Frontmatter (`title`, `description`, `nav: { group, order }`) drives `<title>`
149
+ and the `mdNav('content/docs')` sidebar tree. Component tags on their own line
150
+ embed islands (`<Counter start={5} />`, `csr` / `hydrate="visible"` supported)
151
+ or native behavior components. Code fences are highlighted server-side when the
152
+ optional `shiki` peer dependency is installed. `brust build` freezes the pages
153
+ into the dist (`md-manifest.json` — the content dir isn't needed at runtime),
154
+ and `brust build --ssg` prerenders them to static HTML.
155
+
122
156
  ## Performance
123
157
 
124
158
  Two tiers, split by the napi crossing: pure-Rust paths (`/ping`, native jinja
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brustjs",
3
- "version": "0.1.39-alpha",
3
+ "version": "0.1.41-alpha",
4
4
  "description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,20 +36,27 @@
36
36
  "dependencies": {
37
37
  "@tailwindcss/node": "^4.3.0",
38
38
  "@tailwindcss/oxide": "^4.3.0",
39
+ "marked": "^18.0.5",
39
40
  "smol-toml": "^1.6.1",
40
41
  "typescript": "^6.0.3"
41
42
  },
42
43
  "optionalDependencies": {
43
- "brustjs-darwin-x64": "0.1.39-alpha",
44
- "brustjs-darwin-arm64": "0.1.39-alpha",
45
- "brustjs-linux-x64-gnu": "0.1.39-alpha",
46
- "brustjs-linux-arm64-gnu": "0.1.39-alpha",
47
- "brustjs-linux-x64-musl": "0.1.39-alpha",
48
- "brustjs-linux-arm64-musl": "0.1.39-alpha"
44
+ "brustjs-darwin-x64": "0.1.41-alpha",
45
+ "brustjs-darwin-arm64": "0.1.41-alpha",
46
+ "brustjs-linux-x64-gnu": "0.1.41-alpha",
47
+ "brustjs-linux-arm64-gnu": "0.1.41-alpha",
48
+ "brustjs-linux-x64-musl": "0.1.41-alpha",
49
+ "brustjs-linux-arm64-musl": "0.1.41-alpha"
49
50
  },
50
51
  "peerDependencies": {
51
52
  "react": "^19.2.6",
52
- "react-dom": "^19.2.6"
53
+ "react-dom": "^19.2.6",
54
+ "shiki": "^4.2.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "shiki": {
58
+ "optional": true
59
+ }
53
60
  },
54
61
  "devDependencies": {
55
62
  "@biomejs/biome": "2.4.16",
@@ -60,6 +67,7 @@
60
67
  "lucide-react": "^1.17.0",
61
68
  "react": "^19.2.6",
62
69
  "react-dom": "^19.2.6",
70
+ "shiki": "^4.2.0",
63
71
  "zod": "^4.4.3"
64
72
  },
65
73
  "type": "module",
@@ -94,6 +102,8 @@
94
102
  "build:debug": "cd runtime && bun run build:debug",
95
103
  "test": "bun test tests/integration.test.ts",
96
104
  "dev": "bun runtime/cli/index.ts dev example/pokedex/index.ts",
105
+ "docs:dev": "cd example/docs && bun ../../runtime/cli/index.ts dev index.ts",
106
+ "docs:build": "cd example/docs && bun ../../runtime/cli/index.ts build index.ts --out-dir dist --ssg",
97
107
  "dev:baseline": "bun run bench/apps/bun-serve/index.ts",
98
108
  "bench": "bun run scripts/benchmark.ts",
99
109
  "format": "biome format --write .",
@@ -138,47 +138,60 @@ export function selectNativeBinaries(
138
138
  return { selected, errors }
139
139
  }
140
140
 
141
- interface ParsedArgs {
141
+ export interface ParsedArgs {
142
142
  entry: string // absolute path to the entry file
143
143
  outDir: string // absolute path to the output dir
144
144
  target: string // --target value (default 'auto')
145
+ ssg: boolean // --ssg — prerender static routes after the build
146
+ ssgOut: string | null // --ssg-out value (absolute); null → <outDir>/static computed later
145
147
  }
146
148
 
147
- function parseArgs(args: string[]): ParsedArgs {
149
+ /** Parse `brust build` argv. Pure (no fs access, no process.exit) so it's
150
+ * unit-testable — throws Error on bad input; runBuild owns stderr + exit. */
151
+ export function parseArgs(args: string[]): ParsedArgs {
148
152
  let entry: string | undefined
149
153
  let outDir: string | undefined
150
154
  let target = 'auto'
155
+ let ssg = false
156
+ let ssgOut: string | undefined
151
157
 
152
158
  for (let i = 0; i < args.length; i++) {
153
159
  const a = args[i]
154
160
  if (a === '--out-dir') {
155
161
  outDir = args[++i]
156
162
  if (!outDir) {
157
- console.error('brust build: --out-dir requires a value')
158
- process.exit(1)
163
+ throw new Error('brust build: --out-dir requires a value')
159
164
  }
160
165
  } else if (a.startsWith('--out-dir=')) {
161
166
  outDir = a.slice('--out-dir='.length)
162
167
  } else if (a === '--target') {
163
168
  target = args[++i]
164
169
  if (!target) {
165
- console.error('brust build: --target requires a value')
166
- process.exit(1)
170
+ throw new Error('brust build: --target requires a value')
167
171
  }
168
172
  } else if (a.startsWith('--target=')) {
169
173
  target = a.slice('--target='.length)
170
174
  if (!target) {
171
- console.error('brust build: --target= requires a value')
172
- process.exit(1)
175
+ throw new Error('brust build: --target= requires a value')
176
+ }
177
+ } else if (a === '--ssg') {
178
+ ssg = true
179
+ } else if (a === '--ssg-out') {
180
+ ssgOut = args[++i]
181
+ if (!ssgOut) {
182
+ throw new Error('brust build: --ssg-out requires a value')
183
+ }
184
+ } else if (a.startsWith('--ssg-out=')) {
185
+ ssgOut = a.slice('--ssg-out='.length)
186
+ if (!ssgOut) {
187
+ throw new Error('brust build: --ssg-out= requires a value')
173
188
  }
174
189
  } else if (a.startsWith('-')) {
175
- console.error(`brust build: unknown flag "${a}"`)
176
- process.exit(1)
190
+ throw new Error(`brust build: unknown flag "${a}"`)
177
191
  } else if (entry === undefined) {
178
192
  entry = a
179
193
  } else {
180
- console.error(`brust build: unexpected positional argument "${a}"`)
181
- process.exit(1)
194
+ throw new Error(`brust build: unexpected positional argument "${a}"`)
182
195
  }
183
196
  }
184
197
 
@@ -189,22 +202,35 @@ function parseArgs(args: string[]): ParsedArgs {
189
202
  : resolve(cwd, entry)
190
203
  : resolve(cwd, 'index.ts')
191
204
 
192
- if (!existsSync(entryPath)) {
193
- console.error(`brust build: no entry file at ${entryPath}; pass a path or create ./index.ts`)
194
- process.exit(1)
195
- }
196
-
197
205
  const outPath = outDir
198
206
  ? isAbsolute(outDir)
199
207
  ? outDir
200
208
  : resolve(cwd, outDir)
201
209
  : resolve(cwd, 'dist')
202
210
 
203
- return { entry: entryPath, outDir: outPath, target }
211
+ if (ssgOut !== undefined && !ssg) {
212
+ throw new Error('brust build: --ssg-out requires --ssg')
213
+ }
214
+ const ssgOutPath = ssgOut ? (isAbsolute(ssgOut) ? ssgOut : resolve(cwd, ssgOut)) : null
215
+
216
+ return { entry: entryPath, outDir: outPath, target, ssg, ssgOut: ssgOutPath }
204
217
  }
205
218
 
206
219
  export async function runBuild(args: string[]): Promise<void> {
207
- const { entry, outDir, target } = parseArgs(args)
220
+ let parsed: ParsedArgs
221
+ try {
222
+ parsed = parseArgs(args)
223
+ } catch (err) {
224
+ console.error(err instanceof Error ? err.message : String(err))
225
+ process.exit(1)
226
+ }
227
+ const { entry, outDir, target } = parsed
228
+
229
+ // Entry existence is a runBuild concern (parseArgs stays fs-free/pure).
230
+ if (!existsSync(entry)) {
231
+ console.error(`brust build: no entry file at ${entry}; pass a path or create ./index.ts`)
232
+ process.exit(1)
233
+ }
208
234
  const entryDir = path.dirname(entry)
209
235
 
210
236
  console.log(`[brust build] entry: ${entry}`)
@@ -278,10 +304,46 @@ export async function runBuild(args: string[]): Promise<void> {
278
304
  }
279
305
  }
280
306
 
281
- // 3. Build islands (if any <Island> usage is found in the routes graph).
307
+ // 2.8. Routes module loaded ONCE here, AFTER the css block (the import may
308
+ // transitively reach `.module.css`, which needs the cssLoaderPlugin already
309
+ // registered) and BEFORE the island build (the md emit + island scan below
310
+ // need the flat route table). The MCP and ssg steps reuse it.
311
+ let loadedRoutes: any[] | undefined
312
+ if (existsSync(routesFile)) {
313
+ const { routes } = await import(routesFile)
314
+ loadedRoutes = routes
315
+ }
316
+
317
+ // 2.9. md routes — emit `Md_*.jinja` into <outDir>/jinja BEFORE the island
318
+ // build so islands used only from md content join the chunk scan, and write
319
+ // the frozen `md-manifest.json` next to BOTH jinja dirs (dist root for the
320
+ // prebuilt boot's loadPrebuiltMdManifest, cwd/.brust for the source runtime —
321
+ // the same dual-emit the jinja mirror below does). Strict no-op without md
322
+ // routes: no files, no dirs, byte-identical dist.
323
+ const jinjaDir = path.join(outDir, 'jinja')
324
+ let mdIslands = new Map<string, string>()
325
+ if (existsSync(routesFile) && loadedRoutes !== undefined) {
326
+ const { emitMdArtifacts } = await import('../md/emit.ts')
327
+ ;({ mdIslands } = await emitMdArtifacts({
328
+ entryFile: routesFile,
329
+ flatRoutes: loadedRoutes,
330
+ outDir: jinjaDir,
331
+ withDevClient: false,
332
+ manifestDirs: [outDir, path.join(process.cwd(), '.brust')],
333
+ // Build is fatal on a deleted md file — a silent skip would ship a dist
334
+ // with the route registered but its template missing (dev paths default
335
+ // to 'skip-warn' so the hot-reload loop survives the same state).
336
+ onMissing: 'throw',
337
+ }))
338
+ const mdCount = loadedRoutes.filter((r: any) => r?.chain?.at(-1)?.__mdSource).length
339
+ if (mdCount > 0) console.log(`[brust build] md: ${mdCount} page(s) → ${jinjaDir}`)
340
+ }
341
+
342
+ // 3. Build islands (if any <Island> usage is found in the routes graph,
343
+ // plus the md-content islands collected above).
282
344
  const { scanIslandChunks, buildIslands } = await import('../islands/build.ts')
283
345
  const islandMap = existsSync(routesFile)
284
- ? scanIslandChunks(routesFile)
346
+ ? scanIslandChunks(routesFile, mdIslands)
285
347
  : new Map<string, string>()
286
348
  if (islandMap.size > 0) {
287
349
  const islandsOutDir = path.join(outDir, 'islands')
@@ -347,12 +409,11 @@ export async function runBuild(args: string[]): Promise<void> {
347
409
  }
348
410
  }
349
411
 
350
- // 4. MCP manifest (if routes.tsx exists).
351
- let loadedRoutes: any[] | undefined
412
+ // 4. MCP manifest (if routes.tsx exists). Reuses the routes module loaded in
413
+ // section 2.8.
352
414
  if (existsSync(routesFile)) {
353
415
  const { extractMcpManifest } = await import('../mcp/extractor.ts')
354
- const { routes } = await import(routesFile)
355
- loadedRoutes = routes
416
+ const routes = loadedRoutes ?? []
356
417
  const actionsFile = path.join(entryDir, 'actions.ts')
357
418
  const manifest = await extractMcpManifest({
358
419
  actionsFile: existsSync(actionsFile) ? actionsFile : undefined,
@@ -377,7 +438,9 @@ export async function runBuild(args: string[]): Promise<void> {
377
438
  // the other pre-built artifacts (islands, css, mcp-manifest), so a dist-only
378
439
  // deploy ships the templates. The prebuilt runtime reads them from
379
440
  // `<BRUST_DIST_DIR>/jinja` (see index.ts loadJinjaOnce / configureJinjaDir).
380
- const jinjaDir = path.join(outDir, 'jinja')
441
+ // `jinjaDir` is computed in section 2.9, which already emitted the md
442
+ // templates into it; emitNativeTemplates never clears the dir, and the
443
+ // `.brust/jinja` mirror below copies the md templates along.
381
444
  // Spec S7 Component-source resolution: scan the routes module's source for
382
445
  // ImportDeclarations, NOT the app entry's. The app entry only imports the
383
446
  // routes module + brust; the page components are imported by routes.tsx.
@@ -507,5 +570,39 @@ export async function runBuild(args: string[]): Promise<void> {
507
570
  console.log(`[brust build] native: ${name}`)
508
571
  }
509
572
 
573
+ // 8. SSG export (--ssg): boot the just-built dist once and crawl every
574
+ // statically-renderable route into <--ssg-out | outDir/static>. Reuses the
575
+ // routes module ALREADY loaded for the MCP/css steps (loadedRoutes) — no
576
+ // second import. Without the flag this is a strict no-op.
577
+ if (parsed.ssg) {
578
+ const { collectStaticPaths, exportStatic } = await import('./ssg.ts')
579
+ const decisions = collectStaticPaths(
580
+ (loadedRoutes ?? []) as Parameters<typeof collectStaticPaths>[0],
581
+ )
582
+ const staticOut = parsed.ssgOut ?? path.join(outDir, 'static')
583
+ try {
584
+ const { written, skipped } = await exportStatic({
585
+ distDir: outDir,
586
+ entryDir,
587
+ staticOut,
588
+ routes: decisions,
589
+ })
590
+ const counts = new Map<string, number>()
591
+ for (const s of skipped) {
592
+ const r = s.reason ?? 'unknown'
593
+ counts.set(r, (counts.get(r) ?? 0) + 1)
594
+ }
595
+ const reasons = [...counts.keys()]
596
+ .sort()
597
+ .map((r) => `${r}=${counts.get(r)}`)
598
+ .join(', ')
599
+ const skippedDesc = skipped.length > 0 ? ` (skipped ${skipped.length}: ${reasons})` : ''
600
+ console.log(`[brust build] ssg: ${written.length} pages → ${staticOut}${skippedDesc}`)
601
+ } catch (err) {
602
+ console.error(`[brust build] ssg: ${err instanceof Error ? err.message : String(err)}`)
603
+ process.exit(1)
604
+ }
605
+ }
606
+
510
607
  console.log(`[brust build] done.`)
511
608
  }
@@ -89,7 +89,27 @@ export async function runDev(args: string[]): Promise<void> {
89
89
  outDir: jinjaDir,
90
90
  repoRoot: REPO_ROOT,
91
91
  }
92
+ // md routes piggyback on the same emit sites (task 2.8): templates land in
93
+ // the SAME jinja dir, the frozen manifest next to it in `.brust/` (the dist
94
+ // counterpart is written by `brust build`). Dev pages get the WS dev client
95
+ // baked (md pages render Rust-side — no React-renderer injection point).
96
+ // Strict no-op when the app has no md routes.
97
+ const mdEmitOpts = {
98
+ ...emitOpts,
99
+ withDevClient: true,
100
+ manifestDirs: [path.join(process.cwd(), '.brust')],
101
+ }
102
+ // Lazy-load the md emit ONLY when md routes exist: md/emit.ts statically
103
+ // imports the md renderer (marked), and an md-free app must not pay that
104
+ // dependency at `brust dev` startup (same gating as build.ts / index.ts).
105
+ const hasMdRoutes = loadedRoutes.some((r) =>
106
+ (r as { chain?: { __mdSource?: unknown }[] }).chain?.some((n) => n.__mdSource),
107
+ )
108
+ const emitMd = hasMdRoutes
109
+ ? (await import('../md/emit.ts')).emitMdArtifacts
110
+ : async (_opts: typeof mdEmitOpts) => {}
92
111
  await emitNativeTemplates(emitOpts)
112
+ await emitMd(mdEmitOpts)
93
113
 
94
114
  // Native-route HMR: on a ts/html/islands hot reload the dev coordinator calls
95
115
  // this to recompile the .jinja templates from the (edited) source and reload
@@ -101,6 +121,7 @@ export async function runDev(args: string[]): Promise<void> {
101
121
  const { registerJinjaReEmit } = await import('../dev/jinja-reload.ts')
102
122
  registerJinjaReEmit(async () => {
103
123
  await emitNativeTemplates(emitOpts)
124
+ await emitMd(mdEmitOpts)
104
125
  const native = await import('../index.js')
105
126
  ;(native as { napiLoadJinjaTemplates: (dir: string) => unknown }).napiLoadJinjaTemplates(
106
127
  jinjaDir,
@@ -37,6 +37,8 @@ interface CommandDef {
37
37
  summary: string
38
38
  usage: string
39
39
  flags: { flag: string; desc: string }[]
40
+ /** Free-form lines rendered after the Options block (one paragraph per entry). */
41
+ notes?: string[]
40
42
  }
41
43
 
42
44
  export const COMMANDS: CommandDef[] = [
@@ -51,6 +53,19 @@ export const COMMANDS: CommandDef[] = [
51
53
  flag: '--target <t>',
52
54
  desc: 'Native target(s): auto | all | <platform>-<arch>[-<libc>][,…] (default auto)',
53
55
  },
56
+ {
57
+ flag: '--ssg',
58
+ desc: 'Prerender static routes to HTML files after the build',
59
+ },
60
+ {
61
+ flag: '--ssg-out <dir>',
62
+ desc: 'Output directory for prerendered HTML (default <out-dir>/static)',
63
+ },
64
+ ],
65
+ notes: [
66
+ 'Markdown pages: routes mounted with mdRoutes(<contentDir>) compile to native',
67
+ 'jinja templates at build time and freeze into <out-dir>/md-manifest.json, so',
68
+ 'the dist serves them without the markdown content directory present.',
54
69
  ],
55
70
  },
56
71
  {
@@ -126,5 +141,9 @@ export function renderCommandHelp(name: string): string | null {
126
141
  for (const f of c.flags) {
127
142
  lines.push(` ${style.cyan(pad(f.flag, w))} ${f.desc}`)
128
143
  }
144
+ if (c.notes && c.notes.length > 0) {
145
+ lines.push('')
146
+ for (const n of c.notes) lines.push(style.dim(n))
147
+ }
129
148
  return lines.join('\n')
130
149
  }
@@ -1,5 +1,5 @@
1
- import { type Dirent, existsSync, readdirSync, statSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
3
 
4
4
  // Dirs that never hold authored route/component source — skip them so a stray
5
5
  // newer .tsx in a build cache or dependency doesn't force a needless recompile.
@@ -8,6 +8,12 @@ const IGNORED_DIRS = new Set(['node_modules', '.brust', 'dist', '.git'])
8
8
  /** Walk `dir` recursively, returning the newest mtime (ms) of any `.tsx` file
9
9
  * found, or 0 when there are none. Ignored dirs (build caches, deps) are pruned. */
10
10
  function newestTsxMtime(dir: string): number {
11
+ return newestMtimeWithSuffix(dir, '.tsx')
12
+ }
13
+
14
+ /** Walk `dir` recursively for the newest mtime (ms) of files ending in
15
+ * `suffix`, or 0 when there are none. Ignored dirs are pruned. */
16
+ function newestMtimeWithSuffix(dir: string, suffix: string): number {
11
17
  let newest = 0
12
18
  let entries: Dirent[]
13
19
  try {
@@ -18,8 +24,8 @@ function newestTsxMtime(dir: string): number {
18
24
  for (const entry of entries) {
19
25
  if (entry.isDirectory()) {
20
26
  if (IGNORED_DIRS.has(entry.name)) continue
21
- newest = Math.max(newest, newestTsxMtime(join(dir, entry.name)))
22
- } else if (entry.isFile() && entry.name.endsWith('.tsx')) {
27
+ newest = Math.max(newest, newestMtimeWithSuffix(join(dir, entry.name), suffix))
28
+ } else if (entry.isFile() && entry.name.endsWith(suffix)) {
23
29
  try {
24
30
  newest = Math.max(newest, statSync(join(dir, entry.name)).mtimeMs)
25
31
  } catch {
@@ -36,8 +42,14 @@ function newestTsxMtime(dir: string): number {
36
42
  * prior `brust build`, and an edited page is picked up without a stale render.
37
43
  *
38
44
  * Staleness = the build marker (`_manifest.json`, written last by
39
- * `emitNativeTemplates`) is absent, OR any source `.tsx` is newer than it. */
40
- export function isJinjaStale(scanRoot: string, jinjaDir: string): boolean {
45
+ * `emitNativeTemplates`) is absent, OR any source `.tsx` is newer than it.
46
+ *
47
+ * `manifestDir` is where `md-manifest.json` lives. It defaults to
48
+ * `dirname(jinjaDir)` — the layout contract is `jinjaDir == <x>/jinja` with
49
+ * the md manifest written next to it in `<x>` (both `emitMdArtifacts` callers
50
+ * pass `manifestDirs: [<x>]`). A caller that already holds the manifest dir
51
+ * should pass it explicitly instead of relying on that positional contract. */
52
+ export function isJinjaStale(scanRoot: string, jinjaDir: string, manifestDir?: string): boolean {
41
53
  const manifestPath = join(jinjaDir, '_manifest.json')
42
54
  if (!existsSync(manifestPath)) return true
43
55
  let manifestMtime: number
@@ -46,5 +58,41 @@ export function isJinjaStale(scanRoot: string, jinjaDir: string): boolean {
46
58
  } catch {
47
59
  return true
48
60
  }
49
- return newestTsxMtime(scanRoot) > manifestMtime
61
+ if (newestTsxMtime(scanRoot) > manifestMtime) return true
62
+ return isMdStale(manifestDir ?? dirname(jinjaDir))
63
+ }
64
+
65
+ // Filename literal kept local (mirrors MD_MANIFEST_FILENAME in md/routes.ts)
66
+ // so this boot-path module never pulls the md module graph into md-free apps.
67
+ const MD_MANIFEST = 'md-manifest.json'
68
+
69
+ /** Task 2.9: md pages are also "source" for the emitted templates. The frozen
70
+ * `md-manifest.json` in `manifestDir` (e.g. `.brust/`, next to its jinja dir)
71
+ * records every content dir (top-level `contentDir` + optional per-entry
72
+ * override for multi-dir apps); the manifest file itself is the md build
73
+ * marker — `emitMdArtifacts` writes it together with the md `.jinja`
74
+ * templates. Missing or unreadable manifest (no md routes / first boot) →
75
+ * not stale, same behaviour as before. */
76
+ function isMdStale(manifestDir: string): boolean {
77
+ const mdManifestPath = join(manifestDir, MD_MANIFEST)
78
+ if (!existsSync(mdManifestPath)) return false
79
+ let mdManifestMtime: number
80
+ let manifest: { contentDir?: unknown; entries?: Array<{ contentDir?: unknown }> }
81
+ try {
82
+ mdManifestMtime = statSync(mdManifestPath).mtimeMs
83
+ manifest = JSON.parse(readFileSync(mdManifestPath, 'utf8'))
84
+ } catch {
85
+ return false
86
+ }
87
+ const contentDirs = new Set<string>()
88
+ if (typeof manifest?.contentDir === 'string') contentDirs.add(manifest.contentDir)
89
+ if (Array.isArray(manifest?.entries)) {
90
+ for (const e of manifest.entries) {
91
+ if (typeof e?.contentDir === 'string') contentDirs.add(e.contentDir)
92
+ }
93
+ }
94
+ for (const dir of contentDirs) {
95
+ if (newestMtimeWithSuffix(dir, '.md') > mdManifestMtime) return true
96
+ }
97
+ return false
50
98
  }
@@ -231,8 +231,12 @@ export function countMainTags(template: string): number {
231
231
  * Inserted before the first `</head>` when the page has one (a `<BrustPage>`
232
232
  * shell); otherwise appended (bare-fragment pages like a plain `<div>`). The
233
233
  * browser executes the module script in either position. Gated on `BRUST_DEV`
234
- * so `brust build` never bakes it into production templates. */
235
- function injectDevClientIntoTemplate(template: string): string {
234
+ * so `brust build` never bakes it into production templates.
235
+ *
236
+ * Exported for the md emit step (runtime/md/emit.ts), which bakes the same tag
237
+ * under its `withDevClient` option — md pages render Rust-side too, so without
238
+ * it they never auto-reload in dev. */
239
+ export function injectDevClientIntoTemplate(template: string): string {
236
240
  const tag = buildDevClientTag()
237
241
  if (template.includes(tag)) return template // idempotent across re-emits
238
242
  const headClose = template.indexOf('</head>')
@@ -276,7 +280,9 @@ export interface NativeRouteEmitOpts {
276
280
  * each carrying its `Component`) drives T2 native-chain composition. */
277
281
  flatRoutes: {
278
282
  nativeTemplate?: string
279
- chain?: Array<{ Component?: { name?: string } }>
283
+ /** Chain nodes parent→leaf. A leaf carrying `__mdSource` is a markdown
284
+ * page (runtime/md/routes.ts) — emitted by `emitMdTemplates`, NOT here. */
285
+ chain?: Array<{ Component?: { name?: string }; __mdSource?: unknown }>
280
286
  }[]
281
287
  /** `.brust/jinja` absolute output dir. Created if missing. */
282
288
  outDir: string
@@ -347,8 +353,11 @@ function toRelativeSpecifier(from: string, to: string): string {
347
353
  * path: `components.json` stores `sourcePath` relative to the project root
348
354
  * (cwd); `.factory.ts` imports relative to its own location (`.brust/jinja/`).
349
355
  * `enriched` keeps the ABSOLUTE path internally for the build-time island scan
350
- * below (`readFileSync`). */
351
- function emitComponentArtifacts(
356
+ * below (`readFileSync`).
357
+ *
358
+ * Exported for the md emit step (runtime/md/emit.ts), whose chained wrappers
359
+ * can carry layout SSR components and need the same sidecar emission. */
360
+ export function emitComponentArtifacts(
352
361
  jinjaPath: string,
353
362
  componentsJsonStr: string,
354
363
  pageImports: Map<string, ResolvedImport>,
@@ -471,7 +480,13 @@ function emitComponentArtifacts(
471
480
  export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<void> {
472
481
  mkdirSync(opts.outDir, { recursive: true })
473
482
 
474
- const nativeRoutes = opts.flatRoutes.filter((r) => r.nativeTemplate)
483
+ // md-route exclusion lives HERE (not at the call sites): a chain whose LEAF
484
+ // carries `__mdSource` is a markdown page emitted by `emitMdTemplates` —
485
+ // its synthetic template name has no routes-entry import, so letting it
486
+ // through would log a bogus "no import → skip" warning per md page.
487
+ const nativeRoutes = opts.flatRoutes.filter(
488
+ (r) => r.nativeTemplate && !r.chain?.[r.chain.length - 1]?.__mdSource,
489
+ )
475
490
 
476
491
  // Compile through the napi addon's `compileJsx` rather than spawning the
477
492
  // `jsx-rustc` binary. The binary only exists in the source tree's target/
@@ -881,13 +896,16 @@ export function scanImports(entryFile: string): Map<string, string> {
881
896
  * `.islands.json`.
882
897
  * 4. Append `{% raw %}…{% endraw %}`-wrapped bootstrap to the `.jinja`. The raw
883
898
  * block keeps the importmap's literal `}}`/`{{` inert through minijinja's
884
- * boot-time compile.
899
+ * boot-time compile. The md emit step passes `bakeBootstrap: false` and bakes
900
+ * once itself at the END of its pipeline (the append here has no `includes()`
901
+ * guard, so a second pass over the same template would double-bake).
885
902
  */
886
903
  export function reconcileIslandManifest(
887
904
  jinjaPath: string,
888
905
  islandsJsonPath: string,
889
906
  pageImports: Map<string, ResolvedImport>,
890
907
  routeName: string,
908
+ options?: { bakeBootstrap?: boolean },
891
909
  ): void {
892
910
  if (!existsSync(islandsJsonPath)) return
893
911
 
@@ -941,6 +959,10 @@ export function reconcileIslandManifest(
941
959
  },
942
960
  )
943
961
 
962
+ if (options?.bakeBootstrap === false) {
963
+ writeFileSync(jinjaPath, jinja)
964
+ return
965
+ }
944
966
  const baked = `{% raw %}${ISLANDS_IMPORTMAP_AND_BOOTSTRAP}{% endraw %}`
945
967
  writeFileSync(jinjaPath, jinja + baked)
946
968
  }