@barefootjs/jsx 0.30.6 → 0.31.0

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.
Files changed (37) hide show
  1. package/dist/adapters/interface.d.ts +61 -20
  2. package/dist/adapters/interface.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts +1 -1
  4. package/dist/analyzer.d.ts.map +1 -1
  5. package/dist/compiler.d.ts.map +1 -1
  6. package/dist/index.d.ts +0 -120
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +322 -263
  9. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
  11. package/dist/ir-to-client-js/prune-unused-prop-extractions.d.ts +7 -0
  12. package/dist/ir-to-client-js/prune-unused-prop-extractions.d.ts.map +1 -0
  13. package/dist/types.d.ts +25 -0
  14. package/dist/types.d.ts.map +1 -1
  15. package/dist/value-references.d.ts +7 -7
  16. package/dist/value-references.d.ts.map +1 -1
  17. package/package.json +3 -7
  18. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +183 -312
  19. package/src/__tests__/bf050-single-multi-symmetry.test.ts +118 -0
  20. package/src/__tests__/client-js-generation.test.ts +5 -1
  21. package/src/__tests__/doc-examples.test.ts +5 -1
  22. package/src/__tests__/prune-unused-prop-extractions.test.ts +78 -0
  23. package/src/adapters/interface.ts +61 -20
  24. package/src/analyzer.ts +16 -5
  25. package/src/compiler.ts +41 -3
  26. package/src/index.ts +0 -123
  27. package/src/ir-to-client-js/emit-registration.ts +9 -0
  28. package/src/ir-to-client-js/generate-init.ts +4 -1
  29. package/src/ir-to-client-js/index.ts +5 -1
  30. package/src/ir-to-client-js/prune-unused-prop-extractions.ts +108 -0
  31. package/src/types.ts +25 -0
  32. package/src/value-references.ts +7 -7
  33. package/dist/import-map.d.ts +0 -56
  34. package/dist/import-map.d.ts.map +0 -1
  35. package/dist/import-map.js +0 -18
  36. package/src/__tests__/import-map.test.ts +0 -75
  37. package/src/import-map.ts +0 -72
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Final-pass removal of prop-extraction consts the emitted init body never
3
+ * reads (`const children = _p.children` and friends).
4
+ *
5
+ * `emitPropsExtraction` mirrors the component's destructuring for every
6
+ * prop the REFERENCE GRAPH marks as used — but the graph also counts
7
+ * template-only references (e.g. `{children}` rendered as SSR-adopted
8
+ * content), which the init body never touches. The stray binding is not
9
+ * just dead weight: props arrive as GETTERS over the parent's reactive
10
+ * state, and a slot-children getter (`get children() { return
11
+ * [createComponent('Checkbox', …)] }`) INSTANTIATES child components when
12
+ * read. An init that eagerly evaluates it creates a second, duplicate
13
+ * child instance next to the `upsertChild` wiring the compiler also
14
+ * emits — double event listeners, toggles that cancel themselves out.
15
+ * The legacy site pipeline masked this by registration order (children
16
+ * modules registered after the parent's init queued, so the queued init
17
+ * ran against an already-hydrated tree); Vite's ESM import order
18
+ * registers children first and made the eager read bite (#2537's
19
+ * migration surfaced it on site/ui's form-builder).
20
+ *
21
+ * Mirrors `resolveFinalImports`'s shape: a TS AST walk over the finished
22
+ * code (never a regex — see CLAUDE.md), span-based splicing, iterated to
23
+ * a fixpoint so an extraction referenced only by another pruned
24
+ * extraction's default expression is removed too.
25
+ *
26
+ * Known limits, both erring toward FALSE-KEEP (an extraction survives and
27
+ * the eager getter read stays), never false-prune:
28
+ * - The reference scan is whole-file and scope-unaware: any other
29
+ * occurrence of the same identifier text as a value (a module-level
30
+ * helper's parameter named `children`, a local in an unrelated closure)
31
+ * keeps the extraction alive.
32
+ * - Splicing removes a statement's full leading trivia, so a comment an
33
+ * emitter attached directly above a pruned extraction goes with it.
34
+ */
35
+ import ts from 'typescript'
36
+ import { collectValueReferencedNames } from '../value-references.ts'
37
+ import { PROPS_PARAM } from './utils.ts'
38
+
39
+ /** Is `stmt` a single-declarator `const X = _p.X…` prop extraction whose
40
+ * initializer is `_p.X` or `_p.X ?? <default>`? Returns the bound name, or
41
+ * null when the statement is anything else. */
42
+ function propExtractionName(stmt: ts.Statement): string | null {
43
+ if (!ts.isVariableStatement(stmt)) return null
44
+ const decls = stmt.declarationList.declarations
45
+ if (decls.length !== 1) return null
46
+ const decl = decls[0]!
47
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) return null
48
+
49
+ let core: ts.Expression = decl.initializer
50
+ if (
51
+ ts.isBinaryExpression(core) &&
52
+ core.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
53
+ ) {
54
+ core = core.left
55
+ }
56
+ if (!ts.isPropertyAccessExpression(core)) return null
57
+ if (!ts.isIdentifier(core.expression) || core.expression.text !== PROPS_PARAM) return null
58
+ if (core.name.text !== decl.name.text) return null
59
+ return decl.name.text
60
+ }
61
+
62
+ /**
63
+ * Remove prop-extraction consts inside `init*` functions whose bound name
64
+ * is never referenced as a value anywhere else in `code`. Returns the code
65
+ * unchanged when nothing is prunable or the code doesn't parse.
66
+ */
67
+ export function pruneUnusedPropExtractions(code: string): string {
68
+ for (let round = 0; round < 10; round++) {
69
+ const referenced = collectValueReferencedNames(code)
70
+ if (referenced === null) {
71
+ // Unlike resolveFinalImports' identical fallback (where a missed
72
+ // import fails the build loudly), skipping here silently re-opens
73
+ // the eager-getter double-instantiation this pass exists to prevent
74
+ // — so say so.
75
+ console.warn('[barefootjs] pruneUnusedPropExtractions: generated code did not parse; skipping prune')
76
+ return code
77
+ }
78
+
79
+ const sourceFile = ts.createSourceFile(
80
+ 'generated.js',
81
+ code,
82
+ ts.ScriptTarget.Latest,
83
+ /*setParentNodes*/ false,
84
+ ts.ScriptKind.JS,
85
+ )
86
+
87
+ // Spans to delete, gathered per round; spliced back-to-front so
88
+ // earlier offsets stay valid.
89
+ const spans: Array<{ start: number; end: number }> = []
90
+ for (const stmt of sourceFile.statements) {
91
+ if (!ts.isFunctionDeclaration(stmt) || !stmt.name?.text.startsWith('init') || !stmt.body) continue
92
+ for (const inner of stmt.body.statements) {
93
+ const name = propExtractionName(inner)
94
+ if (name !== null && !referenced.has(name)) {
95
+ spans.push({ start: inner.getFullStart(), end: inner.getEnd() })
96
+ }
97
+ }
98
+ }
99
+
100
+ if (spans.length === 0) return code
101
+ let next = code
102
+ for (const { start, end } of spans.sort((a, b) => b.start - a.start)) {
103
+ next = next.slice(0, start) + next.slice(end)
104
+ }
105
+ code = next
106
+ }
107
+ return code
108
+ }
package/src/types.ts CHANGED
@@ -2225,6 +2225,31 @@ export interface CompileOptions {
2225
2225
  * byte-for-byte unchanged (SR8). Dev/profiling builds only.
2226
2226
  */
2227
2227
  profile?: boolean
2228
+ /**
2229
+ * Forwarded verbatim to `adapter.generate(..., { scriptAssets })` — see
2230
+ * that field's docstring on `AdapterGenerateOptions` for the full
2231
+ * precedence rules (`skipScriptRegistration` still wins; `[]` means "no
2232
+ * scripts", distinct from `undefined`'s "use the adapter-computed path").
2233
+ *
2234
+ * This is plain resolved data (an ordered URL list), not a rewrite
2235
+ * callback — the caller (chiefly `@barefootjs/vite`) has already done all
2236
+ * the resolution (bundling, hashing, manifest lookup) before calling
2237
+ * `compileJSX`; the compiler only threads the list through to the
2238
+ * adapter unchanged.
2239
+ */
2240
+ scriptAssets?: string[]
2241
+ /**
2242
+ * Forwarded verbatim to `adapter.generate(..., { preloadAssets })` — see
2243
+ * that field's docstring on `AdapterGenerateOptions` for the full
2244
+ * precedence rules (`skipScriptRegistration` still wins; `[]` means
2245
+ * "resolved, nothing to preload", distinct from `undefined`'s "no
2246
+ * preload information").
2247
+ *
2248
+ * Same plain-resolved-data contract as `scriptAssets`: the caller
2249
+ * (`@barefootjs/vite`) has already walked the manifest; the compiler
2250
+ * only threads the list through unchanged.
2251
+ */
2252
+ preloadAssets?: string[]
2228
2253
  }
2229
2254
 
2230
2255
  export interface FileOutput {
@@ -11,9 +11,10 @@
11
11
  * `return { … }` with no binding — `ReferenceError: Theme is not defined`
12
12
  * at load, killing the whole page's client JS.
13
13
  *
14
- * `packages/cli`'s `detectStrippedReferences` (in `resolve-imports.ts`)
15
- * shares the same classifier for its own dangling-reference scan, so the
16
- * two "is this a real use" checks in the pipeline can never drift apart.
14
+ * `packages/cli`'s `detectStrippedReferences` (in the since-deleted
15
+ * `resolve-imports.ts`) used to share this classifier for its own
16
+ * dangling-reference scan; `collectExternalImports` is the remaining
17
+ * caller.
17
18
  */
18
19
 
19
20
  import ts from 'typescript'
@@ -27,10 +28,9 @@ import ts from 'typescript'
27
28
  * reference — it reads the binding, it doesn't just spell its name.
28
29
  *
29
30
  * CONTRACT: this classifies identifier positions in **JavaScript** source.
30
- * Both current callers parse with `ts.ScriptKind.JS` —
31
- * `collectValueReferencedNames` below, and `detectStrippedReferences` in
32
- * `packages/cli/src/lib/resolve-imports.ts`, which parses the assembled
33
- * bundle. TypeScript-only positions are deliberately NOT handled: an
31
+ * The current caller parses with `ts.ScriptKind.JS` —
32
+ * `collectValueReferencedNames` below.
33
+ * TypeScript-only positions are deliberately NOT handled: an
34
34
  * identifier in a type position (`const x: Foo = …`, a
35
35
  * `TypeReferenceNode`) is still reported as a value reference, and so are
36
36
  * `interface` / `type` / `enum` declaration names. Do not point this at
@@ -1,56 +0,0 @@
1
- /**
2
- * Shared importmap-snippet renderer.
3
- *
4
- * `bf build` emits `barefoot-externals.json` (the `ExternalsManifest`) whenever
5
- * `externals` / `bundleEntries` are configured. Component adapters (Hono) read
6
- * that manifest at render time via a JSX component (`BfImportMap`). Template-
7
- * string adapters (Go html/template, Mojolicious EP) have no component layer, so
8
- * `bf build` instead emits a static `barefoot-importmap.html` for them to
9
- * `{{ template }}` / `%= include` into the page `<head>`.
10
- *
11
- * This module is the single source of truth for that snippet's HTML, so every
12
- * adapter's importmap injection point stays in sync. See issue #1644.
13
- */
14
- /**
15
- * The subset of `barefoot-externals.json` needed to render the importmap
16
- * snippet. All fields are optional so a partial or hand-written manifest
17
- * (e.g. a Hono `BfImportMap` `externals` prop) still type-checks. The strict
18
- * build-output `ExternalsManifest` is structurally assignable to this, so both
19
- * the CLI's emitted manifest and a hand-written one feed `renderImportMapHtml`.
20
- * This is the one shared manifest type for every importmap injection path.
21
- */
22
- export interface ImportMapManifest {
23
- /** Entries for `<script type="importmap">`. */
24
- importmap?: {
25
- imports?: Record<string, string>;
26
- };
27
- /** URLs to emit as `<link rel="modulepreload">`. */
28
- preloads?: string[];
29
- }
30
- /**
31
- * Shape of `barefoot-externals.json`, written by `bf build`. This is the build
32
- * output contract shared by the CLI (which writes it) and the adapters (which
33
- * consume it) — the all-required superset of {@link ImportMapManifest}.
34
- */
35
- export interface ExternalsManifest {
36
- /** Entries for `<script type="importmap">`. */
37
- importmap: {
38
- imports: Record<string, string>;
39
- };
40
- /** URLs to emit as `<link rel="modulepreload">`. */
41
- preloads: string[];
42
- /** Package names to pass as `--external` to the user's bundler. */
43
- externals: string[];
44
- }
45
- /**
46
- * Render the `<script type="importmap">` (plus `<link rel="modulepreload">`)
47
- * snippet from a parsed externals manifest. Fields are read defensively so a
48
- * partial or hand-written manifest still produces valid output.
49
- *
50
- * Inside the importmap JSON, each `<` is replaced with its JSON unicode escape
51
- * for code point U+003C so a URL containing `</script>` cannot break out of the
52
- * script element — the JSON parser decodes that escape back to `<`, keeping the
53
- * mapping value-identical.
54
- */
55
- export declare function renderImportMapHtml(manifest: ImportMapManifest): string;
56
- //# sourceMappingURL=import-map.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"import-map.d.ts","sourceRoot":"","sources":["../src/import-map.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH;;;;;;;GAOG;AACH,MAAM,WAAW,iBAAiB;IAChC,+CAA+C;IAC/C,SAAS,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAA;IAChD,oDAAoD;IACpD,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CACpB;AAED;;;;GAIG;AACH,MAAM,WAAW,iBAAiB;IAChC,+CAA+C;IAC/C,SAAS,EAAE;QAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;KAAE,CAAA;IAC9C,oDAAoD;IACpD,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,mEAAmE;IACnE,SAAS,EAAE,MAAM,EAAE,CAAA;CACpB;AAOD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,iBAAiB,GAAG,MAAM,CAavE"}
@@ -1,18 +0,0 @@
1
- // src/import-map.ts
2
- function escapeHtmlAttr(value) {
3
- return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
4
- }
5
- function renderImportMapHtml(manifest) {
6
- const imports = manifest.importmap?.imports ?? {};
7
- const json = JSON.stringify({ imports }).replace(/</g, "\\u003c");
8
- const lines = [`<script type="importmap">${json}</script>`];
9
- for (const href of manifest.preloads ?? []) {
10
- lines.push(`<link rel="modulepreload" href="${escapeHtmlAttr(href)}" crossorigin>`);
11
- }
12
- return lines.join(`
13
- `) + `
14
- `;
15
- }
16
- export {
17
- renderImportMapHtml
18
- };
@@ -1,75 +0,0 @@
1
- /**
2
- * renderImportMapHtml tests
3
- *
4
- * The shared importmap-snippet renderer turns a parsed `barefoot-externals.json`
5
- * into the `<script type="importmap">` (+ `<link rel="modulepreload">`) HTML that
6
- * `bf build` emits as `barefoot-importmap.html` for template-string adapters
7
- * (issue #1644). This is the single source of truth for that snippet.
8
- */
9
- import { describe, test, expect } from 'bun:test'
10
- import { renderImportMapHtml } from '../import-map'
11
-
12
- function parseImportMap(html: string): Record<string, string> {
13
- const match = html.match(/<script type="importmap">(.*?)<\/script>/s)
14
- if (!match) throw new Error(`no importmap in: ${html}`)
15
- // Decode the < escape the renderer applies before parsing.
16
- return JSON.parse(match[1]).imports
17
- }
18
-
19
- describe('renderImportMapHtml', () => {
20
- test('emits the manifest importmap imports verbatim', () => {
21
- const html = renderImportMapHtml({
22
- importmap: {
23
- imports: {
24
- '@barefootjs/client': '/components/barefoot.js',
25
- '@barefootjs/client/runtime': '/components/barefoot.js',
26
- zod: 'https://esm.sh/zod@4.4.3',
27
- },
28
- },
29
- preloads: [],
30
- })
31
- expect(parseImportMap(html)).toEqual({
32
- '@barefootjs/client': '/components/barefoot.js',
33
- '@barefootjs/client/runtime': '/components/barefoot.js',
34
- zod: 'https://esm.sh/zod@4.4.3',
35
- })
36
- expect(html).not.toContain('modulepreload')
37
- })
38
-
39
- test('emits modulepreload links with crossorigin for manifest preloads (#1648)', () => {
40
- const html = renderImportMapHtml({
41
- importmap: { imports: {} },
42
- preloads: ['/components/form.js', 'https://esm.sh/zod@4.4.3'],
43
- })
44
- expect(html).toContain('<link rel="modulepreload" href="/components/form.js" crossorigin>')
45
- expect(html).toContain('<link rel="modulepreload" href="https://esm.sh/zod@4.4.3" crossorigin>')
46
- })
47
-
48
- test('reads defensively from a partial manifest', () => {
49
- expect(parseImportMap(renderImportMapHtml({}))).toEqual({})
50
- expect(renderImportMapHtml({})).not.toContain('modulepreload')
51
- })
52
-
53
- test('ends with a trailing newline (template-include friendly)', () => {
54
- expect(renderImportMapHtml({ importmap: { imports: {} } }).endsWith('\n')).toBe(true)
55
- })
56
-
57
- test('escapes < in the importmap JSON so a URL cannot break out of the script', () => {
58
- const html = renderImportMapHtml({
59
- importmap: { imports: { evil: 'https://x/</script><script>alert(1)</script>' } },
60
- })
61
- // The literal closing tag must not appear before the importmap's own.
62
- const importmapClose = html.indexOf('</script>')
63
- expect(html.slice(0, importmapClose)).not.toContain('</script>')
64
- // But the value still round-trips through JSON.parse.
65
- expect(parseImportMap(html).evil).toBe('https://x/</script><script>alert(1)</script>')
66
- })
67
-
68
- test('escapes double quotes and angle brackets in preload hrefs', () => {
69
- const html = renderImportMapHtml({
70
- preloads: ['/components/"onerror=alert(1).js'],
71
- })
72
- expect(html).not.toContain('"onerror=alert(1)')
73
- expect(html).toContain('&quot;onerror=alert(1)')
74
- })
75
- })
package/src/import-map.ts DELETED
@@ -1,72 +0,0 @@
1
- /**
2
- * Shared importmap-snippet renderer.
3
- *
4
- * `bf build` emits `barefoot-externals.json` (the `ExternalsManifest`) whenever
5
- * `externals` / `bundleEntries` are configured. Component adapters (Hono) read
6
- * that manifest at render time via a JSX component (`BfImportMap`). Template-
7
- * string adapters (Go html/template, Mojolicious EP) have no component layer, so
8
- * `bf build` instead emits a static `barefoot-importmap.html` for them to
9
- * `{{ template }}` / `%= include` into the page `<head>`.
10
- *
11
- * This module is the single source of truth for that snippet's HTML, so every
12
- * adapter's importmap injection point stays in sync. See issue #1644.
13
- */
14
-
15
- /**
16
- * The subset of `barefoot-externals.json` needed to render the importmap
17
- * snippet. All fields are optional so a partial or hand-written manifest
18
- * (e.g. a Hono `BfImportMap` `externals` prop) still type-checks. The strict
19
- * build-output `ExternalsManifest` is structurally assignable to this, so both
20
- * the CLI's emitted manifest and a hand-written one feed `renderImportMapHtml`.
21
- * This is the one shared manifest type for every importmap injection path.
22
- */
23
- export interface ImportMapManifest {
24
- /** Entries for `<script type="importmap">`. */
25
- importmap?: { imports?: Record<string, string> }
26
- /** URLs to emit as `<link rel="modulepreload">`. */
27
- preloads?: string[]
28
- }
29
-
30
- /**
31
- * Shape of `barefoot-externals.json`, written by `bf build`. This is the build
32
- * output contract shared by the CLI (which writes it) and the adapters (which
33
- * consume it) — the all-required superset of {@link ImportMapManifest}.
34
- */
35
- export interface ExternalsManifest {
36
- /** Entries for `<script type="importmap">`. */
37
- importmap: { imports: Record<string, string> }
38
- /** URLs to emit as `<link rel="modulepreload">`. */
39
- preloads: string[]
40
- /** Package names to pass as `--external` to the user's bundler. */
41
- externals: string[]
42
- }
43
-
44
- /** Escape a value for use inside a double-quoted HTML attribute. */
45
- function escapeHtmlAttr(value: string): string {
46
- return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
47
- }
48
-
49
- /**
50
- * Render the `<script type="importmap">` (plus `<link rel="modulepreload">`)
51
- * snippet from a parsed externals manifest. Fields are read defensively so a
52
- * partial or hand-written manifest still produces valid output.
53
- *
54
- * Inside the importmap JSON, each `<` is replaced with its JSON unicode escape
55
- * for code point U+003C so a URL containing `</script>` cannot break out of the
56
- * script element — the JSON parser decodes that escape back to `<`, keeping the
57
- * mapping value-identical.
58
- */
59
- export function renderImportMapHtml(manifest: ImportMapManifest): string {
60
- const imports = manifest.importmap?.imports ?? {}
61
- const json = JSON.stringify({ imports }).replace(/</g, '\\u003c')
62
- const lines = [`<script type="importmap">${json}</script>`]
63
- for (const href of manifest.preloads ?? []) {
64
- // `crossorigin` is required so a cross-origin (CDN) preload's request
65
- // matches the actual module `import` (always a CORS fetch); without it
66
- // the browser discards the preload and re-fetches. Harmless for
67
- // same-origin preloads, which use the same credentials mode either way.
68
- // See issue #1648.
69
- lines.push(`<link rel="modulepreload" href="${escapeHtmlAttr(href)}" crossorigin>`)
70
- }
71
- return lines.join('\n') + '\n'
72
- }