brustjs 0.1.39-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/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/routes.ts +13 -0
|
@@ -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
|
+
}
|