brustjs 0.1.28-alpha → 0.1.30-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.
@@ -17,12 +17,12 @@ import { DIRECTIVES_BOOTSTRAP, ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../island
17
17
  * compiler. */
18
18
  export function gatherComponentSources(pageSourcePath: string): {
19
19
  sources: Record<string, string>
20
- mergedImports: Map<string, string>
20
+ mergedImports: Map<string, ResolvedImport>
21
21
  } {
22
22
  const sources: Record<string, string> = {}
23
- // mergedImports accumulates ident→resolvedPath across ALL visited files.
23
+ // mergedImports accumulates ident→ResolvedImport across ALL visited files.
24
24
  // The page's own imports win on conflict (inserted last below).
25
- const mergedImports = new Map<string, string>()
25
+ const mergedImports = new Map<string, ResolvedImport>()
26
26
  const visited = new Set<string>()
27
27
 
28
28
  // Queue items: { ident, resolvedPath } — ident is how the PARENT imported it.
@@ -36,11 +36,11 @@ export function gatherComponentSources(pageSourcePath: string): {
36
36
  // Record source keyed by ident — detect ambiguity.
37
37
  if (ident in sources) {
38
38
  // Same ident already seen — only an error if it resolves to a different path.
39
- // We get the previous path from mergedImports (which maps ident → resolvedPath).
40
- const existingPath = mergedImports.get(ident)
41
- if (existingPath && existingPath !== filePath) {
39
+ // We get the previous spec from mergedImports (which maps ident → ResolvedImport).
40
+ const existing = mergedImports.get(ident)
41
+ if (existing && existing.spec !== filePath) {
42
42
  throw new Error(
43
- `native build: ambiguous component ident "${ident}" resolves to two paths: ${existingPath} and ${filePath}`,
43
+ `native build: ambiguous component ident "${ident}" resolves to two paths: ${existing.spec} and ${filePath}`,
44
44
  )
45
45
  }
46
46
  } else {
@@ -48,36 +48,43 @@ export function gatherComponentSources(pageSourcePath: string): {
48
48
  }
49
49
 
50
50
  // Scan this file's imports and recurse into local ones.
51
- const childImports = scanImports(filePath)
52
- for (const [childIdent, childPath] of childImports) {
53
- // Merge into mergedImports — check for ambiguity.
51
+ const childImports = scanImportRefs(filePath)
52
+ for (const [childIdent, ref] of childImports) {
53
+ // Merge into mergedImports — check for ambiguity (compare by spec). Only
54
+ // Capitalized idents can be COMPONENT sources; lowercase idents (stores,
55
+ // hooks, utils) legitimately repeat across files and must not throw.
54
56
  const existing = mergedImports.get(childIdent)
55
- if (existing !== undefined && existing !== childPath) {
56
- throw new Error(
57
- `native build: ambiguous component ident "${childIdent}" resolves to two paths: ${existing} and ${childPath}`,
58
- )
57
+ if (existing !== undefined && existing.spec !== ref.spec) {
58
+ if (isComponentIdent(childIdent)) {
59
+ throw new Error(
60
+ `native build: ambiguous component ident "${childIdent}" resolves to two paths: ${existing.spec} and ${ref.spec}`,
61
+ )
62
+ }
63
+ // Non-component collision — keep the first, don't recurse the duplicate.
64
+ continue
59
65
  }
60
66
  if (existing === undefined) {
61
- mergedImports.set(childIdent, childPath)
67
+ mergedImports.set(childIdent, ref)
62
68
  }
63
- // Recurse into local files (skip node_modules / unresolved paths).
64
- if (!childPath.includes('node_modules')) {
65
- visit(childPath, childIdent)
69
+ // Recurse into LOCAL files only (a bare spec has no readable file —
70
+ // !bare, not the old node_modules string-check which a bare spec evades).
71
+ if (!ref.bare) {
72
+ visit(ref.spec, childIdent)
66
73
  }
67
74
  }
68
75
  }
69
76
 
70
77
  // Seed: scan the page's own imports and visit each local file.
71
- const pageImports = scanImports(pageSourcePath)
72
- for (const [ident, resolvedPath] of pageImports) {
73
- if (!resolvedPath.includes('node_modules')) {
74
- visit(resolvedPath, ident)
78
+ const pageImports = scanImportRefs(pageSourcePath)
79
+ for (const [ident, ref] of pageImports) {
80
+ if (!ref.bare) {
81
+ visit(ref.spec, ident)
75
82
  }
76
83
  }
77
84
 
78
85
  // Page's own imports win on ident conflict — merge them last.
79
- for (const [ident, resolvedPath] of pageImports) {
80
- mergedImports.set(ident, resolvedPath)
86
+ for (const [ident, ref] of pageImports) {
87
+ mergedImports.set(ident, ref)
81
88
  }
82
89
 
83
90
  return { sources, mergedImports }
@@ -99,9 +106,9 @@ export function gatherComponentSources(pageSourcePath: string): {
99
106
  export function gatherChainSources(
100
107
  chainNames: string[],
101
108
  importMap: Map<string, string>,
102
- ): { sources: Record<string, string>; mergedImports: Map<string, string> } {
109
+ ): { sources: Record<string, string>; mergedImports: Map<string, ResolvedImport> } {
103
110
  const sources: Record<string, string> = {}
104
- const mergedImports = new Map<string, string>()
111
+ const mergedImports = new Map<string, ResolvedImport>()
105
112
 
106
113
  for (const compName of chainNames) {
107
114
  const compPath = importMap.get(compName)
@@ -116,20 +123,28 @@ export function gatherChainSources(
116
123
  const { sources: subSources, mergedImports: subImports } = gatherComponentSources(compPath)
117
124
  for (const [ident, src] of Object.entries(subSources)) {
118
125
  if (ident in sources && sources[ident] !== src) {
119
- throw new Error(
120
- `native build: ambiguous component ident "${ident}" two different sources in one chain`,
121
- )
126
+ // Only Capitalized idents are COMPONENT sources; a lowercase collision
127
+ // (e.g. two stores both named `teamStore`) is legitimate keep first.
128
+ if (isComponentIdent(ident)) {
129
+ throw new Error(
130
+ `native build: ambiguous component ident "${ident}" — two different sources in one chain`,
131
+ )
132
+ }
133
+ continue
122
134
  }
123
135
  sources[ident] = src
124
136
  }
125
- for (const [ident, p] of subImports) {
137
+ for (const [ident, ref] of subImports) {
126
138
  const existing = mergedImports.get(ident)
127
- if (existing !== undefined && existing !== p) {
128
- throw new Error(
129
- `native build: ambiguous component ident "${ident}" resolves to two paths: ${existing} and ${p}`,
130
- )
139
+ if (existing !== undefined && existing.spec !== ref.spec) {
140
+ if (isComponentIdent(ident)) {
141
+ throw new Error(
142
+ `native build: ambiguous component ident "${ident}" resolves to two paths: ${existing.spec} and ${ref.spec}`,
143
+ )
144
+ }
145
+ continue
131
146
  }
132
- mergedImports.set(ident, p)
147
+ mergedImports.set(ident, ref)
133
148
  }
134
149
 
135
150
  // Inject the chain component's OWN source keyed by its ident — it is the
@@ -142,7 +157,9 @@ export function gatherChainSources(
142
157
  )
143
158
  }
144
159
  sources[compName] = ownSrc
145
- mergedImports.set(compName, compPath)
160
+ // The chain component is resolved by name from the routes entry — a LOCAL
161
+ // default-imported source file (route-name resolution is local-only).
162
+ mergedImports.set(compName, { spec: compPath, bare: false, kind: 'default' })
146
163
  }
147
164
 
148
165
  return { sources, mergedImports }
@@ -332,7 +349,7 @@ function toRelativeSpecifier(from: string, to: string): string {
332
349
  function emitComponentArtifacts(
333
350
  jinjaPath: string,
334
351
  componentsJsonStr: string,
335
- pageImports: Map<string, string>,
352
+ pageImports: Map<string, ResolvedImport>,
336
353
  routeName: string,
337
354
  ): { islandIdsFromComponents: string[] } {
338
355
  const raw = JSON.parse(componentsJsonStr) as RawComponentEntry[]
@@ -341,25 +358,27 @@ function emitComponentArtifacts(
341
358
  const jinjaDir = dirname(jinjaPath)
342
359
  const projectRoot = process.cwd()
343
360
 
344
- // Enrich with ABSOLUTE source paths resolved from page's own imports kept
345
- // absolute for the readFileSync island scan further down.
346
- const enriched: EnrichedComponentEntry[] = raw.map((entry) => {
347
- const sourcePath = pageImports.get(entry.component)
348
- if (!sourcePath) {
361
+ // Enrich with the resolved import ref. For local imports `ref.spec` is an
362
+ // ABSOLUTE path (kept absolute for the readFileSync island scan below); for
363
+ // bare imports it's the verbatim package specifier.
364
+ const enriched: Array<EnrichedComponentEntry & { ref: ResolvedImport }> = raw.map((entry) => {
365
+ const ref = pageImports.get(entry.component)
366
+ if (!ref) {
349
367
  throw new Error(
350
368
  `SSR component "${entry.component}" in native route "${routeName}" has no matching import in the page source (expected \`import ${entry.component} from "..."\`)`,
351
369
  )
352
370
  }
353
- return { ...entry, sourcePath }
371
+ return { ...entry, sourcePath: ref.spec, ref }
354
372
  })
355
373
 
356
- // Write <Name>.components.json with PROJECT-RELATIVE sourcePaths. (sourcePath
357
- // is build-time metadata resolveComponentContext imports the factory, not
358
- // these paths so relative is purely a portability/readability win.)
374
+ // Write <Name>.components.json. For LOCAL imports sourcePath is PROJECT-RELATIVE
375
+ // (cwd-relative — no build-machine path baked in); for BARE imports it's the
376
+ // package spec verbatim. (sourcePath is build-time metadata the factory
377
+ // import is what's load-bearing at runtime.)
359
378
  const compJsonPath = jinjaPath.replace(/\.jinja$/, '.components.json')
360
- const compJsonEntries = enriched.map((e) => ({
379
+ const compJsonEntries = enriched.map(({ ref, ...e }) => ({
361
380
  ...e,
362
- sourcePath: relative(projectRoot, e.sourcePath).replaceAll('\\', '/'),
381
+ sourcePath: ref.bare ? ref.spec : relative(projectRoot, ref.spec).replaceAll('\\', '/'),
363
382
  }))
364
383
  writeFileSync(compJsonPath, JSON.stringify(compJsonEntries))
365
384
 
@@ -374,25 +393,38 @@ function emitComponentArtifacts(
374
393
  importLines.push("import { Island } from 'brustjs'")
375
394
  }
376
395
 
377
- // Import each referenced component RELATIVE to the factory file's own dir so
378
- // `await import(factory)` resolves them at runtime regardless of where the
379
- // project lives (no absolute build-machine path baked in).
396
+ // Import each referenced component, regenerating the correct import FORM per
397
+ // kind. Local specs are relativized to the factory file's own dir (so
398
+ // `await import(factory)` resolves them at runtime regardless of project
399
+ // location); bare specs are kept verbatim.
380
400
  const allReferenced = [...new Set(enriched.flatMap((e) => e.referencedComponents))]
381
401
  for (const compName of allReferenced) {
382
402
  if (seen.has(compName)) continue
383
403
  seen.add(compName)
384
- const srcPath = pageImports.get(compName)
385
- if (srcPath) {
386
- const spec = toRelativeSpecifier(jinjaDir, srcPath)
387
- importLines.push(`import ${compName} from ${JSON.stringify(spec)}`)
404
+ const ref = pageImports.get(compName)
405
+ if (!ref) continue
406
+ const spec = ref.bare ? ref.spec : toRelativeSpecifier(jinjaDir, ref.spec)
407
+ const specStr = JSON.stringify(spec)
408
+ if (ref.kind === 'namespace') {
409
+ importLines.push(`import * as ${compName} from ${specStr}`)
410
+ } else if (ref.kind === 'named') {
411
+ // Collapse the redundant `{ X as X }` to `{ X }` when the local name equals
412
+ // the imported name (idiomatic + avoids no-useless-rename lint on the factory).
413
+ const named =
414
+ ref.imported && ref.imported !== compName ? `${ref.imported} as ${compName}` : compName
415
+ importLines.push(`import { ${named} } from ${specStr}`)
416
+ } else {
417
+ importLines.push(`import ${compName} from ${specStr}`)
388
418
  }
389
419
  }
390
420
 
391
421
  // Scan SSR component sources for <Island component={X}> to discover Island
392
- // chunk identifiers that don't appear in the page's own .islands.json.
422
+ // chunk identifiers that don't appear in the page's own .islands.json. Bare
423
+ // imports have no readable local file — skip them (no readFileSync attempt).
393
424
  const islandIdsFromComponents: string[] = []
394
425
  const islandAttrRe = /<Island\s[^>]*component=\{(\w+)\}/g
395
426
  for (const entry of enriched) {
427
+ if (entry.ref.bare) continue
396
428
  try {
397
429
  const src = readFileSync(entry.sourcePath, 'utf8')
398
430
  islandAttrRe.lastIndex = 0
@@ -512,7 +544,7 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
512
544
  let routeSource: string
513
545
  let routeSourcePath: string
514
546
  let sources: Record<string, string>
515
- let mergedImports: Map<string, string>
547
+ let mergedImports: Map<string, ResolvedImport>
516
548
  if (chainNames.length > 1) {
517
549
  routeSource = buildChainWrapperSource(chainNames)
518
550
  // Synthetic path: a placeholder under the leaf's dir. The compiler keys
@@ -585,9 +617,129 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
585
617
  )
586
618
  }
587
619
 
620
+ /** A JSX SSR-component ident is always Capitalized — `<Search/>` lowers to an
621
+ * SsrComponent while `<search/>` is a host element. The Rust compiler only lists
622
+ * Capitalized idents in `componentsJson`/`islandsJson`, so only Capitalized
623
+ * idents can collide as ambiguous COMPONENT sources. Lowercase idents (hooks,
624
+ * store singletons like `teamStore`, util fns) are never components — two local
625
+ * files legitimately sharing such a name (e.g. `teamStore` from two stores) must
626
+ * NOT trip the component-ambiguity guard. (Pre-`scanImportRefs`, the
627
+ * default-only scanner never saw named/namespace lowercase imports at all.) */
628
+ function isComponentIdent(ident: string): boolean {
629
+ return /^[A-Z]/.test(ident)
630
+ }
631
+
632
+ /** A resolved import reference, capturing the import FORM so the SSR factory can
633
+ * regenerate the correct `import` statement. Used only by the SSR-component path
634
+ * (`gatherComponentSources`/`gatherChainSources` → `emitComponentArtifacts` /
635
+ * `reconcileIslandManifest`), NOT by `scanImports` (which stays local-default
636
+ * string-valued for the two external callers in islands/native build). */
637
+ export interface ResolvedImport {
638
+ /** Module specifier: an ABSOLUTE file path for local imports, or the verbatim
639
+ * bare specifier for package imports. */
640
+ spec: string
641
+ /** true ⇒ `spec` is a package/bare specifier (keep verbatim; do not
642
+ * readFileSync/relativize/recurse). */
643
+ bare: boolean
644
+ /** How the symbol was imported, so the factory regenerates the right import. */
645
+ kind: 'default' | 'named' | 'namespace'
646
+ /** For `named`, the exported name (may differ from the local alias). */
647
+ imported?: string
648
+ }
649
+
650
+ /** Resolve a module specifier as it appears in an import: a `.`/`..`-prefixed
651
+ * (local) spec resolves to an absolute file path via the `.tsx/.ts/index.*`
652
+ * candidate logic; any other (bare/package) spec is kept verbatim. Returns
653
+ * `undefined` for a local spec that resolves to no existing file (matches
654
+ * scanImports' silent-drop behavior). */
655
+ function resolveSpec(spec: string, fromFile: string): ResolvedImport | undefined {
656
+ if (!spec.startsWith('.')) {
657
+ // Package/bare specifier — keep verbatim, never resolve/readFileSync.
658
+ return { spec, bare: true, kind: 'default' }
659
+ }
660
+ const baseDir = dirname(fromFile)
661
+ const resolved = resolve(baseDir, spec)
662
+ const candidates = [
663
+ `${resolved}.tsx`,
664
+ `${resolved}.ts`,
665
+ `${resolved}/index.tsx`,
666
+ `${resolved}/index.ts`,
667
+ ]
668
+ const found = candidates.find((p) => existsSync(p))
669
+ if (!found) return undefined
670
+ return { spec: found, bare: false, kind: 'default' }
671
+ }
672
+
673
+ /** Scan ALL import forms in `file` and resolve each local-name binding to a
674
+ * {@link ResolvedImport}. Unlike {@link scanImports} (default-local only, kept
675
+ * stable for external callers), this:
676
+ * - parses default / `* as ns` / `{ a, b as c }` / mixed `d, { a }` forms;
677
+ * - keeps package/bare specifiers verbatim (`bare:true`) instead of skipping
678
+ * them — the SSR path needs them to regenerate `import` lines and to SSR
679
+ * third-party components (e.g. lucide-react icons).
680
+ *
681
+ * Returns `Map<localName, ResolvedImport>` (localName = the in-source identifier
682
+ * used in JSX). Namespace imports are recorded parse-only (the Rust compiler
683
+ * rejects member-expression elements, so `<Ns.Member/>` isn't renderable yet). */
684
+ export function scanImportRefs(file: string): Map<string, ResolvedImport> {
685
+ const source = readFileSync(file, 'utf8')
686
+ const map = new Map<string, ResolvedImport>()
687
+ // Match any import statement's clause + specifier. The clause is parsed below.
688
+ const re = /^import\s+([^'"]+?)\s+from\s+['"]([^'"]+)['"]/gm
689
+ for (let m = re.exec(source); m !== null; m = re.exec(source)) {
690
+ const clause = m[1]!.trim()
691
+ // `import type …` / `import type { … }` / `import type * as …` are erased at
692
+ // build — they bind no runtime value, so a type alias must never enter the
693
+ // map (a Capitalized type name could otherwise collide with a real component).
694
+ if (/^type[\s{*]/.test(clause)) continue
695
+ const spec = m[2]!
696
+ const resolved = resolveSpec(spec, file)
697
+ if (!resolved) continue // local spec that resolves to no file — silent drop
698
+
699
+ // Namespace: `* as Ns`
700
+ const nsMatch = /^\*\s+as\s+([A-Za-z_$][\w$]*)$/.exec(clause)
701
+ if (nsMatch) {
702
+ map.set(nsMatch[1]!, { ...resolved, kind: 'namespace' })
703
+ continue
704
+ }
705
+
706
+ // Split a possible mixed clause `Default, { a, b as c }` into default +
707
+ // named parts. The default ident (if any) is the leading bare identifier.
708
+ const rest = clause
709
+ const namedStart = rest.indexOf('{')
710
+ let defaultPart = namedStart === -1 ? rest : rest.slice(0, namedStart)
711
+ defaultPart = defaultPart.replace(/,\s*$/, '').trim()
712
+ if (defaultPart) {
713
+ const defMatch = /^([A-Za-z_$][\w$]*)$/.exec(defaultPart)
714
+ if (defMatch) {
715
+ map.set(defMatch[1]!, { ...resolved, kind: 'default' })
716
+ }
717
+ }
718
+
719
+ if (namedStart !== -1) {
720
+ const namedEnd = rest.indexOf('}', namedStart)
721
+ const inner = rest.slice(namedStart + 1, namedEnd === -1 ? undefined : namedEnd)
722
+ for (const raw of inner.split(',')) {
723
+ const piece = raw.trim()
724
+ if (!piece) continue
725
+ const aliasMatch = /^([A-Za-z_$][\w$]*)\s+as\s+([A-Za-z_$][\w$]*)$/.exec(piece)
726
+ if (aliasMatch) {
727
+ map.set(aliasMatch[2]!, { ...resolved, kind: 'named', imported: aliasMatch[1]! })
728
+ } else if (/^[A-Za-z_$][\w$]*$/.test(piece)) {
729
+ map.set(piece, { ...resolved, kind: 'named', imported: piece })
730
+ }
731
+ }
732
+ }
733
+ }
734
+ return map
735
+ }
736
+
588
737
  /** Scan the entry file's `import Name from './path'` declarations and build a
589
- * map of localName -> resolved absolute path. Extension resolution tries
590
- * `.tsx`, `.ts`, `/index.tsx`, `/index.ts` in order. */
738
+ * map of localName -> resolved absolute path (DEFAULT-LOCAL only — package
739
+ * specifiers skipped). Extension resolution tries `.tsx`, `.ts`, `/index.tsx`,
740
+ * `/index.ts` in order. Kept string-valued + default-local for the external
741
+ * callers in islands/build.ts + native/build.ts; the SSR-component path uses the
742
+ * richer {@link scanImportRefs} instead. */
591
743
  export function scanImports(entryFile: string): Map<string, string> {
592
744
  const source = readFileSync(entryFile, 'utf8')
593
745
  const map = new Map<string, string>()
@@ -628,7 +780,7 @@ export function scanImports(entryFile: string): Map<string, string> {
628
780
  export function reconcileIslandManifest(
629
781
  jinjaPath: string,
630
782
  islandsJsonPath: string,
631
- pageImports: Map<string, string>,
783
+ pageImports: Map<string, ResolvedImport>,
632
784
  routeName: string,
633
785
  ): void {
634
786
  if (!existsSync(islandsJsonPath)) return
@@ -644,13 +796,21 @@ export function reconcileIslandManifest(
644
796
  // the SSR import. Mirrors the .components.json contract (emitComponentArtifacts).
645
797
  const projectRoot = process.cwd()
646
798
  const enriched: EnrichedIslandEntry[] = raw.map((entry) => {
647
- const sourcePath = pageImports.get(entry.component)
648
- if (!sourcePath) {
799
+ const ref = pageImports.get(entry.component)
800
+ if (!ref) {
649
801
  throw new Error(
650
802
  `island component "${entry.component}" in native route "${routeName}" has no matching import in the page source (expected \`import ${entry.component} from "..."\`)`,
651
803
  )
652
804
  }
653
- return { ...entry, sourcePath: relative(projectRoot, sourcePath).replaceAll('\\', '/') }
805
+ // Islands are LOCAL-only: the runtime (`loadIslandManifest`) rehydrates
806
+ // sourcePath against cwd, so a bare/package spec would break hydration. A
807
+ // bare-import island is a hard error, not a silently-written bad manifest.
808
+ if (ref.bare) {
809
+ throw new Error(
810
+ `island component "${entry.component}" in native route "${routeName}" resolves to a bare/package import "${ref.spec}" — islands must be imported from a local file (the manifest's sourcePath is rehydrated against cwd at runtime)`,
811
+ )
812
+ }
813
+ return { ...entry, sourcePath: relative(projectRoot, ref.spec).replaceAll('\\', '/') }
654
814
  })
655
815
 
656
816
  writeFileSync(islandsJsonPath, JSON.stringify(enriched))
@@ -60,10 +60,11 @@ export function findBrustPackageRoot(startDir: string = import.meta.dir): string
60
60
  }
61
61
 
62
62
  function pokedexExtraFiles(ctx: ScaffoldCtx): EmittedFile[] {
63
- // pokedex's dependency set differs from minimal: it uses `zod` and does NOT
64
- // use tailwind (its app.css is a hand-written design system). `react-dom` is
65
- // included as the framework's SSR/hydration peer (not a direct pokedex
66
- // import) do not "tidy" it away.
63
+ // pokedex's dependency set extends minimal: it adds `zod` (action/query
64
+ // schemas) and `lucide-react` (icons SSR-rendered in native routes + islands).
65
+ // `tailwindcss` is declared like minimal (the redesigned app.css is Tailwind v4
66
+ // — `@import "tailwindcss"`). `react-dom` is the framework's SSR/hydration peer
67
+ // (not a direct pokedex import) — do not "tidy" it away.
67
68
  const pkg = {
68
69
  name: ctx.projectName,
69
70
  version: '0.0.1',
@@ -78,6 +79,8 @@ function pokedexExtraFiles(ctx: ScaffoldCtx): EmittedFile[] {
78
79
  react: '^19.2.6',
79
80
  'react-dom': '^19.2.6',
80
81
  zod: '^4.4.3',
82
+ tailwindcss: '^4.3.0',
83
+ 'lucide-react': '^1.17.0',
81
84
  },
82
85
  devDependencies: {
83
86
  '@types/bun': 'latest',