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/runtime/index.ts
CHANGED
|
@@ -372,6 +372,15 @@ export const brust = {
|
|
|
372
372
|
console.log(`[brust] main: spawning ${workers} worker threads`)
|
|
373
373
|
if (cacheMaxEntries !== undefined) this.configureCache({ maxEntries: cacheMaxEntries })
|
|
374
374
|
|
|
375
|
+
// md routes present? (leaf carries `__mdSource`, attached by mdRoutes()).
|
|
376
|
+
// Checked inline — no md-module import — so md-free apps never load the
|
|
377
|
+
// markdown pipeline. Gates the md emits below AND in the dev coordinator.
|
|
378
|
+
const hasMdRoutes = opts.routes.some(
|
|
379
|
+
(r) =>
|
|
380
|
+
(r.chain[r.chain.length - 1] as { __mdSource?: unknown } | undefined)?.__mdSource !==
|
|
381
|
+
undefined,
|
|
382
|
+
)
|
|
383
|
+
|
|
375
384
|
// Component CSS pipeline. Build the manifest + capture the loader plugin
|
|
376
385
|
// BEFORE buildIslands and pass it EXPLICITLY below (global Bun.plugin()
|
|
377
386
|
// does NOT reach Bun.build) — otherwise Bun's default loader emits
|
|
@@ -439,8 +448,37 @@ export const brust = {
|
|
|
439
448
|
} else {
|
|
440
449
|
const routesPath = path.join(scanRoot, 'routes.tsx')
|
|
441
450
|
if (existsSync(routesPath)) {
|
|
451
|
+
// md routes (task 2.8): emit `Md_*.jinja` + `.brust/md-manifest.json`
|
|
452
|
+
// BEFORE the island scan so islands used only from md content get
|
|
453
|
+
// chunks. Runs every boot when md routes exist (isJinjaStale below
|
|
454
|
+
// only watches .tsx — it can't see edited .md files); strict no-op
|
|
455
|
+
// otherwise. A failure degrades like the staleness re-emit: warn and
|
|
456
|
+
// keep booting (non-md routes still serve).
|
|
457
|
+
let mdIslands = new Map<string, string>()
|
|
458
|
+
if (hasMdRoutes) {
|
|
459
|
+
try {
|
|
460
|
+
const { emitMdArtifacts } = await import('./md/emit.ts')
|
|
461
|
+
;({ mdIslands } = await emitMdArtifacts({
|
|
462
|
+
entryFile: routesPath,
|
|
463
|
+
flatRoutes: opts.routes,
|
|
464
|
+
outDir: path.resolve(process.cwd(), '.brust/jinja'),
|
|
465
|
+
withDevClient: dev,
|
|
466
|
+
manifestDirs: [path.resolve(process.cwd(), '.brust')],
|
|
467
|
+
}))
|
|
468
|
+
} catch (err) {
|
|
469
|
+
console.warn(
|
|
470
|
+
`[brust] main: md template emit failed (run \`brust build\`): ${(err as Error).message}`,
|
|
471
|
+
)
|
|
472
|
+
// The stale templates on disk (if any) still LOAD below — md
|
|
473
|
+
// routes then silently serve previous content. Make that state
|
|
474
|
+
// operator-visible instead of indistinguishable from fresh.
|
|
475
|
+
console.warn(
|
|
476
|
+
'[brust] main: md routes may be serving previously-built templates (stale content)',
|
|
477
|
+
)
|
|
478
|
+
}
|
|
479
|
+
}
|
|
442
480
|
const { scanIslandChunks, buildIslands: build } = await import('./islands/build.ts')
|
|
443
|
-
const islandMap = scanIslandChunks(routesPath)
|
|
481
|
+
const islandMap = scanIslandChunks(routesPath, mdIslands)
|
|
444
482
|
let islandsDir: string | undefined
|
|
445
483
|
if (islandMap.size > 0) {
|
|
446
484
|
const islands = await build(islandMap, { plugins: cssBuildPlugins })
|
|
@@ -518,11 +556,17 @@ export const brust = {
|
|
|
518
556
|
// entirely. A compile failure warns and continues (non-native routes still
|
|
519
557
|
// boot). The heavy emitter is imported lazily so it never enters the hot
|
|
520
558
|
// path (or the prebuilt bundle's live code).
|
|
559
|
+
// (md templates need no staleness check here: the island block above
|
|
560
|
+
// already re-emitted them this boot — emitMdArtifacts runs whenever md
|
|
561
|
+
// routes exist, under the same !prebuilt + routes.tsx guards.)
|
|
521
562
|
if (!prebuilt) {
|
|
522
563
|
const routesPath = path.join(scanRoot, 'routes.tsx')
|
|
523
564
|
if (existsSync(routesPath)) {
|
|
524
565
|
const { isJinjaStale } = await import('./cli/jinja-staleness.ts')
|
|
525
|
-
|
|
566
|
+
// manifestDir passed explicitly: it is the same `.brust` dir the md
|
|
567
|
+
// emit above wrote `md-manifest.json` into (manifestDirs) — no
|
|
568
|
+
// reliance on the dirname(jinjaDir) positional default.
|
|
569
|
+
if (isJinjaStale(scanRoot, jinjaDir, path.resolve(process.cwd(), '.brust'))) {
|
|
526
570
|
try {
|
|
527
571
|
const { emitNativeTemplates } = await import('./cli/native-routes-emit.ts')
|
|
528
572
|
await emitNativeTemplates({
|
|
@@ -633,7 +677,25 @@ export const brust = {
|
|
|
633
677
|
const routesPath = pathModule.join(scanRoot, 'routes.tsx')
|
|
634
678
|
if (fsModule.existsSync(routesPath)) {
|
|
635
679
|
const { scanIslandChunks, buildIslands } = await import('./islands/build.ts')
|
|
636
|
-
|
|
680
|
+
// md routes: re-emit (md content may be what changed) so the
|
|
681
|
+
// island scan below sees the current md-content islands. Same
|
|
682
|
+
// warn-and-continue degrade as the boot emit.
|
|
683
|
+
let mdIslands = new Map<string, string>()
|
|
684
|
+
if (hasMdRoutes) {
|
|
685
|
+
try {
|
|
686
|
+
const { emitMdArtifacts } = await import('./md/emit.ts')
|
|
687
|
+
;({ mdIslands } = await emitMdArtifacts({
|
|
688
|
+
entryFile: routesPath,
|
|
689
|
+
flatRoutes: opts.routes,
|
|
690
|
+
outDir: pathModule.resolve(process.cwd(), '.brust/jinja'),
|
|
691
|
+
withDevClient: true,
|
|
692
|
+
manifestDirs: [pathModule.resolve(process.cwd(), '.brust')],
|
|
693
|
+
}))
|
|
694
|
+
} catch (err) {
|
|
695
|
+
console.warn(`[brust] dev: md template emit failed: ${(err as Error).message}`)
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const islandMap = scanIslandChunks(routesPath, mdIslands)
|
|
637
699
|
if (islandMap.size > 0) {
|
|
638
700
|
await buildIslands(islandMap)
|
|
639
701
|
}
|
|
@@ -689,6 +751,9 @@ export const brust = {
|
|
|
689
751
|
|
|
690
752
|
createWatcher({
|
|
691
753
|
root: scanRoot,
|
|
754
|
+
// md-free apps must not restart workers on stray .md edits
|
|
755
|
+
// (README.md etc.) — gate the watcher's 'md' kind (S4).
|
|
756
|
+
hasMdRoutes,
|
|
692
757
|
onChange: (ev) => {
|
|
693
758
|
void coordinator.handleChange(ev)
|
|
694
759
|
},
|
|
@@ -226,6 +226,20 @@ export function isInternalLink(a: HTMLAnchorElement, event: MouseEvent): boolean
|
|
|
226
226
|
return classifyClick(a, event) === 'navigate'
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
/** True iff a navigation payload is a FULL HTML document rather than the inner
|
|
230
|
+
* `<main>` content of a shared shell. A standalone route (one that renders its
|
|
231
|
+
* own `<html>` with no `<main>`) ships its whole document here — see the no-main
|
|
232
|
+
* contract in routes.ts navigationBranch. Such a payload CANNOT be swapped into
|
|
233
|
+
* the current shell's `<main>` (it would nest a second document inside the live
|
|
234
|
+
* chrome — duplicate topbar/sidebar). The navigator falls back to a full load.
|
|
235
|
+
*
|
|
236
|
+
* Inner `<main>` content is the element's children and can never begin with
|
|
237
|
+
* `<html>`/`<!doctype>`, so this prefix sniff has no false positives. */
|
|
238
|
+
export function isFullDocumentPayload(html: string): boolean {
|
|
239
|
+
const head = html.trimStart().slice(0, 16).toLowerCase()
|
|
240
|
+
return head.startsWith('<!doctype') || head.startsWith('<html')
|
|
241
|
+
}
|
|
242
|
+
|
|
229
243
|
let inFlight: AbortController | null = null
|
|
230
244
|
|
|
231
245
|
export async function navigate(url: URL, mode: 'push' | 'replace' | 'none'): Promise<void> {
|
|
@@ -244,6 +258,15 @@ export async function navigate(url: URL, mode: 'push' | 'replace' | 'none'): Pro
|
|
|
244
258
|
title: string
|
|
245
259
|
store?: Record<string, Record<string, unknown>>
|
|
246
260
|
}
|
|
261
|
+
// A standalone (no-<main>) route ships its FULL document here. We can't swap
|
|
262
|
+
// that into the current shell's <main> without nesting a second document
|
|
263
|
+
// (duplicate chrome — the classic two-topbars artifact), and the current
|
|
264
|
+
// document's own <main> existence can't tell us the TARGET has one. Detect
|
|
265
|
+
// the full-document payload and fall back to the authoritative full load.
|
|
266
|
+
if (isFullDocumentPayload(html)) {
|
|
267
|
+
location.href = url.href
|
|
268
|
+
return
|
|
269
|
+
}
|
|
247
270
|
const main = document.querySelector('main')
|
|
248
271
|
if (!main) throw new Error('navigation: no <main> element')
|
|
249
272
|
unmountIslandsIn(main as HTMLElement)
|
package/runtime/islands/build.ts
CHANGED
|
@@ -41,8 +41,19 @@ export interface BuildIslandsOptions {
|
|
|
41
41
|
* marker carries this same id (native: reconcileIslandManifest rewrite;
|
|
42
42
|
* React: the Component→id registry seeded at worker boot), so there is no
|
|
43
43
|
* app-unique-name requirement.
|
|
44
|
+
*
|
|
45
|
+
* `extraIslands` (task 2.8) merges additional islands the routes-graph scan
|
|
46
|
+
* cannot see — md-route islands resolved by `emitMdTemplates` (`name →
|
|
47
|
+
* absolute source path`, the bare-name map it returns). Each is keyed by the
|
|
48
|
+
* same content-addressed id, so same name + same path dedups against the scan
|
|
49
|
+
* result; same name + different path yields a distinct id (two chunks, the
|
|
50
|
+
* shipped same-name parity). A same-id-different-path collision is a hard
|
|
51
|
+
* error — never silently rebind a chunk id.
|
|
44
52
|
*/
|
|
45
|
-
export function scanIslandChunks(
|
|
53
|
+
export function scanIslandChunks(
|
|
54
|
+
routesEntryFile: string,
|
|
55
|
+
extraIslands?: Map<string, string>,
|
|
56
|
+
): Map<string, string> {
|
|
46
57
|
const chunks = new Map<string, string>()
|
|
47
58
|
const visited = new Set<string>()
|
|
48
59
|
|
|
@@ -92,6 +103,17 @@ export function scanIslandChunks(routesEntryFile: string): Map<string, string> {
|
|
|
92
103
|
}
|
|
93
104
|
}
|
|
94
105
|
|
|
106
|
+
for (const [name, src] of extraIslands ?? []) {
|
|
107
|
+
const id = islandChunkBasename(name, src)
|
|
108
|
+
const existing = chunks.get(id)
|
|
109
|
+
if (existing !== undefined && existing !== src) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`island chunk id "${id}" resolves to two different sources: ${existing} and ${src}`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
chunks.set(id, src)
|
|
115
|
+
}
|
|
116
|
+
|
|
95
117
|
return chunks
|
|
96
118
|
}
|
|
97
119
|
|
|
@@ -42,6 +42,11 @@ export interface NativeIslandEntry {
|
|
|
42
42
|
component: string
|
|
43
43
|
instance: number
|
|
44
44
|
propsPath: string
|
|
45
|
+
/** Literal props value baked at build time (md pages). When present
|
|
46
|
+
* (`!== undefined` — `null` and falsy literals are valid), it is used as the
|
|
47
|
+
* props value and `propsPath` is ignored (md entries carry `propsPath: ''`
|
|
48
|
+
* since the field is required). */
|
|
49
|
+
propsLiteral?: unknown
|
|
45
50
|
ssr: boolean
|
|
46
51
|
hydrate: string
|
|
47
52
|
sourcePath: string
|
|
@@ -160,9 +165,17 @@ export async function resolveIslandContext(
|
|
|
160
165
|
): Promise<Record<string, string>> {
|
|
161
166
|
const out: Record<string, string> = {}
|
|
162
167
|
for (const entry of manifest) {
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
168
|
+
// Build-time literal props (md pages) win over any loader-data path —
|
|
169
|
+
// guarded with `!== undefined` only, so `0` / `''` / `false` / `null`
|
|
170
|
+
// literals are honored. Otherwise: empty propsPath = a propless island →
|
|
171
|
+
// `{}`. (pathInto('') returns the whole context, which is NOT what an
|
|
172
|
+
// absent `props` attr means.)
|
|
173
|
+
const props =
|
|
174
|
+
entry.propsLiteral !== undefined
|
|
175
|
+
? entry.propsLiteral
|
|
176
|
+
: entry.propsPath === ''
|
|
177
|
+
? {}
|
|
178
|
+
: pathInto(data, entry.propsPath)
|
|
166
179
|
// `?? null` handles undefined props; the `?? 'null'` belt-and-braces covers
|
|
167
180
|
// the case where JSON.stringify itself returns undefined (e.g. a function
|
|
168
181
|
// value), so entityEncode never receives undefined. Hoisted ABOVE the ssr
|