@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.
- package/dist/augment-inherited-props.d.ts +141 -0
- package/dist/augment-inherited-props.d.ts.map +1 -0
- package/dist/compiler.d.ts +22 -0
- package/dist/compiler.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +329 -17
- package/dist/ssr-defaults.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/augment-inherited-props.test.ts +97 -0
- package/src/__tests__/merge-template-imports.test.ts +68 -0
- package/src/__tests__/ssr-defaults.test.ts +24 -0
- package/src/augment-inherited-props.ts +366 -0
- package/src/compiler.ts +75 -14
- package/src/index.ts +4 -0
- package/src/ssr-defaults.ts +80 -9
|
@@ -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
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|