@barefootjs/rust 0.30.6 → 0.31.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/rust",
3
- "version": "0.30.6",
3
+ "version": "0.31.1",
4
4
  "description": "minijinja (Rust) adapter for BarefootJS — compiles IR to .j2 templates and ships a Rust rendering runtime (packages/adapter-rust/runtime/); runs under any Rust web framework (axum, etc.)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,9 +17,9 @@
17
17
  "./test-render": {
18
18
  "bun": "./src/test-render.ts"
19
19
  },
20
- "./build": {
21
- "types": "./dist/build.d.ts",
22
- "import": "./dist/build.js"
20
+ "./vite": {
21
+ "types": "./dist/vite.d.ts",
22
+ "import": "./dist/vite.js"
23
23
  }
24
24
  },
25
25
  "files": [
@@ -30,7 +30,7 @@
30
30
  ],
31
31
  "scripts": {
32
32
  "build": "bun run build:js && bun run build:types",
33
- "build:js": "bun build ./src/index.ts ./src/adapter/index.ts ./src/build.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared",
33
+ "build:js": "bun build ./src/index.ts ./src/adapter/index.ts --root ./src --outdir ./dist --format esm --external @barefootjs/jsx --external @barefootjs/shared --external @barefootjs/vite --external vite && bun build ./src/vite.ts --outfile ./dist/vite.js --format esm --target node --external @barefootjs/vite --external vite --external typescript",
34
34
  "build:types": "tsgo --emitDeclarationOnly --outDir ./dist",
35
35
  "test": "bun test",
36
36
  "clean": "rm -rf dist",
@@ -54,14 +54,27 @@
54
54
  "directory": "packages/adapter-rust"
55
55
  },
56
56
  "dependencies": {
57
- "@barefootjs/shared": "0.30.6"
57
+ "@barefootjs/shared": "0.31.1"
58
58
  },
59
59
  "peerDependencies": {
60
- "@barefootjs/jsx": ">=0.2.0"
60
+ "@barefootjs/jsx": ">=0.2.0",
61
+ "@barefootjs/vite": ">=0.2.0",
62
+ "vite": "^6.0.0"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@barefootjs/vite": {
66
+ "optional": true
67
+ },
68
+ "vite": {
69
+ "optional": true
70
+ }
61
71
  },
62
72
  "devDependencies": {
63
73
  "@barefootjs/adapter-tests": "0.1.0",
64
- "@barefootjs/jsx": "0.30.6",
65
- "typescript": "^5.0.0"
74
+ "@barefootjs/jsx": "0.31.1",
75
+ "@barefootjs/vite": "0.31.1",
76
+ "@barefootjs/client": "0.31.1",
77
+ "typescript": "^5.0.0",
78
+ "vite": "^6.0.0"
66
79
  }
67
80
  }
@@ -811,6 +811,14 @@ pub struct ChildRendererSpec {
811
811
  pub struct RenderSession {
812
812
  pub scripts: Mutex<Vec<String>>,
813
813
  pub script_seen: Mutex<HashSet<String>>,
814
+ /// `<link rel="modulepreload">` hints, kept as their own list/dedup-set
815
+ /// pair mirroring `scripts`/`script_seen` exactly -- see
816
+ /// `register_preload`'s docstring. A preload URL and a script URL are
817
+ /// never the same concern (preloads are the entry's TRANSITIVE deps,
818
+ /// never the entry itself), so there is no cross-dedup between the two
819
+ /// sets.
820
+ pub preloads: Mutex<Vec<String>>,
821
+ pub preload_seen: Mutex<HashSet<String>>,
814
822
  pub child_renderers: Mutex<HashMap<String, ChildRendererSpec>>,
815
823
  pub context_stacks: Mutex<HashMap<String, Vec<JsValue>>>,
816
824
  rng_counter: Mutex<u64>,
@@ -821,6 +829,8 @@ impl RenderSession {
821
829
  Arc::new(RenderSession {
822
830
  scripts: Mutex::new(Vec::new()),
823
831
  script_seen: Mutex::new(HashSet::new()),
832
+ preloads: Mutex::new(Vec::new()),
833
+ preload_seen: Mutex::new(HashSet::new()),
824
834
  child_renderers: Mutex::new(HashMap::new()),
825
835
  context_stacks: Mutex::new(HashMap::new()),
826
836
  rng_counter: Mutex::new(0),
@@ -976,20 +986,54 @@ impl BfInstance {
976
986
  self.session.scripts.lock().unwrap().push(path.to_string());
977
987
  }
978
988
 
989
+ /// Register a `<link rel="modulepreload">` hint (a component's resolved
990
+ /// TRANSITIVE-chunk preload URL, per `AdapterGenerateOptions.
991
+ /// preloadAssets`) into the SAME session every `BfInstance` clone in
992
+ /// this render tree shares (`Arc<RenderSession>` -- see the struct
993
+ /// docstring), exactly mirroring `register_script` above. Because every
994
+ /// clone (root, and every child minted by `render_child`) holds the
995
+ /// SAME `Arc`, a preload registered three levels deep is visible to the
996
+ /// root's `scripts()` call with no separate propagation step needed --
997
+ /// unlike the PHP/Python ports, which mutate per-instance state and
998
+ /// must explicitly re-seed each child's collector from the parent's.
999
+ fn register_preload(&self, path: &str) {
1000
+ let mut seen = self.session.preload_seen.lock().unwrap();
1001
+ if seen.contains(path) {
1002
+ return;
1003
+ }
1004
+ seen.insert(path.to_string());
1005
+ self.session.preloads.lock().unwrap().push(path.to_string());
1006
+ }
1007
+
979
1008
  /// `pub` (beyond the `"scripts"` `call_method` dispatch below) so a
980
- /// production host can read back the accumulated `<script>` tags AFTER
981
- /// rendering, to splice into its own page layout (mirrors Python
982
- /// integrations' `bf.scripts()` call in their layout helper, e.g.
1009
+ /// production host can read back the accumulated `<link>`/`<script>`
1010
+ /// tags AFTER rendering, to splice into its own page layout (mirrors
1011
+ /// Python integrations' `bf.scripts()` call in their layout helper, e.g.
983
1012
  /// `integrations/flask/app.py`'s `layout(..., scripts=bf.scripts())`).
1013
+ ///
1014
+ /// Preload hints are emitted FIRST, ahead of every `<script
1015
+ /// type="module">` tag -- a hint that arrives after the script it
1016
+ /// describes is useless. Preloads carry no execution-order constraint
1017
+ /// the way scripts do (they are just hints, not code that runs), so
1018
+ /// registration order is emitted as-is.
984
1019
  pub fn scripts(&self) -> String {
985
- self.session
1020
+ let preload_tags = self
1021
+ .session
1022
+ .preloads
1023
+ .lock()
1024
+ .unwrap()
1025
+ .iter()
1026
+ .map(|p| format!("<link rel=\"modulepreload\" crossorigin href=\"{p}\">"))
1027
+ .collect::<Vec<_>>();
1028
+ let script_tags = self
1029
+ .session
986
1030
  .scripts
987
1031
  .lock()
988
1032
  .unwrap()
989
1033
  .iter()
990
1034
  .map(|p| format!("<script type=\"module\" src=\"{p}\"></script>"))
991
- .collect::<Vec<_>>()
992
- .join("\n")
1035
+ .collect::<Vec<_>>();
1036
+ preload_tags.into_iter().chain(script_tags).collect::<Vec<_>>().join("\n")
993
1037
  }
994
1038
 
995
1039
  /// Renderer contract (#1897): invoked from a template as
@@ -1305,6 +1349,10 @@ impl Object for BfInstance {
1305
1349
  self.register_script(a(0).as_str().unwrap_or(""));
1306
1350
  Ok(MjValue::from(()))
1307
1351
  }
1352
+ "register_preload" => {
1353
+ self.register_preload(a(0).as_str().unwrap_or(""));
1354
+ Ok(MjValue::from(()))
1355
+ }
1308
1356
  "scripts" => Ok(safe(self.scripts())),
1309
1357
 
1310
1358
  // -- Streaming SSR ----------------------------------------------
@@ -555,3 +555,149 @@ export function Wrapper({ children }: { children?: any }) {
555
555
  )
556
556
  })
557
557
  })
558
+
559
+ describe('MinijinjaAdapter - scriptAssets (Vite late-binding, PR1)', () => {
560
+ const CLIENT_COMPONENT = `
561
+ 'use client'
562
+ import { createSignal } from '@barefootjs/client'
563
+ export function Counter() {
564
+ const [count, setCount] = createSignal(0)
565
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
566
+ }
567
+ `
568
+
569
+ test('emits one register_script per URL, in order, when scriptAssets is set', () => {
570
+ const ir = compileToIR(CLIENT_COMPONENT)
571
+ const { template } = new MinijinjaAdapter().generate(ir, {
572
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
573
+ })
574
+ const runtimeIdx = template.indexOf("bf.register_script('/assets/runtime-abc123.js')")
575
+ const compIdx = template.indexOf("bf.register_script('/assets/counter-def456.js')")
576
+ expect(runtimeIdx).toBeGreaterThanOrEqual(0)
577
+ expect(compIdx).toBeGreaterThanOrEqual(0)
578
+ expect(runtimeIdx).toBeLessThan(compIdx)
579
+ expect(template).toContain('_bf_reg0')
580
+ expect(template).toContain('_bf_reg1')
581
+ expect(template).not.toContain('/static/components/barefoot.js')
582
+ expect(template).not.toContain('Counter.client.js')
583
+ })
584
+
585
+ test('emits a single registration for a single-element scriptAssets array', () => {
586
+ const ir = compileToIR(CLIENT_COMPONENT)
587
+ const { template } = new MinijinjaAdapter().generate(ir, {
588
+ scriptAssets: ['/assets/only-one.js'],
589
+ })
590
+ expect(template).toContain("bf.register_script('/assets/only-one.js')")
591
+ expect(template.match(/register_script/g)?.length).toBe(1)
592
+ })
593
+
594
+ test('an empty scriptAssets array emits no script registrations', () => {
595
+ const ir = compileToIR(CLIENT_COMPONENT)
596
+ const { template } = new MinijinjaAdapter().generate(ir, { scriptAssets: [] })
597
+ expect(template).not.toContain('register_script')
598
+ })
599
+
600
+ test('skipScriptRegistration still wins when scriptAssets is also set', () => {
601
+ const ir = compileToIR(CLIENT_COMPONENT)
602
+ const { template } = new MinijinjaAdapter().generate(ir, {
603
+ skipScriptRegistration: true,
604
+ scriptAssets: ['/assets/should-not-appear.js'],
605
+ })
606
+ expect(template).not.toContain('register_script')
607
+ })
608
+
609
+ test('absent scriptAssets falls back to adapter-computed script paths', () => {
610
+ const ir = compileToIR(CLIENT_COMPONENT)
611
+ const computed = new MinijinjaAdapter().generate(ir).template
612
+ const explicitUndefined = new MinijinjaAdapter().generate(ir, { scriptAssets: undefined }).template
613
+ expect(computed).toContain("bf.register_script('/static/components/barefoot.js')")
614
+ expect(computed).toContain("bf.register_script('/static/components/Counter.client.js')")
615
+ expect(explicitUndefined).toBe(computed)
616
+ })
617
+ })
618
+
619
+ describe('MinijinjaAdapter - preloadAssets', () => {
620
+ const CLIENT_COMPONENT = `
621
+ 'use client'
622
+ import { createSignal } from '@barefootjs/client'
623
+ export function Counter() {
624
+ const [count, setCount] = createSignal(0)
625
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
626
+ }
627
+ `
628
+
629
+ test('non-empty preloadAssets + non-empty scriptAssets: preload registrations emitted, in order, before script registrations', () => {
630
+ const ir = compileToIR(CLIENT_COMPONENT)
631
+ const { template } = new MinijinjaAdapter().generate(ir, {
632
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
633
+ preloadAssets: ['/assets/index-pre1.js', '/assets/shared-pre2.js'],
634
+ })
635
+ const pre1Idx = template.indexOf("{% set _bf_pre0 = bf.register_preload('/assets/index-pre1.js') %}")
636
+ const pre2Idx = template.indexOf("{% set _bf_pre1 = bf.register_preload('/assets/shared-pre2.js') %}")
637
+ const script1Idx = template.indexOf("{% set _bf_reg0 = bf.register_script('/assets/runtime-abc123.js') %}")
638
+ const script2Idx = template.indexOf("{% set _bf_reg1 = bf.register_script('/assets/counter-def456.js') %}")
639
+ expect(pre1Idx).toBeGreaterThanOrEqual(0)
640
+ expect(pre2Idx).toBeGreaterThan(pre1Idx)
641
+ expect(script1Idx).toBeGreaterThan(pre2Idx)
642
+ expect(script2Idx).toBeGreaterThan(script1Idx)
643
+ })
644
+
645
+ test('preloadAssets: [] emits no preload registration', () => {
646
+ const ir = compileToIR(CLIENT_COMPONENT)
647
+ const { template } = new MinijinjaAdapter().generate(ir, {
648
+ scriptAssets: ['/assets/runtime-abc123.js'],
649
+ preloadAssets: [],
650
+ })
651
+ expect(template).not.toContain('register_preload')
652
+ expect(template).toContain("bf.register_script('/assets/runtime-abc123.js')")
653
+ })
654
+
655
+ test('preloadAssets: undefined emits no preload registration', () => {
656
+ const ir = compileToIR(CLIENT_COMPONENT)
657
+ const { template } = new MinijinjaAdapter().generate(ir, {
658
+ scriptAssets: ['/assets/runtime-abc123.js'],
659
+ preloadAssets: undefined,
660
+ })
661
+ expect(template).not.toContain('register_preload')
662
+ expect(template).toContain("bf.register_script('/assets/runtime-abc123.js')")
663
+ })
664
+
665
+ test('preloadAssets non-empty but scriptAssets: [] emits no preload registration (preloads are only meaningful alongside a real script)', () => {
666
+ const ir = compileToIR(CLIENT_COMPONENT)
667
+ const { template } = new MinijinjaAdapter().generate(ir, {
668
+ scriptAssets: [],
669
+ preloadAssets: ['/assets/index-pre1.js'],
670
+ })
671
+ expect(template).not.toContain('register_preload')
672
+ expect(template).not.toContain('register_script')
673
+ })
674
+
675
+ test('skipScriptRegistration: true suppresses both preloads and scripts', () => {
676
+ const ir = compileToIR(CLIENT_COMPONENT)
677
+ const { template } = new MinijinjaAdapter().generate(ir, {
678
+ skipScriptRegistration: true,
679
+ scriptAssets: ['/assets/runtime-abc123.js'],
680
+ preloadAssets: ['/assets/index-pre1.js'],
681
+ })
682
+ expect(template).not.toContain('register_preload')
683
+ expect(template).not.toContain('register_script')
684
+ })
685
+
686
+ // Regression guard: a previous attempt emitted a literal
687
+ // `<link rel="modulepreload">` tag directly into the component template,
688
+ // which injected a rendered DOM node before the component's root and
689
+ // broke hydration across all eight integrations (blade, erb,
690
+ // go-template, jinja, mojolicious, rust, twig, xslate). Preload hints
691
+ // must ONLY ever be emitted as no-output register statements (here,
692
+ // `{% set _bf_preN = bf.register_preload(...) %}`) that the adapter's
693
+ // runtime later renders itself — never as literal markup baked into the
694
+ // template.
695
+ test('never emits a literal <link tag into the template', () => {
696
+ const ir = compileToIR(CLIENT_COMPONENT)
697
+ const { template } = new MinijinjaAdapter().generate(ir, {
698
+ scriptAssets: ['/assets/runtime-abc123.js'],
699
+ preloadAssets: ['/assets/index-pre1.js'],
700
+ })
701
+ expect(template).not.toContain('<link')
702
+ })
703
+ })
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Coverage of `@barefootjs/rust/vite`'s `barefoot()`:
3
+ *
4
+ * - one real `vite build()` end to end (mirrors `@barefootjs/go-template/
5
+ * vite`, `@barefootjs/hono/vite`, `@barefootjs/blade/vite`'s,
6
+ * `@barefootjs/jinja/vite`'s, and `@barefootjs/erb/vite`'s own
7
+ * `vite.test.ts` rigor — a plugin that only passes mocked unit tests
8
+ * hasn't been shown to work) against a checked-in fixture (not a system
9
+ * tmpdir) so `@barefootjs/client` resolves through the monorepo's real
10
+ * node_modules symlinks;
11
+ * - the `assets` → generated `bf-assets.json` behavior, exercised the same
12
+ * way (a real build, since it needs the real manifest Vite writes).
13
+ *
14
+ * Unlike Go, there is no `afterEmit`-driven type-combination step to test
15
+ * here (see `vite.ts`'s module docstring) — `MinijinjaAdapter.generate()`
16
+ * never produces a `types` section at all, so `barefoot()` always returns a
17
+ * single-element array unless `assets` is set.
18
+ */
19
+ import { describe, test, expect } from 'bun:test'
20
+ import { build } from 'vite'
21
+ import { mkdtemp, rm, readFile } from 'node:fs/promises'
22
+ import { tmpdir } from 'node:os'
23
+ import { join, resolve } from 'node:path'
24
+ import { barefoot, barefoot as defaultBarefoot } from '../vite.ts'
25
+
26
+ const FIXTURE_ROOT = resolve(import.meta.dirname, '../../e2e-fixture')
27
+
28
+ describe('@barefootjs/rust/vite: real vite build', () => {
29
+ test('exports the same function as both named `barefoot` and default', () => {
30
+ expect(defaultBarefoot).toBe(barefoot)
31
+ })
32
+
33
+ test('returns a single-element plugin array when `assets` is omitted', () => {
34
+ const plugins = barefoot({ components: ['src/components'], templates: 'views' })
35
+ expect(plugins).toHaveLength(1)
36
+ })
37
+
38
+ test('writes a self-contained .j2 template with scriptAssets baked in, same as core alone would do', async () => {
39
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-rust-vite-dist-'))
40
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-rust-vite-views-'))
41
+
42
+ try {
43
+ await build({
44
+ configFile: false,
45
+ root: FIXTURE_ROOT,
46
+ base: '/static/build/',
47
+ logLevel: 'warn',
48
+ build: { outDir, emptyOutDir: true },
49
+ plugins: barefoot({
50
+ components: ['src/components'],
51
+ templates: templatesDir,
52
+ }),
53
+ })
54
+
55
+ const template = await readFile(join(templatesDir, 'Counter.j2'), 'utf8')
56
+ expect(template).toContain("bf.register_script(")
57
+ } finally {
58
+ await rm(outDir, { recursive: true, force: true })
59
+ await rm(templatesDir, { recursive: true, force: true })
60
+ }
61
+ }, 60_000)
62
+
63
+ test('`assets` resolves a non-component entry\'s manifest-hashed URL into a generated JSON asset map', async () => {
64
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-rust-vite-dist-assets-'))
65
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-rust-vite-views-assets-'))
66
+ const assetsPath = join(FIXTURE_ROOT, 'dist/bf-assets.json')
67
+
68
+ try {
69
+ await build({
70
+ configFile: false,
71
+ root: FIXTURE_ROOT,
72
+ base: '/static/build/',
73
+ logLevel: 'warn',
74
+ build: {
75
+ outDir,
76
+ emptyOutDir: true,
77
+ // Registering the non-component entry is the CALLER's job (stock
78
+ // Vite config) — `assets` below only resolves the URL Vite
79
+ // already bundled it to, it doesn't request the bundling.
80
+ rollupOptions: { input: { bootstrap: resolve(FIXTURE_ROOT, 'client/bootstrap.ts') } },
81
+ },
82
+ plugins: barefoot({
83
+ components: ['src/components'],
84
+ templates: templatesDir,
85
+ assets: { Bootstrap: 'client/bootstrap.ts' },
86
+ }),
87
+ })
88
+
89
+ const manifest = JSON.parse(await readFile(join(outDir, '.vite/manifest.json'), 'utf8'))
90
+ const expectedUrl = `/static/build/${manifest['client/bootstrap.ts'].file}`
91
+
92
+ const content = JSON.parse(await readFile(assetsPath, 'utf8'))
93
+ expect(content).toEqual({ Bootstrap: expectedUrl })
94
+ } finally {
95
+ await rm(outDir, { recursive: true, force: true })
96
+ await rm(templatesDir, { recursive: true, force: true })
97
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
98
+ }
99
+ }, 60_000)
100
+
101
+ test('`assets` throws an actionable error when the entry was never registered as a Rollup input', async () => {
102
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-rust-vite-dist-assets-missing-'))
103
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-rust-vite-views-assets-missing-'))
104
+
105
+ try {
106
+ await expect(
107
+ build({
108
+ configFile: false,
109
+ root: FIXTURE_ROOT,
110
+ base: '/static/build/',
111
+ logLevel: 'silent',
112
+ build: { outDir, emptyOutDir: true },
113
+ plugins: barefoot({
114
+ components: ['src/components'],
115
+ templates: templatesDir,
116
+ assets: { Bootstrap: 'client/bootstrap.ts' },
117
+ }),
118
+ }),
119
+ ).rejects.toThrow(/was not found in the build manifest/)
120
+ } finally {
121
+ await rm(outDir, { recursive: true, force: true })
122
+ await rm(templatesDir, { recursive: true, force: true })
123
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
124
+ }
125
+ }, 60_000)
126
+ })
@@ -117,15 +117,33 @@ export function truthyTest(node: ParsedExpr, rendered: string): string {
117
117
  * callback's receiver.
118
118
  */
119
119
  export class JinjaFilterEmitter implements ParsedExprEmitter {
120
+ // Plain field declarations + assignment, NOT TS constructor-parameter-
121
+ // property shorthand: Vite's `bundleConfigFile` externalizes any bare
122
+ // (non-relative) import when loading `vite.config.ts` (see
123
+ // `@barefootjs/rust/vite`'s docstring), so this file can be loaded
124
+ // directly by Node's OWN native TypeScript type-stripping (enabled by
125
+ // default since Node 22.18/23.6) rather than esbuild — and Node's
126
+ // strip-only mode does not support parameter properties (`SyntaxError
127
+ // [ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX]`), only plain type annotations.
128
+ private readonly param: string
129
+ private readonly localVarMap: Map<string, string>
130
+ private readonly isStringName: (n: string) => boolean
131
+ // Records a BF101 for predicate shapes this emitter can only degrade
132
+ // (#2038). Optional so emitter construction stays possible without an
133
+ // adapter; a missing hook keeps the old silent-degrade emit.
134
+ private readonly onUnsupported?: (message: string, reason?: string) => void
135
+
120
136
  constructor(
121
- private readonly param: string,
122
- private readonly localVarMap: Map<string, string>,
123
- private readonly isStringName: (n: string) => boolean = () => false,
124
- // Records a BF101 for predicate shapes this emitter can only degrade
125
- // (#2038). Optional so emitter construction stays possible without an
126
- // adapter; a missing hook keeps the old silent-degrade emit.
127
- private readonly onUnsupported?: (message: string, reason?: string) => void,
128
- ) {}
137
+ param: string,
138
+ localVarMap: Map<string, string>,
139
+ isStringName: (n: string) => boolean = () => false,
140
+ onUnsupported?: (message: string, reason?: string) => void,
141
+ ) {
142
+ this.param = param
143
+ this.localVarMap = localVarMap
144
+ this.isStringName = isStringName
145
+ this.onUnsupported = onUnsupported
146
+ }
129
147
 
130
148
  identifier(name: string): string {
131
149
  if (name === this.param) return minijinjaIdent(this.param)
@@ -288,7 +306,13 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
288
306
  * - no lambda fallback exists (see the file header, divergence 2).
289
307
  */
290
308
  export class JinjaTopLevelEmitter implements ParsedExprEmitter {
291
- constructor(private readonly ctx: JinjaEmitContext) {}
309
+ // Plain field + assignment, not a parameter property — see
310
+ // `JinjaFilterEmitter`'s constructor comment above for why.
311
+ private readonly ctx: JinjaEmitContext
312
+
313
+ constructor(ctx: JinjaEmitContext) {
314
+ this.ctx = ctx
315
+ }
292
316
 
293
317
  identifier(name: string): string {
294
318
  // `undefined` / `null` nested inside a larger expression tree — Jinja
@@ -241,9 +241,6 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
241
241
  name = 'minijinja'
242
242
  extension = '.j2'
243
243
  templatesPerComponent = true
244
- // Template-string target with no component layer: `bf build` emits a static
245
- // import-map HTML snippet to include into the page <head>.
246
- importMapInjection = 'html-snippet' as const
247
244
 
248
245
  /**
249
246
  * Identifier-path callees the Jinja runtime can render in template scope.
@@ -408,7 +405,7 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
408
405
  // Generate script registration
409
406
  const scriptReg = options?.skipScriptRegistration
410
407
  ? ''
411
- : this.generateScriptRegistrations(ir, options?.scriptBaseName)
408
+ : this.generateScriptRegistrations(ir, options?.scriptBaseName, options?.scriptAssets, options?.preloadAssets)
412
409
 
413
410
  // SSR context consumers (`const x = useContext(Ctx)`): seed each local
414
411
  // from the active provider value (or the `createContext` default). The
@@ -450,7 +447,35 @@ export class MinijinjaAdapter extends BaseAdapter implements IRNodeEmitter<Jinja
450
447
  // Script Registration
451
448
  // ===========================================================================
452
449
 
453
- private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
450
+ private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string, scriptAssets?: string[], preloadAssets?: string[]): string {
451
+ // `scriptAssets`, when present (including `[]`), fully supersedes the
452
+ // adapter-computed `barefootJsPath` / `clientJsBasePath` pair — see
453
+ // `AdapterGenerateOptions.scriptAssets`. The caller (e.g. the Vite
454
+ // plugin) has already decided the exact ordered URL list, including
455
+ // whether any script is needed at all.
456
+ if (scriptAssets) {
457
+ if (scriptAssets.length === 0) return ''
458
+ const lines: string[] = []
459
+ // `preloadAssets` is only meaningful alongside a non-empty
460
+ // `scriptAssets` (see `AdapterGenerateOptions.preloadAssets`) — this
461
+ // branch is only reached when that already holds. Emitted BEFORE the
462
+ // script registrations: every preload hint must precede the script
463
+ // tags it describes, or the hint is useless (see `register_preload`'s
464
+ // docstring in the Rust runtime — `bf.register_preload`/`bf.scripts()`
465
+ // preserve that ordering downstream, so codegen order is what fixes
466
+ // it here).
467
+ if (preloadAssets && preloadAssets.length > 0) {
468
+ preloadAssets.forEach((url, i) => {
469
+ lines.push(`{% set _bf_pre${i} = bf.register_preload('${url}') %}`)
470
+ })
471
+ }
472
+ scriptAssets.forEach((url, i) => {
473
+ lines.push(`{% set _bf_reg${i} = bf.register_script('${url}') %}`)
474
+ })
475
+ lines.push('')
476
+ return lines.join('\n')
477
+ }
478
+
454
479
  const hasInteractivity = hasClientInteractivity(ir)
455
480
  if (!hasInteractivity) return ''
456
481