@barefootjs/jsx 0.7.0 → 0.9.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.
@@ -0,0 +1,97 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+
3
+ import { augmentInheritedPropAccesses } from '../augment-inherited-props'
4
+ import type { ComponentIR, IRMetadata, ParamInfo } from '../index'
5
+
6
+ /**
7
+ * Build a minimal `ComponentIR` exercising the SolidJS props-object pattern.
8
+ * Only the fields `augmentInheritedPropAccesses` reads are populated; the rest
9
+ * are cast through `as` so the test stays focused on the scanned inputs.
10
+ */
11
+ function makeIR(opts: {
12
+ propsObjectName: string | null
13
+ propsParams: ParamInfo[]
14
+ memos?: Array<{ computation: string }>
15
+ effects?: Array<{ body: string }>
16
+ initStatements?: Array<{ body: string }>
17
+ root?: unknown
18
+ }): ComponentIR {
19
+ const metadata = {
20
+ componentName: 'Checkbox',
21
+ propsObjectName: opts.propsObjectName,
22
+ propsParams: opts.propsParams,
23
+ memos: opts.memos ?? [],
24
+ signals: [],
25
+ effects: opts.effects ?? [],
26
+ initStatements: opts.initStatements ?? [],
27
+ } as unknown as IRMetadata
28
+
29
+ return {
30
+ root: (opts.root ?? { type: 'element', tag: 'div', attrs: [], children: [] }) as never,
31
+ metadata,
32
+ errors: [],
33
+ } as unknown as ComponentIR
34
+ }
35
+
36
+ describe('augmentInheritedPropAccesses', () => {
37
+ test('adds inherited accessed props from memos, effects, and template attrs', () => {
38
+ const ir = makeIR({
39
+ propsObjectName: 'props',
40
+ propsParams: [{ name: 'checked', type: { kind: 'primitive', raw: 'boolean', primitive: 'boolean' }, optional: false }],
41
+ // className read in a classes memo → string
42
+ memos: [{ computation: '`base ${props.className}`' }],
43
+ // size read only in an effect → string (default classification)
44
+ effects: [{ body: 'console.log(props.size)' }],
45
+ root: {
46
+ type: 'element',
47
+ tag: 'button',
48
+ attrs: [
49
+ // bare-reference attribute → nillable (unknown)
50
+ { name: 'id', value: { kind: 'expression', expr: 'props.id' } },
51
+ // boolean attribute → boolean
52
+ { name: 'disabled', value: { kind: 'expression', expr: 'props.disabled ?? false' } },
53
+ ],
54
+ children: [],
55
+ },
56
+ })
57
+
58
+ augmentInheritedPropAccesses(ir)
59
+
60
+ const byName = new Map(ir.metadata.propsParams.map(p => [p.name, p]))
61
+ // Pre-existing param untouched.
62
+ expect(byName.get('checked')?.type.kind).toBe('primitive')
63
+
64
+ // className → string (memo / string context)
65
+ expect(byName.get('className')?.type).toMatchObject({ kind: 'primitive', primitive: 'string' })
66
+ // size → string, accessed ONLY in an effect (the intentional Mojo-unification path)
67
+ expect(byName.get('size')?.type).toMatchObject({ kind: 'primitive', primitive: 'string' })
68
+ // id → unknown (bare-reference, nillable/omittable)
69
+ expect(byName.get('id')?.type).toMatchObject({ kind: 'unknown' })
70
+ // disabled → boolean (boolean HTML attribute, even with `?? false`)
71
+ expect(byName.get('disabled')?.type).toMatchObject({ kind: 'primitive', primitive: 'boolean' })
72
+
73
+ // Synthetic params are marked optional.
74
+ for (const name of ['className', 'size', 'id', 'disabled']) {
75
+ expect(byName.get(name)?.optional).toBe(true)
76
+ }
77
+ })
78
+
79
+ test('is idempotent — re-running adds nothing', () => {
80
+ const ir = makeIR({
81
+ propsObjectName: 'props',
82
+ propsParams: [],
83
+ memos: [{ computation: 'props.className' }],
84
+ })
85
+ augmentInheritedPropAccesses(ir)
86
+ const afterFirst = ir.metadata.propsParams.length
87
+ augmentInheritedPropAccesses(ir)
88
+ expect(ir.metadata.propsParams.length).toBe(afterFirst)
89
+ expect(afterFirst).toBe(1)
90
+ })
91
+
92
+ test('no-op when there is no props-object pattern', () => {
93
+ const ir = makeIR({ propsObjectName: null, propsParams: [], memos: [{ computation: 'props.className' }] })
94
+ augmentInheritedPropAccesses(ir)
95
+ expect(ir.metadata.propsParams.length).toBe(0)
96
+ })
97
+ })
@@ -0,0 +1,68 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { mergeTemplateImports } from '../compiler'
3
+
4
+ describe('mergeTemplateImports', () => {
5
+ test('merges same-source named imports with disjoint + overlapping symbols', () => {
6
+ const out = mergeTemplateImports([
7
+ "import { bfText, bfTextEnd } from '@barefootjs/hono/utils'",
8
+ "import { bfComment } from '@barefootjs/hono/utils'",
9
+ "import { bfComment, bfText, bfTextEnd } from '@barefootjs/hono/utils'",
10
+ ])
11
+ // Single statement, no redeclared binding (Deno rejects duplicates).
12
+ expect(out).toBe("import { bfText, bfTextEnd, bfComment } from '@barefootjs/hono/utils'")
13
+ expect((out.match(/from '@barefootjs\/hono\/utils'/g) ?? []).length).toBe(1)
14
+ })
15
+
16
+ // The merge must not depend on the emitter's exact spacing: a named import
17
+ // that slipped past the matcher would fall through to by-line dedup and
18
+ // re-introduce the duplicate `bfComment` binding Deno rejects. Mixed
19
+ // spacing (compact, padded, double-spaced `from`, trailing `;`) must still
20
+ // collapse to one statement.
21
+ test('folds same-source imports regardless of whitespace / trailing semicolon', () => {
22
+ const out = mergeTemplateImports([
23
+ "import {bfText,bfTextEnd} from '@barefootjs/hono/utils'",
24
+ "import { bfComment } from \"@barefootjs/hono/utils\";",
25
+ "import { bfComment, bfText } from '@barefootjs/hono/utils'",
26
+ ])
27
+ expect(out).toBe("import { bfText, bfTextEnd, bfComment } from '@barefootjs/hono/utils'")
28
+ expect((out.match(/from '@barefootjs\/hono\/utils'/g) ?? []).length).toBe(1)
29
+ })
30
+
31
+ // `import type` must stay separate from the value import even with compact
32
+ // spacing — the value matcher must not swallow a type-only line.
33
+ test('keeps type vs value separate under compact spacing', () => {
34
+ const out = mergeTemplateImports([
35
+ "import {Foo} from 'x'",
36
+ "import type {Bar} from 'x'",
37
+ ])
38
+ expect(out).toBe("import { Foo } from 'x'\nimport type { Bar } from 'x'")
39
+ })
40
+
41
+ test('single-component input is unchanged (order preserved)', () => {
42
+ const lines = [
43
+ "import { bfComment, bfText, bfTextEnd } from '@barefootjs/hono/utils'",
44
+ "import { createSignal } from '@barefootjs/hono/client-shim'",
45
+ "import { Button } from '@/components/ui/button'",
46
+ ]
47
+ expect(mergeTemplateImports(lines)).toBe(lines.join('\n'))
48
+ })
49
+
50
+ test('keeps value and type imports from the same source separate', () => {
51
+ const out = mergeTemplateImports([
52
+ "import { Foo } from 'x'",
53
+ "import type { Bar } from 'x'",
54
+ "import { Baz } from 'x'",
55
+ ])
56
+ expect(out).toBe("import { Foo, Baz } from 'x'\nimport type { Bar } from 'x'")
57
+ })
58
+
59
+ test('passes through and dedupes side-effect / default imports by line', () => {
60
+ const out = mergeTemplateImports([
61
+ "import './a.css'",
62
+ "import Foo from 'foo'",
63
+ "import './a.css'",
64
+ "import { x } from 'm'",
65
+ ])
66
+ expect(out).toBe("import './a.css'\nimport Foo from 'foo'\nimport { x } from 'm'")
67
+ })
68
+ })
@@ -115,4 +115,28 @@ describe('extractSsrDefaults', () => {
115
115
  expect(defaults?.count).toEqual({ value: 3 })
116
116
  expect(defaults?.squared).toEqual({ value: 9 })
117
117
  })
118
+
119
+ // (#checkbox) A className memo interpolating module string consts — incl. a
120
+ // `[...].join(' ')` const — plus `props.className ?? ''` resolves to a
121
+ // concrete string so the SSR `class="..."` renders the full token list
122
+ // (Checkbox's `classes` memo). Without seeding module consts / evaluating
123
+ // `.join`, the memo collapsed to `null` and the class attribute rendered
124
+ // empty.
125
+ test('module-const + join template-literal className memo resolves to a string', () => {
126
+ const metadata = metadataFor(`
127
+ 'use client'
128
+ import { createMemo } from '@barefootjs/client'
129
+ const base = 'a b'
130
+ const states = ['c', 'd'].join(' ')
131
+ function Box(props: { tone?: string }) {
132
+ const classes = createMemo(() => \`\${base} \${states} \${props.className ?? ''} tail\`)
133
+ return <button class={classes()}>x</button>
134
+ }
135
+ `)
136
+
137
+ const defaults = extractSsrDefaults(metadata)
138
+ // props.className is undefined → `?? ''` → '' → 'a b c d tail' (the double
139
+ // space mirrors Hono's empty-className interpolation).
140
+ expect(defaults?.classes).toEqual({ value: 'a b c d tail' })
141
+ })
118
142
  })
@@ -0,0 +1,366 @@
1
+ /**
2
+ * Shared adapter helpers for the SolidJS props-object pattern (#checkbox).
3
+ *
4
+ * These two functions are the SINGLE SOURCE OF TRUTH for logic that the
5
+ * Go-template and Mojolicious template adapters previously carried as
6
+ * near-identical private copies. They live in `@barefootjs/jsx` (the IR
7
+ * layer) so the two adapters can share one implementation; they are
8
+ * deliberately NOT wired into the core IR-construction pipeline — only the
9
+ * Go and Mojo adapters call them, so every other IR consumer (Hono, the
10
+ * client codegen, etc.) is unaffected.
11
+ */
12
+
13
+ import ts from 'typescript'
14
+
15
+ import type { ComponentIR, IRMetadata, IRNode, IRElement, TypeInfo } from './types'
16
+ import { isBooleanAttr } from './html-constants'
17
+
18
+ /**
19
+ * A `const x = useContext(SomeContext)` consumer in a component body. SSR
20
+ * template adapters have no JS runtime context stack, so a consumer's value is
21
+ * threaded in at the data-construction layer: the adapter exposes a field/stash
22
+ * var defaulted to the context default, which an enclosing `<Ctx.Provider
23
+ * value>` overwrites for descendant child slots.
24
+ */
25
+ export interface ContextConsumer {
26
+ /** The local const bound to the `useContext` call (e.g. `theme`). */
27
+ localName: string
28
+ /** The `createContext` identifier read (e.g. `ThemeContext`). */
29
+ contextName: string
30
+ /**
31
+ * The `createContext(<default>)` argument as a JS literal, or `null` when
32
+ * absent / not a literal (the consumer then defaults to the empty value).
33
+ */
34
+ defaultValue: string | number | boolean | null
35
+ }
36
+
37
+ /**
38
+ * Collect every `const x = useContext(Ctx)` consumer in a component, resolving
39
+ * each `Ctx` to its `createContext(<default>)` default via the component's
40
+ * module-scope `createContext` constants. Returns `[]` when the component
41
+ * consumes no context. Single source of truth for the SSR-context adapters.
42
+ */
43
+ export function collectContextConsumers(metadata: IRMetadata): ContextConsumer[] {
44
+ const constants = metadata.localConstants ?? []
45
+ // Map each createContext const name → its default-arg literal value.
46
+ const contextDefaults = new Map<string, string | number | boolean | null>()
47
+ for (const c of constants) {
48
+ if (c.systemConstructKind !== 'createContext' || c.value === undefined) continue
49
+ contextDefaults.set(c.name, parseCreateContextDefault(c.value))
50
+ }
51
+ if (contextDefaults.size === 0) return []
52
+
53
+ const consumers: ContextConsumer[] = []
54
+ for (const c of constants) {
55
+ if (c.value === undefined) continue
56
+ const ctxName = parseUseContextArg(c.value)
57
+ if (ctxName === null || !contextDefaults.has(ctxName)) continue
58
+ consumers.push({
59
+ localName: c.name,
60
+ contextName: ctxName,
61
+ defaultValue: contextDefaults.get(ctxName) ?? null,
62
+ })
63
+ }
64
+ return consumers
65
+ }
66
+
67
+ /** Parse `useContext(Ident)` → `Ident`, else `null`. */
68
+ function parseUseContextArg(source: string): string | null {
69
+ const expr = parseSingleExpression(source)
70
+ if (!expr || !ts.isCallExpression(expr)) return null
71
+ if (!ts.isIdentifier(expr.expression) || expr.expression.text !== 'useContext') return null
72
+ if (expr.arguments.length !== 1) return null
73
+ const arg = expr.arguments[0]
74
+ return ts.isIdentifier(arg) ? arg.text : null
75
+ }
76
+
77
+ /** Parse `createContext(<literal>)` → the default value, else `null`. */
78
+ function parseCreateContextDefault(source: string): string | number | boolean | null {
79
+ const expr = parseSingleExpression(source)
80
+ if (!expr || !ts.isCallExpression(expr)) return null
81
+ if (expr.arguments.length === 0) return null
82
+ const arg = expr.arguments[0]
83
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) return arg.text
84
+ if (ts.isNumericLiteral(arg)) return Number(arg.text)
85
+ if (arg.kind === ts.SyntaxKind.TrueKeyword) return true
86
+ if (arg.kind === ts.SyntaxKind.FalseKeyword) return false
87
+ return null
88
+ }
89
+
90
+ function parseSingleExpression(source: string): ts.Expression | null {
91
+ const sf = ts.createSourceFile('__ctx.ts', `(${source})`, ts.ScriptTarget.Latest, false)
92
+ const stmt = sf.statements[0]
93
+ if (!stmt || !ts.isExpressionStatement(stmt)) return null
94
+ let e: ts.Expression = stmt.expression
95
+ while (ts.isParenthesizedExpression(e)) e = e.expression
96
+ return e
97
+ }
98
+
99
+ /**
100
+ * (#checkbox) Enumerate inherited-attribute accesses for the SolidJS
101
+ * props-object pattern.
102
+ *
103
+ * `function Checkbox(props: CheckboxProps)` only lists `CheckboxProps`'s own
104
+ * members in `propsParams`; the inherited `ButtonHTMLAttributes` members the
105
+ * component actually reads (`props.className` in the classes memo, `props.id`,
106
+ * `props.disabled` on the root element) are never enumerated, so the generated
107
+ * Input/Props structs (Go) or stash vars (Mojo) have no field to bind a
108
+ * caller's `className: ''` / `id` / `disabled` to. This scans the component's
109
+ * expressions for `props.<name>` accesses (where `props` is the resolved
110
+ * `propsObjectName`) and appends any not-already-a-param as a synthetic prop
111
+ * param, with a type inferred from how the access is used:
112
+ * - a boolean attribute (`disabled={props.disabled ?? false}`) → `boolean`;
113
+ * - a pure bare-reference attribute (`id={props.id}`) → `unknown`
114
+ * (nillable, so the attribute is omitted when unset — Hono parity);
115
+ * - otherwise (`className`, read in a string memo) → `string`.
116
+ *
117
+ * Scans memos, signals, init statements, effects, and template attribute
118
+ * expressions. (The Mojo adapter previously omitted `effects` from its scan;
119
+ * unifying on the Go behaviour of also scanning `effects` is intentional — the
120
+ * function only ever ADDS a param, and it changes no fixture output.)
121
+ *
122
+ * Idempotent: re-running (e.g. once in `generate`, again in `generateTypes` on
123
+ * a round-tripped IR) is a no-op once the params are present. Mutates
124
+ * `ir.metadata.propsParams` in place so every downstream emitter sees one
125
+ * consistent param list.
126
+ *
127
+ * The single source of truth for BOTH the Go and Mojo adapters — change here,
128
+ * not in an adapter copy.
129
+ */
130
+ export function augmentInheritedPropAccesses(ir: ComponentIR): void {
131
+ const propsObj = ir.metadata.propsObjectName
132
+ if (!propsObj) return // only the props-object pattern is affected
133
+
134
+ const existing = new Set(ir.metadata.propsParams.map(p => p.name))
135
+
136
+ // Collect bare-reference attribute exprs (`attr={props.id}`) and boolean
137
+ // attributes from the template so we can classify accessed props by use.
138
+ const bareRefProps = new Set<string>()
139
+ const booleanAttrProps = new Set<string>()
140
+ const accessed = new Set<string>()
141
+ const accessRe = new RegExp(`(?:^|[^\\w$.])${propsObj}\\.([A-Za-z_$][\\w$]*)`, 'g')
142
+ const scan = (s: string | undefined): void => {
143
+ if (!s) return
144
+ for (const m of s.matchAll(accessRe)) accessed.add(m[1])
145
+ }
146
+
147
+ // Memos, signals, init statements, effects: any `props.X` read.
148
+ for (const memo of ir.metadata.memos) scan(memo.computation)
149
+ for (const signal of ir.metadata.signals) scan(signal.initialValue)
150
+ for (const stmt of ir.metadata.initStatements ?? []) scan(stmt.body)
151
+ for (const eff of ir.metadata.effects ?? []) scan((eff as { body?: string }).body)
152
+
153
+ // Function-scope plain-const initializers (the Switch pattern): a
154
+ // `const trackClasses = \`… ${props.className ?? ''}\`` holds the only
155
+ // `props.X` read. Skip module consts — they can't reference function-scoped
156
+ // `props`. Reads land in string context, so the default `string` type holds.
157
+ for (const c of ir.metadata.localConstants ?? []) {
158
+ if (c.isModule) continue
159
+ scan(c.value)
160
+ }
161
+
162
+ // Template attribute exprs — also note bare-ref / boolean usage.
163
+ const walk = (node: IRNode | undefined): void => {
164
+ if (!node) return
165
+ const el = node as unknown as IRElement
166
+ for (const attr of el.attrs ?? []) {
167
+ const v = attr.value as { kind?: string; expr?: string; presenceOrUndefined?: boolean }
168
+ if (v?.kind === 'expression' && typeof v.expr === 'string') {
169
+ scan(v.expr)
170
+ const expr = v.expr.trim()
171
+ const prefix = `${propsObj}.`
172
+ // A boolean HTML attribute (`disabled={props.disabled ?? false}`)
173
+ // marks the referenced prop as boolean even when wrapped in a
174
+ // `?? false` default — extract the prop name from the expr.
175
+ if (isBooleanAttr(attr.name) || v.presenceOrUndefined) {
176
+ const m = expr.match(new RegExp(`^${propsObj}\\.([A-Za-z_$][\\w$]*)`))
177
+ if (m) booleanAttrProps.add(m[1])
178
+ } else if (expr.startsWith(prefix)) {
179
+ const rest = expr.slice(prefix.length)
180
+ // A pure bare-reference attribute (`id={props.id}`) → nillable.
181
+ if (/^[A-Za-z_$][\w$]*$/.test(rest)) bareRefProps.add(rest)
182
+ }
183
+ }
184
+ }
185
+ for (const child of (el.children ?? []) as unknown[]) {
186
+ const c = child as { element?: IRNode }
187
+ walk((c.element ?? child) as IRNode)
188
+ }
189
+ }
190
+ walk(ir.root)
191
+
192
+ for (const name of accessed) {
193
+ if (existing.has(name)) continue
194
+ let raw: string
195
+ if (booleanAttrProps.has(name)) raw = 'boolean'
196
+ else if (bareRefProps.has(name)) raw = 'unknown' // → interface{} (nillable, omittable)
197
+ else raw = 'string' // read in a memo / string context (e.g. className)
198
+ const type: TypeInfo =
199
+ raw === 'boolean'
200
+ ? { kind: 'primitive', raw: 'boolean', primitive: 'boolean' }
201
+ : raw === 'string'
202
+ ? { kind: 'primitive', raw: 'string', primitive: 'string' }
203
+ : { kind: 'unknown', raw: 'unknown' }
204
+ ir.metadata.propsParams.push({ name, type, optional: true })
205
+ existing.add(name)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Statically evaluate `[<string literals>].join(<sep?>)` (e.g. a module-scope
211
+ * `const stateClasses = ['…', …].join(' ')`) to its joined string, so SSR
212
+ * adapters inline the flattened literal byte-for-byte like the Hono reference
213
+ * instead of referencing a binding that doesn't exist server-side. Default
214
+ * separator `,` matches JS `Array.prototype.join`. Returns `null` for any
215
+ * other shape (non-`.join` call, non-array receiver, non-string-literal element
216
+ * or separator). Shared by the Mojo + Xslate adapters; Go keeps a private copy.
217
+ */
218
+ export function evalStringArrayJoin(source: string): string | null {
219
+ const sf = ts.createSourceFile(
220
+ '__join.ts', `const __x = (${source});`, ts.ScriptTarget.Latest, /*setParentNodes*/ false,
221
+ )
222
+ const stmt = sf.statements[0]
223
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
224
+ let node = stmt.declarationList.declarations[0]?.initializer
225
+ while (node && ts.isParenthesizedExpression(node)) node = node.expression
226
+ if (!node || !ts.isCallExpression(node)) return null
227
+ const callee = node.expression
228
+ if (!ts.isPropertyAccessExpression(callee)) return null
229
+ if (callee.name.text !== 'join') return null
230
+ let recv: ts.Expression = callee.expression
231
+ while (ts.isParenthesizedExpression(recv)) recv = recv.expression
232
+ if (!ts.isArrayLiteralExpression(recv)) return null
233
+ const parts: string[] = []
234
+ for (const el of recv.elements) {
235
+ if (ts.isStringLiteral(el) || ts.isNoSubstitutionTemplateLiteral(el)) {
236
+ parts.push(el.text)
237
+ } else {
238
+ return null
239
+ }
240
+ }
241
+ let sep = ','
242
+ if (node.arguments.length >= 1) {
243
+ const arg = node.arguments[0]
244
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) sep = arg.text
245
+ else return null
246
+ }
247
+ return parts.join(sep)
248
+ }
249
+
250
+ /** A minimal `{ name }` shape — both adapters pass their own param/const lists. */
251
+ interface NamedConst {
252
+ name: string
253
+ value?: string
254
+ isModule?: boolean
255
+ }
256
+
257
+ /** A parsed `Record<staticKeys, scalar>[propKey]` map entry. */
258
+ export interface RecordIndexEntry {
259
+ key: string
260
+ value: { kind: 'number' | 'string'; text: string }
261
+ }
262
+
263
+ /** The structured result of a `Record<staticKeys, scalar>[propKey]` access. */
264
+ export interface RecordIndexAccess {
265
+ /** The bare prop identifier used as the index key (`size` in `sizeMap[size]`). */
266
+ indexPropName: string
267
+ /** The map's entries in source order — each a static key + scalar literal value. */
268
+ entries: RecordIndexEntry[]
269
+ /**
270
+ * When the index key is a local const with a `props.X ?? '<lit>'` default
271
+ * (the Toggle `classes` memo's `const variant = props.variant ?? 'default'`),
272
+ * the `<lit>` fallback key — so a caller can render the default entry's value
273
+ * when the prop is unset. Absent when the key is a bare prop with no default.
274
+ */
275
+ defaultKey?: string
276
+ }
277
+
278
+ /**
279
+ * Structural parse of a spread-object VALUE of the form `IDENT[KEY]` where:
280
+ * - `IDENT` resolves via `localConstants` to a MODULE-scope (`isModule`)
281
+ * object literal whose every property has a static (string-literal or
282
+ * identifier) key and a scalar (number or string) literal value
283
+ * (a `Record<staticKeys, scalar>` map like `sizeMap`), AND
284
+ * - `KEY` is a bare identifier that is a prop (`propsParams`).
285
+ *
286
+ * Returns the structured `{ indexPropName, entries }` when convertible, else
287
+ * `null` for any unsupported shape (non-element-access, non-identifier
288
+ * object/index, non-prop index, non-module const, non-object-literal const,
289
+ * computed/spread/dynamic key, or non-scalar value) so the caller falls back
290
+ * to its normal lowering / BF101.
291
+ *
292
+ * The single source of truth for BOTH adapters' `recordIndexAccessTo*` emitters
293
+ * — only the final language-specific emit differs (Go inline `map[string]any{…}
294
+ * [fmt.Sprint(in.Field)]`; Mojo `{ … }->{$key}`). (#checkbox / icon `sizeMap[size]`.)
295
+ */
296
+ export function parseRecordIndexAccess(
297
+ val: ts.Expression,
298
+ localConstants: readonly NamedConst[],
299
+ propsParams: ReadonlyArray<{ name: string }>,
300
+ /**
301
+ * Resolves an index key that isn't a bare prop (a memo-local const like
302
+ * `const variant = props.variant ?? 'default'`) to its underlying prop + an
303
+ * optional default-key literal; returns `null` to reject. Keeps the parse
304
+ * generic — the caller owns the block-scoped binding map.
305
+ */
306
+ resolveKey?: (name: string) => { propName: string; defaultLiteral?: string } | null,
307
+ ): RecordIndexAccess | null {
308
+ if (!ts.isElementAccessExpression(val)) return null
309
+ const obj = val.expression
310
+ const arg = val.argumentExpression
311
+ if (!ts.isIdentifier(obj) || !ts.isIdentifier(arg)) return null
312
+ // KEY resolution. A caller-supplied local key wins first — the Toggle memo's
313
+ // `const variant = props.variant ?? 'default'` shadows the same-named
314
+ // `variant` prop, and only the local binding carries the `'default'` fallback.
315
+ // Otherwise the key must be a bare prop (`sizeMap[size]`).
316
+ let indexPropName: string
317
+ let defaultKey: string | undefined
318
+ const resolved = resolveKey?.(arg.text)
319
+ if (resolved) {
320
+ indexPropName = resolved.propName
321
+ defaultKey = resolved.defaultLiteral
322
+ } else if (propsParams.some(p => p.name === arg.text)) {
323
+ indexPropName = arg.text
324
+ } else {
325
+ return null
326
+ }
327
+ // IDENT must resolve to a module-scope object-literal const.
328
+ const constInfo = localConstants.find(c => c.name === obj.text && c.isModule)
329
+ if (constInfo?.value === undefined) return null
330
+
331
+ const sf = ts.createSourceFile(
332
+ '__rec.ts', `(${constInfo.value})`, ts.ScriptTarget.Latest, /* setParentNodes */ true,
333
+ )
334
+ if (sf.statements.length !== 1) return null
335
+ const stmt = sf.statements[0]
336
+ if (!ts.isExpressionStatement(stmt)) return null
337
+ let parsed: ts.Expression = stmt.expression
338
+ while (ts.isParenthesizedExpression(parsed)) parsed = parsed.expression
339
+ if (!ts.isObjectLiteralExpression(parsed)) return null
340
+
341
+ const entries: RecordIndexEntry[] = []
342
+ for (const prop of parsed.properties) {
343
+ if (!ts.isPropertyAssignment(prop)) return null
344
+ let key: string
345
+ if (ts.isIdentifier(prop.name)) {
346
+ key = prop.name.text
347
+ } else if (
348
+ ts.isStringLiteral(prop.name) ||
349
+ ts.isNoSubstitutionTemplateLiteral(prop.name)
350
+ ) {
351
+ key = prop.name.text
352
+ } else {
353
+ return null
354
+ }
355
+ let v: ts.Expression = prop.initializer
356
+ while (ts.isParenthesizedExpression(v)) v = v.expression
357
+ if (ts.isNumericLiteral(v)) {
358
+ entries.push({ key, value: { kind: 'number', text: v.text } })
359
+ } else if (ts.isStringLiteral(v) || ts.isNoSubstitutionTemplateLiteral(v)) {
360
+ entries.push({ key, value: { kind: 'string', text: v.text } })
361
+ } else {
362
+ return null
363
+ }
364
+ }
365
+ return { indexPropName, entries, defaultKey }
366
+ }
package/src/compiler.ts CHANGED
@@ -31,6 +31,70 @@ export interface CompileOptionsWithAdapter extends CompileOptions {
31
31
  adapter: TemplateAdapter
32
32
  }
33
33
 
34
+ /**
35
+ * Merge the import lines of a multi-component template file into a single,
36
+ * conflict-free block.
37
+ *
38
+ * Named value/type imports from the same source are folded into their first
39
+ * occurrence (preserving line order and first-seen symbol order); every
40
+ * other import form (side-effect, default, namespace) is kept in place and
41
+ * de-duplicated by exact line. This ensures a symbol is never imported
42
+ * twice across sibling components — a redeclaration that Bun tolerates but
43
+ * stricter ESM parsers (the Deno runtime that renders SSR templates) reject.
44
+ *
45
+ * For a single-component file the output is identical to the input order;
46
+ * only repeated sibling imports collapse.
47
+ *
48
+ * Matching is whitespace-insensitive (`import {a,b} from 'x'` and
49
+ * `import { a , b } from "x"` fold the same): the merge must not silently
50
+ * depend on the emitter's exact spacing. A named import that failed to match
51
+ * would fall through to the by-line branch below and re-introduce the very
52
+ * duplicate-binding SyntaxError this function exists to prevent, so the
53
+ * patterns tolerate any spacing the generated lines might carry.
54
+ */
55
+ export function mergeTemplateImports(lines: string[]): string {
56
+ const result: string[] = []
57
+ const valueIdx = new Map<string, number>()
58
+ const valueNames = new Map<string, Set<string>>()
59
+ const typeIdx = new Map<string, number>()
60
+ const typeNames = new Map<string, Set<string>>()
61
+ const seenOther = new Set<string>()
62
+
63
+ const fold = (
64
+ src: string,
65
+ rawNames: string,
66
+ idx: Map<string, number>,
67
+ names: Map<string, Set<string>>,
68
+ render: (src: string, names: Set<string>) => string,
69
+ ) => {
70
+ if (!idx.has(src)) {
71
+ idx.set(src, result.length)
72
+ names.set(src, new Set())
73
+ result.push('')
74
+ }
75
+ const set = names.get(src)!
76
+ for (const n of rawNames.split(',').map(s => s.trim()).filter(Boolean)) set.add(n)
77
+ result[idx.get(src)!] = render(src, set)
78
+ }
79
+
80
+ for (const raw of lines) {
81
+ const line = raw.trim()
82
+ if (!line) continue
83
+ const typeMatch = line.match(/^import\s+type\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
84
+ const valueMatch = line.match(/^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]\s*;?$/)
85
+ if (valueMatch) {
86
+ fold(valueMatch[2], valueMatch[1], valueIdx, valueNames, (s, n) => `import { ${[...n].join(', ')} } from '${s}'`)
87
+ } else if (typeMatch) {
88
+ fold(typeMatch[2], typeMatch[1], typeIdx, typeNames, (s, n) => `import type { ${[...n].join(', ')} } from '${s}'`)
89
+ } else if (!seenOther.has(line)) {
90
+ seenOther.add(line)
91
+ result.push(line)
92
+ }
93
+ }
94
+
95
+ return result.filter(Boolean).join('\n')
96
+ }
97
+
34
98
  // =============================================================================
35
99
  // Multiple Component Compilation
36
100
  // =============================================================================
@@ -264,20 +328,17 @@ function compileMultipleComponents(
264
328
  return { files, errors }
265
329
  }
266
330
 
267
- // Merge imports from all components, deduplicating by line
268
- const seenImportLines = new Set<string>()
269
- const uniqueImports: string[] = []
270
- for (const output of allOutputs) {
271
- if (output.imports) {
272
- for (const line of output.imports.split('\n')) {
273
- if (line.trim() && !seenImportLines.has(line)) {
274
- seenImportLines.add(line)
275
- uniqueImports.push(line)
276
- }
277
- }
278
- }
279
- }
280
- const mergedImports = uniqueImports.join('\n')
331
+ // Merge imports from all components. Named imports from the same source
332
+ // are combined into their first occurrence rather than deduplicated by
333
+ // exact line: in a multi-component file each component emits its own
334
+ // `import { } from '@barefootjs/hono/utils'` listing only the symbols
335
+ // it uses, so plain line-dedup leaves several statements that re-declare
336
+ // the same binding (e.g. `bfComment`). Bun tolerates the redeclaration,
337
+ // but stricter ESM parsers — including Deno, used to render the SSR
338
+ // template — reject it as a SyntaxError.
339
+ const mergedImports = mergeTemplateImports(
340
+ allOutputs.flatMap(o => (o.imports ? o.imports.split('\n') : [])),
341
+ )
281
342
 
282
343
  // Combine unique type definitions
283
344
  const seenTypes = new Set<string>()
package/src/index.ts CHANGED
@@ -280,6 +280,10 @@ export type { WrapReason } from './ir-to-client-js/reactivity'
280
280
  // HTML constants
281
281
  export { BOOLEAN_ATTRS, isBooleanAttr } from './html-constants'
282
282
 
283
+ // Shared props-object-pattern helpers for the Go / Mojo template adapters
284
+ export { augmentInheritedPropAccesses, parseRecordIndexAccess, evalStringArrayJoin, collectContextConsumers } from './augment-inherited-props'
285
+ export type { RecordIndexAccess, RecordIndexEntry, ContextConsumer } from './augment-inherited-props'
286
+
283
287
  // HTML element attribute types
284
288
  export type {
285
289
  // Event types