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/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
- if (isJinjaStale(scanRoot, jinjaDir)) {
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
- const islandMap = scanIslandChunks(routesPath)
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)
@@ -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(routesEntryFile: string): Map<string, string> {
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
- // Empty propsPath = a propless island `{}`. (pathInto('') returns the whole
164
- // context, which is NOT what an absent `props` attr means.)
165
- const props = entry.propsPath === '' ? {} : pathInto(data, entry.propsPath)
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