@barefootjs/jinja 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/jinja",
3
- "version": "0.30.6",
3
+ "version": "0.31.1",
4
4
  "description": "Jinja2 adapter for BarefootJS — compiles IR to .jinja templates and ships the Python BarefootJS rendering runtime; runs under any Python web framework (Flask, 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",
@@ -53,14 +53,27 @@
53
53
  "directory": "packages/adapter-jinja"
54
54
  },
55
55
  "dependencies": {
56
- "@barefootjs/shared": "0.30.6"
56
+ "@barefootjs/shared": "0.31.1"
57
57
  },
58
58
  "peerDependencies": {
59
- "@barefootjs/jsx": ">=0.2.0"
59
+ "@barefootjs/jsx": ">=0.2.0",
60
+ "@barefootjs/vite": ">=0.2.0",
61
+ "vite": "^6.0.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "@barefootjs/vite": {
65
+ "optional": true
66
+ },
67
+ "vite": {
68
+ "optional": true
69
+ }
60
70
  },
61
71
  "devDependencies": {
62
72
  "@barefootjs/adapter-tests": "0.1.0",
63
- "@barefootjs/jsx": "0.30.6",
64
- "typescript": "^5.0.0"
73
+ "@barefootjs/jsx": "0.31.1",
74
+ "@barefootjs/vite": "0.31.1",
75
+ "@barefootjs/client": "0.31.1",
76
+ "typescript": "^5.0.0",
77
+ "vite": "^6.0.0"
65
78
  }
66
79
  }
@@ -531,6 +531,8 @@ class BarefootJS:
531
531
 
532
532
  _scripts = _dual_accessor("_scripts", lambda self: [])
533
533
  _script_seen = _dual_accessor("_script_seen", lambda self: {})
534
+ _preloads = _dual_accessor("_preloads", lambda self: [])
535
+ _preload_seen = _dual_accessor("_preload_seen", lambda self: {})
534
536
  _child_renderers = _dual_accessor("_child_renderers", lambda self: {})
535
537
  _is_child = _dual_accessor("_is_child", False)
536
538
  _scope_id = _dual_accessor("_scope_id")
@@ -675,6 +677,24 @@ class BarefootJS:
675
677
  seen[path] = True
676
678
  self._scripts().append(path)
677
679
 
680
+ def register_preload(self, path: str) -> None:
681
+ """Register a `<link rel="modulepreload">` hint (a component's
682
+ resolved TRANSITIVE-chunk preload URL, per
683
+ `AdapterGenerateOptions.preloadAssets`) -- mirrors `register_script`
684
+ above exactly. Kept as a SEPARATE `_preloads`/`_preload_seen` pair
685
+ -- a preload URL and a script URL are never the same concern, so
686
+ there is no cross-dedup to do between the two. Python lists/dicts
687
+ are mutable REFERENCE types (unlike PHP arrays), so
688
+ `child_bf._preloads(parent._preloads())` shares the SAME underlying
689
+ list -- no `ArrayObject`-style wrapper is needed for cross-instance
690
+ propagation the way the PHP port requires (see that port's
691
+ `register_preload` docblock)."""
692
+ seen = self._preload_seen()
693
+ if seen.get(path):
694
+ return
695
+ seen[path] = True
696
+ self._preloads().append(path)
697
+
678
698
  # -----------------------------------------------------------------
679
699
  # Child Component Rendering
680
700
  # -----------------------------------------------------------------
@@ -721,8 +741,9 @@ class BarefootJS:
721
741
  def register_components_from_manifest(
722
742
  self, manifest: dict, signal_init: Optional[dict] = None
723
743
  ) -> None:
724
- """`bf build` emits a manifest describing every component the page
725
- might invoke. This walks that manifest and registers one child
744
+ """`vite build` (via `@barefootjs/jinja/vite`'s `barefoot()` plugin)
745
+ emits a manifest describing every component the page might invoke.
746
+ This walks that manifest and registers one child
726
747
  renderer per UI registry entry -- the path shape `ui/<name>/index`
727
748
  maps to the `<name>` slot key the generated template invokes via
728
749
  `bf.render_child('<name>', ...)`.
@@ -787,6 +808,8 @@ class BarefootJS:
787
808
  child_bf._child_renderers(parent._child_renderers())
788
809
  child_bf._scripts(parent._scripts())
789
810
  child_bf._script_seen(parent._script_seen())
811
+ child_bf._preloads(parent._preloads())
812
+ child_bf._preload_seen(parent._preload_seen())
790
813
 
791
814
  extra: dict = {}
792
815
  if signal_init_fn:
@@ -810,7 +833,14 @@ class BarefootJS:
810
833
  # -----------------------------------------------------------------
811
834
 
812
835
  def scripts(self) -> str:
813
- tags = [f'<script type="module" src="{path}"></script>' for path in self._scripts()]
836
+ """Renders every collected `<link rel="modulepreload">` hint, THEN
837
+ every collected `<script type="module">` tag -- preloads always
838
+ precede the scripts they describe, or the hint is useless. Preloads
839
+ carry no execution-order constraint the way scripts do (they are
840
+ just hints, not code that runs), so registration order is emitted
841
+ as-is."""
842
+ tags = [f'<link rel="modulepreload" crossorigin href="{path}">' for path in self._preloads()]
843
+ tags += [f'<script type="module" src="{path}"></script>' for path in self._scripts()]
814
844
  return "\n".join(tags)
815
845
 
816
846
  # -----------------------------------------------------------------
@@ -567,3 +567,149 @@ export function Child() {
567
567
  expect(template).not.toContain('scope_comment')
568
568
  })
569
569
  })
570
+
571
+ describe('JinjaAdapter - scriptAssets (Vite late-binding, PR1)', () => {
572
+ const CLIENT_COMPONENT = `
573
+ 'use client'
574
+ import { createSignal } from '@barefootjs/client'
575
+ export function Counter() {
576
+ const [count, setCount] = createSignal(0)
577
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
578
+ }
579
+ `
580
+
581
+ test('emits one register_script per URL, in order, when scriptAssets is set', () => {
582
+ const ir = compileToIR(CLIENT_COMPONENT)
583
+ const { template } = new JinjaAdapter().generate(ir, {
584
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
585
+ })
586
+ const runtimeIdx = template.indexOf("bf.register_script('/assets/runtime-abc123.js')")
587
+ const compIdx = template.indexOf("bf.register_script('/assets/counter-def456.js')")
588
+ expect(runtimeIdx).toBeGreaterThanOrEqual(0)
589
+ expect(compIdx).toBeGreaterThanOrEqual(0)
590
+ expect(runtimeIdx).toBeLessThan(compIdx)
591
+ expect(template).toContain('_bf_reg0')
592
+ expect(template).toContain('_bf_reg1')
593
+ expect(template).not.toContain('/static/components/barefoot.js')
594
+ expect(template).not.toContain('Counter.client.js')
595
+ })
596
+
597
+ test('emits a single registration for a single-element scriptAssets array', () => {
598
+ const ir = compileToIR(CLIENT_COMPONENT)
599
+ const { template } = new JinjaAdapter().generate(ir, {
600
+ scriptAssets: ['/assets/only-one.js'],
601
+ })
602
+ expect(template).toContain("bf.register_script('/assets/only-one.js')")
603
+ expect(template.match(/register_script/g)?.length).toBe(1)
604
+ })
605
+
606
+ test('an empty scriptAssets array emits no script registrations', () => {
607
+ const ir = compileToIR(CLIENT_COMPONENT)
608
+ const { template } = new JinjaAdapter().generate(ir, { scriptAssets: [] })
609
+ expect(template).not.toContain('register_script')
610
+ })
611
+
612
+ test('skipScriptRegistration still wins when scriptAssets is also set', () => {
613
+ const ir = compileToIR(CLIENT_COMPONENT)
614
+ const { template } = new JinjaAdapter().generate(ir, {
615
+ skipScriptRegistration: true,
616
+ scriptAssets: ['/assets/should-not-appear.js'],
617
+ })
618
+ expect(template).not.toContain('register_script')
619
+ })
620
+
621
+ test('absent scriptAssets falls back to adapter-computed script paths', () => {
622
+ const ir = compileToIR(CLIENT_COMPONENT)
623
+ const computed = new JinjaAdapter().generate(ir).template
624
+ const explicitUndefined = new JinjaAdapter().generate(ir, { scriptAssets: undefined }).template
625
+ expect(computed).toContain("bf.register_script('/static/components/barefoot.js')")
626
+ expect(computed).toContain("bf.register_script('/static/components/Counter.client.js')")
627
+ expect(explicitUndefined).toBe(computed)
628
+ })
629
+ })
630
+
631
+ describe('JinjaAdapter - preloadAssets', () => {
632
+ const CLIENT_COMPONENT = `
633
+ 'use client'
634
+ import { createSignal } from '@barefootjs/client'
635
+ export function Counter() {
636
+ const [count, setCount] = createSignal(0)
637
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
638
+ }
639
+ `
640
+
641
+ test('non-empty preloadAssets + non-empty scriptAssets: preload registrations emitted, in order, before script registrations', () => {
642
+ const ir = compileToIR(CLIENT_COMPONENT)
643
+ const { template } = new JinjaAdapter().generate(ir, {
644
+ scriptAssets: ['/assets/runtime-abc123.js', '/assets/counter-def456.js'],
645
+ preloadAssets: ['/assets/index-pre1.js', '/assets/shared-pre2.js'],
646
+ })
647
+ const pre1Idx = template.indexOf("{% set _bf_pre0 = bf.register_preload('/assets/index-pre1.js') %}")
648
+ const pre2Idx = template.indexOf("{% set _bf_pre1 = bf.register_preload('/assets/shared-pre2.js') %}")
649
+ const script1Idx = template.indexOf("{% set _bf_reg0 = bf.register_script('/assets/runtime-abc123.js') %}")
650
+ const script2Idx = template.indexOf("{% set _bf_reg1 = bf.register_script('/assets/counter-def456.js') %}")
651
+ expect(pre1Idx).toBeGreaterThanOrEqual(0)
652
+ expect(pre2Idx).toBeGreaterThan(pre1Idx)
653
+ expect(script1Idx).toBeGreaterThan(pre2Idx)
654
+ expect(script2Idx).toBeGreaterThan(script1Idx)
655
+ })
656
+
657
+ test('preloadAssets: [] emits no preload registration', () => {
658
+ const ir = compileToIR(CLIENT_COMPONENT)
659
+ const { template } = new JinjaAdapter().generate(ir, {
660
+ scriptAssets: ['/assets/runtime-abc123.js'],
661
+ preloadAssets: [],
662
+ })
663
+ expect(template).not.toContain('register_preload')
664
+ expect(template).toContain("bf.register_script('/assets/runtime-abc123.js')")
665
+ })
666
+
667
+ test('preloadAssets: undefined emits no preload registration', () => {
668
+ const ir = compileToIR(CLIENT_COMPONENT)
669
+ const { template } = new JinjaAdapter().generate(ir, {
670
+ scriptAssets: ['/assets/runtime-abc123.js'],
671
+ preloadAssets: undefined,
672
+ })
673
+ expect(template).not.toContain('register_preload')
674
+ expect(template).toContain("bf.register_script('/assets/runtime-abc123.js')")
675
+ })
676
+
677
+ test('preloadAssets non-empty but scriptAssets: [] emits no preload registration (preloads are only meaningful alongside a real script)', () => {
678
+ const ir = compileToIR(CLIENT_COMPONENT)
679
+ const { template } = new JinjaAdapter().generate(ir, {
680
+ scriptAssets: [],
681
+ preloadAssets: ['/assets/index-pre1.js'],
682
+ })
683
+ expect(template).not.toContain('register_preload')
684
+ expect(template).not.toContain('register_script')
685
+ })
686
+
687
+ test('skipScriptRegistration: true suppresses both preloads and scripts', () => {
688
+ const ir = compileToIR(CLIENT_COMPONENT)
689
+ const { template } = new JinjaAdapter().generate(ir, {
690
+ skipScriptRegistration: true,
691
+ scriptAssets: ['/assets/runtime-abc123.js'],
692
+ preloadAssets: ['/assets/index-pre1.js'],
693
+ })
694
+ expect(template).not.toContain('register_preload')
695
+ expect(template).not.toContain('register_script')
696
+ })
697
+
698
+ // Regression guard: a previous attempt emitted a literal
699
+ // `<link rel="modulepreload">` tag directly into the component template,
700
+ // which injected a rendered DOM node before the component's root and
701
+ // broke hydration across all eight integrations (blade, erb,
702
+ // go-template, jinja, mojolicious, rust, twig, xslate). Preload hints
703
+ // must ONLY ever be emitted as no-output register statements (here,
704
+ // `{% set _bf_preN = bf.register_preload(...) %}`) that the adapter's
705
+ // runtime later renders itself — never as literal markup baked into the
706
+ // template.
707
+ test('never emits a literal <link tag into the template', () => {
708
+ const ir = compileToIR(CLIENT_COMPONENT)
709
+ const { template } = new JinjaAdapter().generate(ir, {
710
+ scriptAssets: ['/assets/runtime-abc123.js'],
711
+ preloadAssets: ['/assets/index-pre1.js'],
712
+ })
713
+ expect(template).not.toContain('<link')
714
+ })
715
+ })
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Coverage of `@barefootjs/jinja/vite`'s `barefoot()`:
3
+ *
4
+ * - one real `vite build()` end to end (mirrors `@barefootjs/go-template/
5
+ * vite`, `@barefootjs/hono/vite`, and `@barefootjs/blade/vite`'s own
6
+ * `vite.test.ts` rigor — a plugin that only passes mocked unit tests
7
+ * hasn't been shown to work) against a checked-in fixture (not a system
8
+ * tmpdir) so `@barefootjs/client` resolves through the monorepo's real
9
+ * node_modules symlinks;
10
+ * - the `assets` → generated `bf-assets.json` behavior, exercised the same
11
+ * way (a real build, since it needs the real manifest Vite writes).
12
+ *
13
+ * Unlike Go, there is no `afterEmit`-driven type-combination step to test
14
+ * here (see `vite.ts`'s module docstring) — `JinjaAdapter.generate()` never
15
+ * produces a `types` section at all, so `barefoot()` always returns a
16
+ * single-element array unless `assets` is set.
17
+ */
18
+ import { describe, test, expect } from 'bun:test'
19
+ import { build } from 'vite'
20
+ import { mkdtemp, rm, readFile } from 'node:fs/promises'
21
+ import { tmpdir } from 'node:os'
22
+ import { join, resolve } from 'node:path'
23
+ import { barefoot, barefoot as defaultBarefoot } from '../vite.ts'
24
+
25
+ const FIXTURE_ROOT = resolve(import.meta.dirname, '../../e2e-fixture')
26
+
27
+ describe('@barefootjs/jinja/vite: real vite build', () => {
28
+ test('exports the same function as both named `barefoot` and default', () => {
29
+ expect(defaultBarefoot).toBe(barefoot)
30
+ })
31
+
32
+ test('returns a single-element plugin array when `assets` is omitted', () => {
33
+ const plugins = barefoot({ components: ['src/components'], templates: 'views' })
34
+ expect(plugins).toHaveLength(1)
35
+ })
36
+
37
+ test('writes a self-contained .jinja template with scriptAssets baked in, same as core alone would do', async () => {
38
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-jinja-vite-dist-'))
39
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-jinja-vite-views-'))
40
+
41
+ try {
42
+ await build({
43
+ configFile: false,
44
+ root: FIXTURE_ROOT,
45
+ base: '/static/build/',
46
+ logLevel: 'warn',
47
+ build: { outDir, emptyOutDir: true },
48
+ plugins: barefoot({
49
+ components: ['src/components'],
50
+ templates: templatesDir,
51
+ }),
52
+ })
53
+
54
+ const template = await readFile(join(templatesDir, 'Counter.jinja'), 'utf8')
55
+ expect(template).toContain("bf.register_script(")
56
+ } finally {
57
+ await rm(outDir, { recursive: true, force: true })
58
+ await rm(templatesDir, { recursive: true, force: true })
59
+ }
60
+ }, 60_000)
61
+
62
+ test('`assets` resolves a non-component entry\'s manifest-hashed URL into a generated JSON asset map', async () => {
63
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-jinja-vite-dist-assets-'))
64
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-jinja-vite-views-assets-'))
65
+ const assetsPath = join(FIXTURE_ROOT, 'dist/bf-assets.json')
66
+
67
+ try {
68
+ await build({
69
+ configFile: false,
70
+ root: FIXTURE_ROOT,
71
+ base: '/static/build/',
72
+ logLevel: 'warn',
73
+ build: {
74
+ outDir,
75
+ emptyOutDir: true,
76
+ // Registering the non-component entry is the CALLER's job (stock
77
+ // Vite config) — `assets` below only resolves the URL Vite
78
+ // already bundled it to, it doesn't request the bundling.
79
+ rollupOptions: { input: { bootstrap: resolve(FIXTURE_ROOT, 'client/bootstrap.ts') } },
80
+ },
81
+ plugins: barefoot({
82
+ components: ['src/components'],
83
+ templates: templatesDir,
84
+ assets: { Bootstrap: 'client/bootstrap.ts' },
85
+ }),
86
+ })
87
+
88
+ const manifest = JSON.parse(await readFile(join(outDir, '.vite/manifest.json'), 'utf8'))
89
+ const expectedUrl = `/static/build/${manifest['client/bootstrap.ts'].file}`
90
+
91
+ const content = JSON.parse(await readFile(assetsPath, 'utf8'))
92
+ expect(content).toEqual({ Bootstrap: expectedUrl })
93
+ } finally {
94
+ await rm(outDir, { recursive: true, force: true })
95
+ await rm(templatesDir, { recursive: true, force: true })
96
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
97
+ }
98
+ }, 60_000)
99
+
100
+ test('`assets` throws an actionable error when the entry was never registered as a Rollup input', async () => {
101
+ const outDir = await mkdtemp(join(tmpdir(), 'barefoot-jinja-vite-dist-assets-missing-'))
102
+ const templatesDir = await mkdtemp(join(tmpdir(), 'barefoot-jinja-vite-views-assets-missing-'))
103
+
104
+ try {
105
+ await expect(
106
+ build({
107
+ configFile: false,
108
+ root: FIXTURE_ROOT,
109
+ base: '/static/build/',
110
+ logLevel: 'silent',
111
+ build: { outDir, emptyOutDir: true },
112
+ plugins: barefoot({
113
+ components: ['src/components'],
114
+ templates: templatesDir,
115
+ assets: { Bootstrap: 'client/bootstrap.ts' },
116
+ }),
117
+ }),
118
+ ).rejects.toThrow(/was not found in the build manifest/)
119
+ } finally {
120
+ await rm(outDir, { recursive: true, force: true })
121
+ await rm(templatesDir, { recursive: true, force: true })
122
+ await rm(join(FIXTURE_ROOT, 'dist'), { recursive: true, force: true })
123
+ }
124
+ }, 60_000)
125
+ })
@@ -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/jinja/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 jinjaIdent(this.param)
@@ -296,7 +314,13 @@ export class JinjaFilterEmitter implements ParsedExprEmitter {
296
314
  * - no lambda fallback exists (see the file header, divergence 2).
297
315
  */
298
316
  export class JinjaTopLevelEmitter implements ParsedExprEmitter {
299
- constructor(private readonly ctx: JinjaEmitContext) {}
317
+ // Plain field + assignment, not a parameter property — see
318
+ // `JinjaFilterEmitter`'s constructor comment above for why.
319
+ private readonly ctx: JinjaEmitContext
320
+
321
+ constructor(ctx: JinjaEmitContext) {
322
+ this.ctx = ctx
323
+ }
300
324
 
301
325
  identifier(name: string): string {
302
326
  // `undefined` / `null` nested inside a larger expression tree — Jinja
@@ -193,9 +193,6 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
193
193
  name = 'jinja'
194
194
  extension = '.jinja'
195
195
  templatesPerComponent = true
196
- // Template-string target with no component layer: `bf build` emits a static
197
- // import-map HTML snippet to include into the page <head>.
198
- importMapInjection = 'html-snippet' as const
199
196
 
200
197
  /**
201
198
  * Identifier-path callees the Jinja runtime can render in template scope.
@@ -360,7 +357,7 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
360
357
  // Generate script registration
361
358
  const scriptReg = options?.skipScriptRegistration
362
359
  ? ''
363
- : this.generateScriptRegistrations(ir, options?.scriptBaseName)
360
+ : this.generateScriptRegistrations(ir, options?.scriptBaseName, options?.scriptAssets, options?.preloadAssets)
364
361
 
365
362
  // SSR context consumers (`const x = useContext(Ctx)`): seed each local
366
363
  // from the active provider value (or the `createContext` default). The
@@ -402,7 +399,32 @@ export class JinjaAdapter extends BaseAdapter implements IRNodeEmitter<JinjaRend
402
399
  // Script Registration
403
400
  // ===========================================================================
404
401
 
405
- private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string): string {
402
+ private generateScriptRegistrations(ir: ComponentIR, scriptBaseName?: string, scriptAssets?: string[], preloadAssets?: string[]): string {
403
+ // `scriptAssets`, when present (including `[]`), fully supersedes the
404
+ // adapter-computed `barefootJsPath` / `clientJsBasePath` pair — see
405
+ // `AdapterGenerateOptions.scriptAssets`. The caller (e.g. the Vite
406
+ // plugin) has already decided the exact ordered URL list, including
407
+ // whether any script is needed at all.
408
+ if (scriptAssets) {
409
+ if (scriptAssets.length === 0) return ''
410
+ const lines: string[] = []
411
+ // `preloadAssets` is only meaningful alongside a non-empty
412
+ // `scriptAssets` (see `AdapterGenerateOptions.preloadAssets`) — this
413
+ // branch is only reached when that already holds. Emitted BEFORE the
414
+ // script registrations: every preload hint must precede the script
415
+ // tags it describes, or the hint is useless.
416
+ if (preloadAssets && preloadAssets.length > 0) {
417
+ preloadAssets.forEach((url, i) => {
418
+ lines.push(`{% set _bf_pre${i} = bf.register_preload('${url}') %}`)
419
+ })
420
+ }
421
+ scriptAssets.forEach((url, i) => {
422
+ lines.push(`{% set _bf_reg${i} = bf.register_script('${url}') %}`)
423
+ })
424
+ lines.push('')
425
+ return lines.join('\n')
426
+ }
427
+
406
428
  const hasInteractivity = hasClientInteractivity(ir)
407
429
  if (!hasInteractivity) return ''
408
430