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.
- package/README.md +34 -0
- package/example/pokedex/components/ThemeToggle.tsx +11 -3
- package/package.json +16 -8
- package/runtime/cli/build.ts +123 -26
- package/runtime/cli/dev.ts +21 -0
- package/runtime/cli/help.ts +19 -0
- package/runtime/cli/jinja-staleness.ts +55 -7
- package/runtime/cli/native-routes-emit.ts +29 -7
- package/runtime/cli/ssg.ts +257 -0
- package/runtime/dev/coordinator.ts +16 -4
- package/runtime/dev/watcher.ts +16 -5
- package/runtime/index.js +52 -52
- package/runtime/index.ts +68 -3
- package/runtime/islands/bootstrap.ts +23 -0
- package/runtime/islands/build.ts +23 -1
- package/runtime/islands/native-render.ts +16 -3
- package/runtime/md/emit.ts +544 -0
- package/runtime/md/render.ts +469 -0
- package/runtime/md/routes.ts +347 -0
- package/runtime/md/scan.ts +175 -0
- package/runtime/native/build.ts +9 -1
- package/runtime/native/index.ts +4 -1
- package/runtime/native/runtime.ts +33 -2
- package/runtime/routes.ts +13 -0
- package/runtime/store/signal.ts +40 -3
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
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// /theme action which sets the `mode` cookie — so SSR matches on the next load.
|
|
12
12
|
import { Moon, Sun } from 'lucide-react'
|
|
13
13
|
import { client } from 'brustjs/client'
|
|
14
|
+
import type { BehaviorCtx } from 'brustjs/native'
|
|
14
15
|
import { computed, signal } from 'brustjs/store'
|
|
15
16
|
import type { Actions } from '../actions'
|
|
16
17
|
|
|
@@ -18,7 +19,7 @@ const api = client<Actions>()
|
|
|
18
19
|
|
|
19
20
|
// behavior → client bundle, registered as "themeToggle". Reads the initial mode
|
|
20
21
|
// straight off <html data-mode> (server already set it from the cookie).
|
|
21
|
-
export const behavior = () => {
|
|
22
|
+
export const behavior = ({ effect }: BehaviorCtx) => {
|
|
22
23
|
const mode = signal(
|
|
23
24
|
typeof document !== 'undefined' ? (document.documentElement.dataset.mode ?? 'dark') : 'dark',
|
|
24
25
|
)
|
|
@@ -28,10 +29,17 @@ export const behavior = () => {
|
|
|
28
29
|
const isDark = computed(() => mode() === 'dark')
|
|
29
30
|
const isLight = computed(() => mode() === 'light')
|
|
30
31
|
|
|
32
|
+
// Reactive sync (dogfooding ctx `effect`): reflect `mode` onto <html data-mode>
|
|
33
|
+
// whenever it changes — the toggle just flips the signal, the DOM follows. On
|
|
34
|
+
// mount this runs once and writes the value the server already set (a no-op, so
|
|
35
|
+
// no flash). Replaces the imperative `dataset.mode = …` that lived in toggle().
|
|
36
|
+
effect(() => {
|
|
37
|
+
document.documentElement.dataset.mode = mode()
|
|
38
|
+
})
|
|
39
|
+
|
|
31
40
|
async function toggle() {
|
|
32
41
|
const next = mode() === 'dark' ? 'light' : 'dark'
|
|
33
|
-
|
|
34
|
-
mode.set(next)
|
|
42
|
+
mode.set(next) // the effect flips <html data-mode> reactively
|
|
35
43
|
await api.theme.post({ mode: next }) // persist via cookie for the next SSR
|
|
36
44
|
}
|
|
37
45
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brustjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.40-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.
|
|
44
|
-
"brustjs-darwin-arm64": "0.1.
|
|
45
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
46
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
48
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
44
|
+
"brustjs-darwin-x64": "0.1.40-alpha",
|
|
45
|
+
"brustjs-darwin-arm64": "0.1.40-alpha",
|
|
46
|
+
"brustjs-linux-x64-gnu": "0.1.40-alpha",
|
|
47
|
+
"brustjs-linux-arm64-gnu": "0.1.40-alpha",
|
|
48
|
+
"brustjs-linux-x64-musl": "0.1.40-alpha",
|
|
49
|
+
"brustjs-linux-arm64-musl": "0.1.40-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",
|
package/runtime/cli/build.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/runtime/cli/dev.ts
CHANGED
|
@@ -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,
|
package/runtime/cli/help.ts
CHANGED
|
@@ -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,
|
|
22
|
-
} else if (entry.isFile() && entry.name.endsWith(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|