@barefootjs/erb 0.17.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 (68) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +24 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/boolean-result.d.ts +66 -0
  4. package/dist/adapter/boolean-result.d.ts.map +1 -0
  5. package/dist/adapter/emit-context.d.ts +107 -0
  6. package/dist/adapter/emit-context.d.ts.map +1 -0
  7. package/dist/adapter/erb-adapter.d.ts +374 -0
  8. package/dist/adapter/erb-adapter.d.ts.map +1 -0
  9. package/dist/adapter/expr/array-method.d.ts +87 -0
  10. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  11. package/dist/adapter/expr/emitters.d.ts +102 -0
  12. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  13. package/dist/adapter/expr/operand.d.ts +34 -0
  14. package/dist/adapter/expr/operand.d.ts.map +1 -0
  15. package/dist/adapter/index.d.ts +6 -0
  16. package/dist/adapter/index.d.ts.map +1 -0
  17. package/dist/adapter/index.js +189154 -0
  18. package/dist/adapter/lib/constants.d.ts +25 -0
  19. package/dist/adapter/lib/constants.d.ts.map +1 -0
  20. package/dist/adapter/lib/ir-scope.d.ts +27 -0
  21. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  22. package/dist/adapter/lib/ruby-naming.d.ts +65 -0
  23. package/dist/adapter/lib/ruby-naming.d.ts.map +1 -0
  24. package/dist/adapter/lib/types.d.ts +28 -0
  25. package/dist/adapter/lib/types.d.ts.map +1 -0
  26. package/dist/adapter/memo/seed.d.ts +35 -0
  27. package/dist/adapter/memo/seed.d.ts.map +1 -0
  28. package/dist/adapter/props/prop-classes.d.ts +50 -0
  29. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  30. package/dist/adapter/spread/spread-codegen.d.ts +63 -0
  31. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  32. package/dist/adapter/value/parsed-literal.d.ts +21 -0
  33. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  34. package/dist/build.d.ts +28 -0
  35. package/dist/build.d.ts.map +1 -0
  36. package/dist/build.js +189174 -0
  37. package/dist/conformance-pins.d.ts +13 -0
  38. package/dist/conformance-pins.d.ts.map +1 -0
  39. package/dist/index.d.ts +9 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +189172 -0
  42. package/lib/barefoot_js/backend/erb.rb +123 -0
  43. package/lib/barefoot_js/dev_reload.rb +159 -0
  44. package/lib/barefoot_js/evaluator.rb +714 -0
  45. package/lib/barefoot_js/search_params.rb +63 -0
  46. package/lib/barefoot_js.rb +1155 -0
  47. package/package.json +67 -0
  48. package/src/__tests__/erb-adapter.test.ts +298 -0
  49. package/src/adapter/analysis/component-tree.ts +122 -0
  50. package/src/adapter/boolean-result.ts +157 -0
  51. package/src/adapter/emit-context.ts +119 -0
  52. package/src/adapter/erb-adapter.ts +1768 -0
  53. package/src/adapter/expr/array-method.ts +424 -0
  54. package/src/adapter/expr/emitters.ts +629 -0
  55. package/src/adapter/expr/operand.ts +55 -0
  56. package/src/adapter/index.ts +6 -0
  57. package/src/adapter/lib/constants.ts +37 -0
  58. package/src/adapter/lib/ir-scope.ts +51 -0
  59. package/src/adapter/lib/ruby-naming.ts +127 -0
  60. package/src/adapter/lib/types.ts +31 -0
  61. package/src/adapter/memo/seed.ts +75 -0
  62. package/src/adapter/props/prop-classes.ts +89 -0
  63. package/src/adapter/spread/spread-codegen.ts +176 -0
  64. package/src/adapter/value/parsed-literal.ts +29 -0
  65. package/src/build.ts +37 -0
  66. package/src/conformance-pins.ts +100 -0
  67. package/src/index.ts +9 -0
  68. package/src/test-render.ts +668 -0
@@ -0,0 +1,6 @@
1
+ /**
2
+ * ERB Template Adapter Exports
3
+ */
4
+
5
+ export { ErbAdapter, erbAdapter } from './erb-adapter.ts'
6
+ export type { ErbAdapterOptions } from './erb-adapter.ts'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Compile-time constant tables for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `lib/constants.ts`.
5
+ */
6
+
7
+ import type { PrimitiveSpec } from './types.ts'
8
+
9
+ /**
10
+ * Single source of truth for the ERB adapter's template-primitive
11
+ * surface. Each entry pairs the expected arity with the emit function.
12
+ * Adding / removing a primitive is a one-line change.
13
+ *
14
+ * The emit fn returns a Ruby expression (no surrounding `<%= %>`)
15
+ * suitable for embedding inside the ERB template action —
16
+ * `bf.json(val)`, `bf.floor(val)`, etc. Args arrive already
17
+ * Ruby-rendered via `convertExpressionToRuby` recursion, so a caller
18
+ * passing `props.config` reaches the emit fn as `v[:config]`.
19
+ */
20
+ export const ERB_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
21
+ 'JSON.stringify': { arity: 1, emit: (args) => `bf.json(${args[0]})` },
22
+ 'String': { arity: 1, emit: (args) => `bf.string(${args[0]})` },
23
+ 'Number': { arity: 1, emit: (args) => `bf.number(${args[0]})` },
24
+ 'Math.floor': { arity: 1, emit: (args) => `bf.floor(${args[0]})` },
25
+ 'Math.ceil': { arity: 1, emit: (args) => `bf.ceil(${args[0]})` },
26
+ 'Math.round': { arity: 1, emit: (args) => `bf.round(${args[0]})` },
27
+ }
28
+
29
+ /**
30
+ * Module-scope `templatePrimitives` map derived once from the spec
31
+ * record. Per-instance derivation would re-build the same Map on
32
+ * every `new ErbAdapter()` call.
33
+ */
34
+ export const ERB_PRIMITIVE_EMIT_MAP: Record<string, (args: string[]) => string> =
35
+ Object.fromEntries(
36
+ Object.entries(ERB_TEMPLATE_PRIMITIVES).map(([k, v]) => [k, v.emit])
37
+ )
@@ -0,0 +1,51 @@
1
+ /**
2
+ * IR traversal helpers for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `lib/ir-scope.ts` (issue #2018
5
+ * track D lineage). Pure functions over the IR tree — no adapter instance
6
+ * state.
7
+ */
8
+
9
+ import type { IRNode, IRProp, IRIfStatement, IRFragment } from '@barefootjs/jsx'
10
+
11
+ /**
12
+ * Find the `children` prop's `jsx-children` payload (#1326). Narrowed
13
+ * via the AttrValue `kind` discriminator so adapter code stays type-
14
+ * safe if the IR shape evolves — adding a new AttrValue variant or
15
+ * renaming `children` to `jsxChildren` becomes a TS compile error
16
+ * here instead of silently dropping the children at runtime.
17
+ */
18
+ export function resolveJsxChildrenProp(props: readonly IRProp[]): IRNode[] {
19
+ const prop = props.find(p => p.name === 'children')
20
+ if (!prop) return []
21
+ if (prop.value.kind !== 'jsx-children') return []
22
+ return prop.value.children
23
+ }
24
+
25
+ /**
26
+ * Collect the component's root scope element node(s) — the elements that
27
+ * become the rendered root and so carry `data-key` for a keyed loop item. A
28
+ * plain element root is itself; an `if-statement` (early-return) root
29
+ * contributes the top element of each branch (`consequent` + the `alternate`
30
+ * chain), since exactly one branch renders at runtime. Non-element branch
31
+ * tops (fragments / nested shapes) are walked one level so an
32
+ * `if (…) return <A/>` still resolves to `<A>`. (#1297)
33
+ */
34
+ export function collectRootScopeNodes(node: IRNode): Set<IRNode> {
35
+ const out = new Set<IRNode>()
36
+ const visit = (n: IRNode | null): void => {
37
+ if (!n) return
38
+ if (n.type === 'element') { out.add(n); return }
39
+ if (n.type === 'if-statement') {
40
+ const s = n as IRIfStatement
41
+ visit(s.consequent)
42
+ visit(s.alternate)
43
+ return
44
+ }
45
+ if (n.type === 'fragment') {
46
+ for (const c of (n as IRFragment).children) visit(c)
47
+ }
48
+ }
49
+ visit(node)
50
+ return out
51
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Ruby identifier / literal / hash-key conventions for the ERB adapter.
3
+ *
4
+ * Pure helpers, ported from (and extending) the Mojolicious adapter's
5
+ * `lib/perl-naming.ts` for Ruby's syntax. None read adapter instance
6
+ * state, so they live at module scope as the single source of truth for
7
+ * Ruby-identifier quoting, literal escaping, and marker-id encoding.
8
+ *
9
+ * ## Variable model
10
+ *
11
+ * Templates receive exactly two locals: `bf` (runtime) and `v` (vars Hash,
12
+ * symbol keys). Every prop / signal / memo / module-constant reference
13
+ * lowers to `v[:name]` — never a bare Ruby local — which sidesteps Ruby
14
+ * identifier-validity and reserved-word issues for that whole class of
15
+ * names (a prop literally named `class` or `Foo` is a non-issue: it's a
16
+ * *symbol* key, not a variable reference).
17
+ *
18
+ * The ONE place a bare Ruby local is still needed is a loop/block
19
+ * parameter (`todos().map(todo => ...)` → `|todo|`) — `rubyLocal` is the
20
+ * single naming rule for that case, matching the ERB adapter's binding
21
+ * architecture doc.
22
+ */
23
+
24
+ /**
25
+ * Ruby's reserved words — cannot be used as a local variable / block
26
+ * parameter name (the parser reads them as keywords, not identifiers).
27
+ * `self`, `nil`, `true`, `false` are technically pseudo-variables/keywords
28
+ * with special meaning even in expression position, so they're included
29
+ * too — using one of these as a block param name is either a SyntaxError
30
+ * or silently shadows a builtin literal, either of which we want to avoid
31
+ * for the same "load-bearing loop var" reason the Perl side avoids
32
+ * `my $if = ...`.
33
+ */
34
+ const RUBY_KEYWORDS: ReadonlySet<string> = new Set([
35
+ '__ENCODING__', '__LINE__', '__FILE__',
36
+ 'BEGIN', 'END',
37
+ 'alias', 'and', 'begin', 'break', 'case', 'class', 'def', 'defined?',
38
+ 'do', 'else', 'elsif', 'end', 'ensure', 'false', 'for', 'if', 'in',
39
+ 'module', 'next', 'nil', 'not', 'or', 'redo', 'rescue', 'retry',
40
+ 'return', 'self', 'super', 'then', 'true', 'undef', 'unless', 'until',
41
+ 'when', 'while', 'yield',
42
+ // Not Ruby keywords, but the two RESERVED locals every compiled template
43
+ // receives (the binding architecture contract: `bf` the runtime context,
44
+ // `v` the vars Hash). A loop/block param named `v` or `bf` (e.g.
45
+ // `items.values().map(v => ...)`, whose synthesized `.entries()` value
46
+ // binding is literally `v`) would otherwise shadow the vars Hash inside
47
+ // the loop body, silently breaking every subsequent `v[:name]` read for
48
+ // the rest of that scope. Route through the same collision-suffix path
49
+ // as a real keyword.
50
+ 'bf', 'v',
51
+ ])
52
+
53
+ /** A syntactically valid Ruby local-variable / block-parameter identifier
54
+ * (must start lowercase-letter-or-underscore; a leading uppercase letter
55
+ * parses as a constant reference, not a variable). */
56
+ const VALID_RUBY_LOCAL = /^[a-z_][A-Za-z0-9_]*$/
57
+
58
+ /**
59
+ * Map a JS loop/block parameter name (`todo`, `index`, `class`, `Item`) to
60
+ * a safe bare Ruby local. Appends a trailing `_` when the name collides
61
+ * with a Ruby keyword; when the name isn't even a syntactically valid
62
+ * local (leading uppercase — parses as a constant — or another invalid
63
+ * leading character), prefixes `_` instead so the result still starts
64
+ * lowercase/underscore. Every loop-var / block-param emission site in the
65
+ * adapter goes through this one helper — no inline mangling.
66
+ */
67
+ export function rubyLocal(name: string): string {
68
+ if (RUBY_KEYWORDS.has(name)) return `${name}_`
69
+ if (VALID_RUBY_LOCAL.test(name)) return name
70
+ // Invalid leading character (most commonly a JS param starting with an
71
+ // uppercase letter, which Ruby would otherwise parse as a constant
72
+ // reference) — prefix rather than suffix so the fixed name still starts
73
+ // with a valid local-variable leading character.
74
+ return `_${name}`
75
+ }
76
+
77
+ /** Escape a string for a Ruby single-quoted literal: backslash first (so
78
+ * it doesn't double-escape the quote we add next), then the quote. */
79
+ export function escapeRubySingleQuoted(s: string): string {
80
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
81
+ }
82
+
83
+ /** Wrap a raw string value as a Ruby single-quoted literal. */
84
+ export function rubyStringLiteral(s: string): string {
85
+ return `'${escapeRubySingleQuoted(s)}'`
86
+ }
87
+
88
+ /** A syntactically valid bare Ruby hash-key / symbol identifier. */
89
+ const VALID_RUBY_SYMBOL = /^[A-Za-z_][A-Za-z0-9_]*[?!]?$/
90
+
91
+ /**
92
+ * Render `name` as a Ruby Hash literal SYMBOL key in `key: value` position.
93
+ * A JSX attribute / prop name like `data-slot` isn't a valid bare Ruby
94
+ * identifier — Ruby's `"quoted": value` symbol-key syntax accepts an
95
+ * arbitrary string, so a non-identifier name still renders as a valid
96
+ * symbol key (`"data-slot": value` → `{:"data-slot" => value}`).
97
+ * Identifier-safe names (`className`, `size`, `_bf_slot`) pass through
98
+ * unquoted (`size: value`) for readability.
99
+ */
100
+ export function rubySymbolKey(name: string): string {
101
+ return VALID_RUBY_SYMBOL.test(name) ? `${name}:` : `"${name.replace(/"/g, '\\"')}":`
102
+ }
103
+
104
+ /**
105
+ * Render `name` as a Ruby symbol LITERAL (`:name` / `:"data-slot"`) — used
106
+ * for a compile-time-known Hash key read (`item[:field]`), as opposed to
107
+ * `rubySymbolKey`'s `key: value` Hash-literal position.
108
+ */
109
+ export function rubySymbolLiteral(name: string): string {
110
+ return VALID_RUBY_SYMBOL.test(name) ? `:${name}` : `:"${name.replace(/"/g, '\\"')}"`
111
+ }
112
+
113
+ /**
114
+ * Encode an `IRLoop.markerId` into a Ruby-identifier-safe suffix for the
115
+ * `bf_iter_…` sort hoist local. Collision-free for marker ids that differ
116
+ * in any character — `-` and `_` map to distinct encodings (`_x2d` vs
117
+ * `__`) so `l-0` and `l_0` stay distinct.
118
+ *
119
+ * Today the IR only emits `l<digits>` so the encoding is mostly an
120
+ * identity, but pinning collision-freeness up front avoids a silent
121
+ * variable-shadow bug if a future marker generator widens the alphabet.
122
+ */
123
+ export function rubyIdentifierFromMarkerId(markerId: string): string {
124
+ return markerId.replace(/[^a-zA-Z0-9]/g, (ch) =>
125
+ ch === '_' ? '__' : `_x${ch.charCodeAt(0).toString(16)}`
126
+ )
127
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared type aliases for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `lib/types.ts`. Pure type
5
+ * declarations — no runtime behaviour — so the extracted emit modules and
6
+ * the main adapter share one definition rather than re-declaring the render
7
+ * context / options shape.
8
+ */
9
+
10
+ /** A template-primitive spec: expected call arity + the emit fn. */
11
+ export interface PrimitiveSpec {
12
+ arity: number
13
+ emit: (args: string[]) => string
14
+ }
15
+
16
+ /**
17
+ * ERB adapter's IRNode render context. The ERB lowering currently doesn't
18
+ * consume any render-position flags (`isRootOfClientComponent` is handled
19
+ * differently here than in Hono/Go), so the Ctx is empty. Kept as a named
20
+ * alias so future flags can extend it without changing the `IRNodeEmitter`
21
+ * interface.
22
+ */
23
+ export type ErbRenderCtx = Record<string, never>
24
+
25
+ export interface ErbAdapterOptions {
26
+ /** Base path for client JS files (default: '/static/components/') */
27
+ clientJsBasePath?: string
28
+
29
+ /** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
30
+ barefootJsPath?: string
31
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * In-template memo / context seeding for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `memo/seed.ts` (issue #2018 track D
5
+ * lineage). Free functions taking an `ErbMemoContext` (built by the
6
+ * adapter's `memoCtx` getter) so the cluster depends only on the recursive
7
+ * expression entry, not the whole adapter class. These emit `<% v[:x] =
8
+ * ...; %>` seed lines that let the template body's `v[:x]` resolve to a
9
+ * derived signal/memo value or an active context value at SSR time. Mirror
10
+ * of the Go adapter's `memo/*`, retargeted to the vars-Hash variable model.
11
+ */
12
+
13
+ import {
14
+ type ComponentIR,
15
+ type ContextConsumer,
16
+ collectContextConsumers,
17
+ computeSsrSeedPlan,
18
+ } from '@barefootjs/jsx'
19
+
20
+ import type { ErbMemoContext } from '../emit-context.ts'
21
+ import { rubyStringLiteral } from '../lib/ruby-naming.ts'
22
+
23
+ /** Ruby literal for a context-consumer's `createContext` default. */
24
+ export function contextDefaultRuby(c: ContextConsumer): string {
25
+ const d = c.defaultValue
26
+ if (d === null || d === undefined) return 'nil'
27
+ if (typeof d === 'string') return rubyStringLiteral(d)
28
+ if (typeof d === 'boolean') return d ? 'true' : 'false'
29
+ return String(d)
30
+ }
31
+
32
+ /**
33
+ * Emit one `<% v[:<local>] = bf.use_context(...) %>` seed line per context
34
+ * consumer so the template body's `v[:<local>]` resolves to the active
35
+ * provider value (or the `createContext` default).
36
+ */
37
+ export function generateContextConsumerSeed(ir: ComponentIR): string {
38
+ const consumers = collectContextConsumers(ir.metadata)
39
+ if (consumers.length === 0) return ''
40
+ return (
41
+ consumers
42
+ .map(
43
+ c =>
44
+ `<% v[:${c.localName}] = bf.use_context('${c.contextName}', ${contextDefaultRuby(c)}) %>`,
45
+ )
46
+ .join('\n') + '\n'
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Emit `<% v[:<name>] = <ruby> %>` seed lines for every `derived` step of
52
+ * the backend-neutral SSR seed plan — the scope/availability/ordering
53
+ * analysis lives in `computeSsrSeedPlan` (packages/jsx/src/ssr-seed-plan.ts);
54
+ * this only lowers each step's expression to Ruby and applies the two
55
+ * backend-specific emit guards: skip an empty lowering, and skip a lowering
56
+ * that references no `v[:var]` at all (a constant init/body — e.g. a
57
+ * `derived` step with empty `frees` — keeps the existing static ssr-defaults
58
+ * seed instead). `env-reader` and `opaque` steps emit nothing (the runtime
59
+ * supplies the reader, or the adapter's ssr-defaults path already covers
60
+ * it). (#1297, #2075)
61
+ */
62
+ export function generateDerivedMemoSeed(ctx: ErbMemoContext, ir: ComponentIR): string {
63
+ // Package G attached this to metadata at compile time; the `??` fallback
64
+ // only covers hand-built metadata in older tests that predate the attached
65
+ // plan — same shared function, so there's no divergence from the compiler.
66
+ const plan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata)
67
+ const lines: string[] = []
68
+ for (const step of plan.steps) {
69
+ if (step.kind !== 'derived') continue
70
+ const ruby = ctx.convertExpressionToRuby(step.expr, step.parsed)
71
+ if (ruby === '' || !/v\[:[A-Za-z_]\w*\]/.test(ruby)) continue
72
+ lines.push(`<% v[:${step.name}] = ${ruby} %>`)
73
+ }
74
+ return lines.length > 0 ? lines.join('\n') + '\n' : ''
75
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Prop classification for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `props/prop-classes.ts` (issue #2018
5
+ * track D lineage). Pure functions over `ir.metadata` that derive the
6
+ * per-compile prop/name sets the adapter consults during lowering. No
7
+ * adapter instance state.
8
+ */
9
+
10
+ import type { ComponentIR } from '@barefootjs/jsx'
11
+ import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
12
+
13
+ /**
14
+ * SSR-resolvable context-value names: props, signal getters, memos.
15
+ * A `<Ctx.Provider value>` member NOT in this set is a client-only function
16
+ * with no SSR value, lowered to `nil`.
17
+ */
18
+ export function collectProviderDataNames(ir: ComponentIR): Set<string> {
19
+ return new Set<string>([
20
+ ...ir.metadata.propsParams.map(p => p.name),
21
+ ...(ir.metadata.signals ?? []).map(s => s.getter),
22
+ ...(ir.metadata.memos ?? []).map(m => m.name),
23
+ ])
24
+ }
25
+
26
+ /**
27
+ * Props whose declared TS type is boolean — a bare binding of one
28
+ * (`data-active={props.isActive}`) must stringify as JS `String(boolean)`
29
+ * ("true"/"false"), matching the `bf.bool_str` helper's output.
30
+ */
31
+ export function collectBooleanTypedProps(ir: ComponentIR): Set<string> {
32
+ return new Set(
33
+ ir.metadata.propsParams
34
+ .filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
35
+ .map(prop => prop.name),
36
+ )
37
+ }
38
+
39
+ /**
40
+ * No-destructure-default props → `nil` when the caller omits them → guard
41
+ * their bare-reference attribute emission with a Ruby nil-check so the
42
+ * attribute drops instead of rendering `attr=""` (Hono-style nullish
43
+ * omission). A prop WITH a destructure default (`value = ''`) is never
44
+ * `nil` in the body and must stay unconditional, so it is excluded. Mirrors
45
+ * the Go adapter's nillable-field guard: there the witness is the resolved
46
+ * `interface{}` field type; here it is the absence of a default. Excludes
47
+ * concrete-primitive types (`string`/`number`/`boolean`) to match the Go
48
+ * adapter's scope, which guards only nillable fields and leaves concrete
49
+ * fields unconditional.
50
+ */
51
+ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
52
+ return new Set(
53
+ ir.metadata.propsParams
54
+ .filter(
55
+ p =>
56
+ p.defaultValue === undefined &&
57
+ !p.isRest &&
58
+ p.type?.kind !== 'primitive',
59
+ )
60
+ .map(p => p.name),
61
+ )
62
+ }
63
+
64
+ /**
65
+ * String-typed signals and props. A signal is string-typed when its inferred
66
+ * type is `string` (the analyzer infers this from a string-literal initial
67
+ * value) or, defensively, when its initial value is a bare string literal; a
68
+ * prop when its annotated type is `string`.
69
+ *
70
+ * Ruby's `==`/`!=` don't coerce operand types the way Perl's numeric `==`
71
+ * does, so this set does NOT drive equality-operator selection in the ERB
72
+ * adapter (unlike Mojo's `eq`/`ne` split). It still matters for **index
73
+ * access**: `obj[index]` lowers a string-typed `index` to a Hash lookup
74
+ * (`obj[index.to_sym]`, JSON-shaped Ruby hashes use symbol keys) and any
75
+ * other type to an Array lookup (`obj[index]`) — see
76
+ * `expr/operand.ts::isStringTypedOperand`.
77
+ */
78
+ export function collectStringValueNames(ir: ComponentIR): Set<string> {
79
+ const names = new Set<string>()
80
+ for (const s of ir.metadata.signals) {
81
+ if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
82
+ names.add(s.getter)
83
+ }
84
+ }
85
+ for (const p of ir.metadata.propsParams) {
86
+ if (isStringTypeInfo(p.type)) names.add(p.name)
87
+ }
88
+ return names
89
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Object-literal / conditional-spread → Ruby Hash lowering for the ERB
3
+ * template adapter.
4
+ *
5
+ * Ported from the Mojolicious adapter's `spread/spread-codegen.ts` (issue
6
+ * #2018 track D lineage). Free functions taking an `ErbSpreadContext` (built
7
+ * by the adapter's `spreadCtx` getter) so the cluster depends on the narrow
8
+ * seam — the recursive expression entry plus per-compile bookkeeping —
9
+ * rather than the whole adapter class. Mirror of the Go adapter's
10
+ * `spread/spread-codegen.ts`.
11
+ *
12
+ * The conditional-spread / object-literal entries read the IR-carried
13
+ * structured `ParsedExpr` tree instead of re-parsing the source with
14
+ * `ts.createSourceFile`. The condition and scalar values are threaded
15
+ * straight into `ctx.convertExpressionToRuby` as its `preParsed` argument
16
+ * (cf. go-template's `convertExpressionToGo(jsExpr, out?, preParsed?)`), so
17
+ * no stringify→re-parse round-trip occurs. The `ts.factory` rebuild in
18
+ * `recordIndexAccessToRuby` only reconstructs the `IDENT[KEY]` node the
19
+ * shared `parseRecordIndexAccess` parser accepts; no source-text re-parse.
20
+ * `stringifyParsedExpr` is retained solely for the BF101 diagnostic message
21
+ * (display purposes).
22
+ */
23
+
24
+ import ts from 'typescript'
25
+ import { parseRecordIndexAccess, stringifyParsedExpr } from '@barefootjs/jsx'
26
+ import type { ParsedExpr } from '@barefootjs/jsx'
27
+
28
+ import type { ErbSpreadContext } from '../emit-context.ts'
29
+ import { rubySymbolKey, escapeRubySingleQuoted } from '../lib/ruby-naming.ts'
30
+
31
+ /**
32
+ * Lower a `cond ? {…} : {…}` conditional-spread expression — carried as the
33
+ * IR's structured `ParsedExpr` tree — to a Ruby ternary over two Hashes, or
34
+ * null when it isn't that shape. `parseExpression` already strips redundant
35
+ * parentheses, so the conditional / object-literal shapes surface directly.
36
+ */
37
+ export function conditionalSpreadToRuby(
38
+ ctx: ErbSpreadContext,
39
+ expr: ParsedExpr | undefined,
40
+ ): string | null {
41
+ if (!expr || expr.kind !== 'conditional') return null
42
+ const whenTrue = expr.consequent
43
+ const whenFalse = expr.alternate
44
+ if (whenTrue.kind !== 'object-literal' || whenFalse.kind !== 'object-literal') {
45
+ return null
46
+ }
47
+ // Thread the condition's carried `ParsedExpr` tree straight through as
48
+ // `preParsed` — no stringify→re-parse round-trip; `convertExpressionToRuby`
49
+ // uses the tree directly and derives any diagnostic text from it.
50
+ const condRuby = ctx.convertExpressionToRuby('', expr.test)
51
+ const trueRuby = objectLiteralToRubyHash(ctx, whenTrue)
52
+ const falseRuby = objectLiteralToRubyHash(ctx, whenFalse)
53
+ if (trueRuby === null || falseRuby === null) return null
54
+ // JS ternary tests JS truthiness — wrap, mirroring the top-level emitter's
55
+ // `conditional()`.
56
+ return `(bf.truthy?(${condRuby}) ? ${trueRuby} : ${falseRuby})`
57
+ }
58
+
59
+ /**
60
+ * Lower a bare object-literal expression (`{ align: 'start' }`), carried as
61
+ * the IR's structured `ParsedExpr` tree, to a Ruby Hash via
62
+ * `objectLiteralToRubyHash`, or null when it isn't a plain object literal.
63
+ * Used for inline object-literal child props (carousel `opts`).
64
+ */
65
+ export function objectLiteralExprToRubyHash(
66
+ ctx: ErbSpreadContext,
67
+ expr: ParsedExpr | undefined,
68
+ ): string | null {
69
+ if (!expr || expr.kind !== 'object-literal') return null
70
+ return objectLiteralToRubyHash(ctx, expr)
71
+ }
72
+
73
+ /**
74
+ * Convert a static object literal into a Ruby Hash string for a conditional
75
+ * spread. Only static string/identifier keys are allowed; values resolve
76
+ * via `convertExpressionToRuby`. Returns null for any computed/spread/
77
+ * dynamic key. Empty object → `{}`.
78
+ */
79
+ export function objectLiteralToRubyHash(
80
+ ctx: ErbSpreadContext,
81
+ obj: Extract<ParsedExpr, { kind: 'object-literal' }>,
82
+ ): string | null {
83
+ const entries: string[] = []
84
+ for (const prop of obj.properties) {
85
+ // Shorthand `{ a }` was a `ShorthandPropertyAssignment` (not a
86
+ // `PropertyAssignment`), so the former parser rejected it — keep refusing.
87
+ if (prop.shorthand) return null
88
+ // A numeric key (`{ 1: x }`) was rejected by the former parser (only
89
+ // identifier / string-literal names were accepted); `keyKind`
90
+ // distinguishes it from a same-text string `'1'` key.
91
+ if (prop.keyKind === 'numeric') return null
92
+ const key = prop.key
93
+ const val = prop.value
94
+ const indexed = recordIndexAccessToRuby(ctx, val)
95
+ if (
96
+ indexed === null &&
97
+ val.kind === 'index-access' &&
98
+ !isLiteralIndex(val.index)
99
+ ) {
100
+ // Variable-index record access (`sizeMap[size]`) that the
101
+ // static-inline path couldn't resolve — a non-scalar record value,
102
+ // or a non-const receiver. Record BF101 and bail so the whole spread
103
+ // surfaces the out-of-shape diagnostic.
104
+ ctx.errors.push({
105
+ code: 'BF101',
106
+ severity: 'error',
107
+ message: `Spread object value '${stringifyParsedExpr(val)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Ruby Hash.`,
108
+ loc: { file: ctx.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
109
+ suggestion: {
110
+ message: 'Index a record whose values are number/string literals, or move the spread into a \'use client\' component so hydration computes it.',
111
+ },
112
+ })
113
+ return null
114
+ }
115
+ const valRuby =
116
+ indexed !== null
117
+ ? indexed
118
+ // Thread the carried `val` tree straight through as `preParsed` —
119
+ // no stringify→re-parse round-trip.
120
+ : ctx.convertExpressionToRuby('', val)
121
+ entries.push(`${rubySymbolKey(key)} ${valRuby}`)
122
+ }
123
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
124
+ }
125
+
126
+ /** True when a parsed index is a numeric or string literal (`arr[0]`, `m['k']`). */
127
+ function isLiteralIndex(index: ParsedExpr): boolean {
128
+ return (
129
+ index.kind === 'literal' &&
130
+ (index.literalType === 'number' || index.literalType === 'string')
131
+ )
132
+ }
133
+
134
+ /**
135
+ * Lower a spread-object VALUE of the form `IDENT[KEY]` where:
136
+ * - `IDENT` resolves via `localConstants` to a MODULE-scope object
137
+ * literal whose property values are all scalar (number/string)
138
+ * literals under static (string-literal or identifier) keys
139
+ * (a `Record<staticKeys, scalar>` map like `sizeMap`), AND
140
+ * - `KEY` is a bare identifier that is a prop.
141
+ * Emits an inline indexed Ruby Hash:
142
+ * `{ sm: 16, md: 20 }[v[:size].to_sym]`
143
+ *
144
+ * Returns the Ruby string when convertible, else `null` so the caller falls
145
+ * back to its normal value lowering (which records BF101 for an unsupported
146
+ * shape). Mirror of the Go adapter's `recordIndexAccessToGoMap`.
147
+ */
148
+ export function recordIndexAccessToRuby(ctx: ErbSpreadContext, val: ParsedExpr): string | null {
149
+ // `parseRecordIndexAccess` (the shared single-source-of-truth parser) takes a
150
+ // `ts.Expression`. The only shape it accepts is `IDENT[KEY]` with identifier
151
+ // object and index, so rebuild exactly that node from the carried tree via
152
+ // `ts.factory` — no source-text re-parse needed. Any other shape can't match
153
+ // and short-circuits to `null` here.
154
+ if (
155
+ val.kind !== 'index-access' ||
156
+ val.object.kind !== 'identifier' ||
157
+ val.index.kind !== 'identifier'
158
+ ) {
159
+ return null
160
+ }
161
+ const tsVal = ts.factory.createElementAccessExpression(
162
+ ts.factory.createIdentifier(val.object.name),
163
+ ts.factory.createIdentifier(val.index.name),
164
+ )
165
+ // Shared structural parse (single source of truth in `@barefootjs/jsx`);
166
+ // this wrapper only does the Ruby-specific emit (Hash literal + symbol
167
+ // key access) from the structured result.
168
+ const parsed = parseRecordIndexAccess(tsVal, ctx.localConstants, ctx.propsParams)
169
+ if (!parsed) return null
170
+ const entries = parsed.entries.map(e => {
171
+ const mapVal =
172
+ e.value.kind === 'number' ? e.value.text : `'${escapeRubySingleQuoted(e.value.text)}'`
173
+ return `${rubySymbolKey(e.key)} ${mapVal}`
174
+ })
175
+ return `{ ${entries.join(', ')} }[v[:${parsed.indexPropName}].to_sym]`
176
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * String-type value helpers for the ERB template adapter.
3
+ *
4
+ * Ported from the Mojolicious adapter's `value/parsed-literal.ts`
5
+ * (issue #2018 track D lineage). Pure functions over analyzer type info /
6
+ * const-initializer text — no adapter instance state.
7
+ *
8
+ * SHARED CANDIDATE: `isStringTypeInfo` and `isBareStringLiteral` are
9
+ * byte-identical to the Mojo/Xslate adapters' copies and adapter-agnostic.
10
+ *
11
+ * Module-scope pure-string-const inlining is owned by the shared
12
+ * `collectModuleStringConsts` in `@barefootjs/jsx` (consumed via
13
+ * `moduleStringConsts`), so no adapter-local source re-parse lives here.
14
+ */
15
+
16
+ import type { TypeInfo } from '@barefootjs/jsx'
17
+
18
+ /** True when `type` is the `string` primitive. */
19
+ export function isStringTypeInfo(type: TypeInfo | undefined): boolean {
20
+ return type?.kind === 'primitive' && type.primitive === 'string'
21
+ }
22
+
23
+ /** True when `initialValue` is a bare string-literal expression (`'x'` /
24
+ * `"x"`), used as a fallback for signals whose type wasn't inferred. */
25
+ export function isBareStringLiteral(initialValue: string | undefined): boolean {
26
+ if (!initialValue) return false
27
+ const v = initialValue.trim()
28
+ return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
29
+ }
package/src/build.ts ADDED
@@ -0,0 +1,37 @@
1
+ // ERB build config factory for barefoot.config.ts
2
+
3
+ import type { BuildOptions } from '@barefootjs/jsx'
4
+ import { ErbAdapter } from './adapter/index.ts'
5
+ import type { ErbAdapterOptions } from './adapter/index.ts'
6
+
7
+ export interface ErbBuildOptions extends BuildOptions {
8
+ /** Adapter-specific options passed to ErbAdapter */
9
+ adapterOptions?: ErbAdapterOptions
10
+ }
11
+
12
+ /**
13
+ * Create a BarefootBuildConfig for ERB (Embedded Ruby) template projects.
14
+ *
15
+ * Uses structural typing — does not import BarefootBuildConfig to avoid a
16
+ * circular dependency between @barefootjs/erb and @barefootjs/cli.
17
+ */
18
+ export function createConfig(options: ErbBuildOptions = {}) {
19
+ return {
20
+ adapter: new ErbAdapter(options.adapterOptions),
21
+ paths: options.paths,
22
+ components: options.components,
23
+ outDir: options.outDir,
24
+ minify: options.minify,
25
+ contentHash: options.contentHash,
26
+ externals: options.externals,
27
+ externalsBasePath: options.externalsBasePath,
28
+ bundleEntries: options.bundleEntries,
29
+ localImportPrefixes: options.localImportPrefixes,
30
+ outputLayout: options.outputLayout ?? {
31
+ templates: 'templates',
32
+ clientJs: 'client',
33
+ runtime: 'client',
34
+ },
35
+ postBuild: options.postBuild,
36
+ }
37
+ }