@barefootjs/xslate 0.16.0 → 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 (50) hide show
  1. package/dist/adapter/analysis/component-tree.d.ts +30 -0
  2. package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
  3. package/dist/adapter/emit-context.d.ts +94 -0
  4. package/dist/adapter/emit-context.d.ts.map +1 -0
  5. package/dist/adapter/expr/array-method.d.ts +63 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +97 -0
  8. package/dist/adapter/expr/emitters.d.ts.map +1 -0
  9. package/dist/adapter/expr/operand.d.ts +25 -0
  10. package/dist/adapter/expr/operand.d.ts.map +1 -0
  11. package/dist/adapter/index.js +1428 -1326
  12. package/dist/adapter/lib/constants.d.ts +22 -0
  13. package/dist/adapter/lib/constants.d.ts.map +1 -0
  14. package/dist/adapter/lib/ir-scope.d.ts +34 -0
  15. package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
  16. package/dist/adapter/lib/kolon-naming.d.ts +22 -0
  17. package/dist/adapter/lib/kolon-naming.d.ts.map +1 -0
  18. package/dist/adapter/lib/types.d.ts +27 -0
  19. package/dist/adapter/lib/types.d.ts.map +1 -0
  20. package/dist/adapter/memo/seed.d.ts +38 -0
  21. package/dist/adapter/memo/seed.d.ts.map +1 -0
  22. package/dist/adapter/props/prop-classes.d.ts +32 -0
  23. package/dist/adapter/props/prop-classes.d.ts.map +1 -0
  24. package/dist/adapter/spread/spread-codegen.d.ts +69 -0
  25. package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
  26. package/dist/adapter/value/parsed-literal.d.ts +32 -0
  27. package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
  28. package/dist/adapter/xslate-adapter.d.ts +38 -92
  29. package/dist/adapter/xslate-adapter.d.ts.map +1 -1
  30. package/dist/build.js +1428 -1326
  31. package/dist/index.js +1428 -1326
  32. package/lib/BarefootJS/Backend/Xslate.pm +1 -1
  33. package/package.json +3 -3
  34. package/src/__tests__/query-href.test.ts +96 -0
  35. package/src/__tests__/xslate-adapter.test.ts +55 -0
  36. package/src/adapter/analysis/component-tree.ts +123 -0
  37. package/src/adapter/emit-context.ts +104 -0
  38. package/src/adapter/expr/array-method.ts +311 -0
  39. package/src/adapter/expr/emitters.ts +530 -0
  40. package/src/adapter/expr/operand.ts +35 -0
  41. package/src/adapter/lib/constants.ts +34 -0
  42. package/src/adapter/lib/ir-scope.ts +64 -0
  43. package/src/adapter/lib/kolon-naming.ts +27 -0
  44. package/src/adapter/lib/types.ts +30 -0
  45. package/src/adapter/memo/seed.ts +125 -0
  46. package/src/adapter/props/prop-classes.ts +64 -0
  47. package/src/adapter/spread/spread-codegen.ts +179 -0
  48. package/src/adapter/value/parsed-literal.ts +80 -0
  49. package/src/adapter/xslate-adapter.ts +169 -1233
  50. package/src/test-render.ts +3 -0
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Kolon identifier / hash-key conventions for the Text::Xslate adapter.
3
+ *
4
+ * Pure helpers extracted from `xslate-adapter.ts` (domain-module refactor,
5
+ * issue #2018 track D): none read adapter instance state, so they live at
6
+ * module scope as the single source of truth for Kolon-literal escaping and
7
+ * hashref-key quoting.
8
+ */
9
+
10
+ /**
11
+ * Escape a string for a Kolon/Perl single-quoted literal: backslash first
12
+ * (so it doesn't double-escape the quote we add next), then the quote. Used
13
+ * by every `'…'` hashref key/value emitter.
14
+ */
15
+ export function escapeKolonSingleQuoted(s: string): string {
16
+ return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")
17
+ }
18
+
19
+ /**
20
+ * Quote a hashref KEY for Kolon when it isn't a bare-identifier-safe name.
21
+ * Kolon parses `data-slot` as `data - slot` (subtraction) and faults on the
22
+ * undefined `data` symbol, so a hyphenated key (`data-slot`, `aria-label`)
23
+ * must be single-quoted: `'data-slot'`. Bare identifiers pass through unquoted.
24
+ */
25
+ export function kolonHashKey(name: string): string {
26
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `'${escapeKolonSingleQuoted(name)}'`
27
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared type aliases for the Text::Xslate (Kolon) template adapter.
3
+ *
4
+ * Extracted from `xslate-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Pure type declarations — no runtime behaviour — so the
6
+ * extracted emit modules and the main adapter share one definition rather
7
+ * than re-declaring the render 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
+ * Xslate adapter's IRNode render context. Like the Mojo adapter, Kolon's
18
+ * lowering doesn't consume any render-position flags, so the Ctx is empty.
19
+ * Kept as a named alias so future flags can extend it without changing the
20
+ * `IRNodeEmitter` interface.
21
+ */
22
+ export type XslateRenderCtx = Record<string, never>
23
+
24
+ export interface XslateAdapterOptions {
25
+ /** Base path for client JS files (default: '/static/components/') */
26
+ clientJsBasePath?: string
27
+
28
+ /** Path to barefoot.js runtime (default: '/static/components/barefoot.js') */
29
+ barefootJsPath?: string
30
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * In-template memo / context seeding for the Text::Xslate template adapter.
3
+ *
4
+ * Extracted from `xslate-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Free functions taking a `XslateMemoContext` (built by the adapter's
6
+ * `memoCtx` getter) so the cluster depends only on the recursive expression entry, not
7
+ * the whole adapter class. These emit the `: my $x = ...;` line-statements
8
+ * that let the body's bare `$x` resolve to a derived signal/memo value or an
9
+ * active context value at SSR time. Mirror of the Go / Mojo adapter's `memo/*`.
10
+ */
11
+
12
+ import {
13
+ type ComponentIR,
14
+ type ContextConsumer,
15
+ collectContextConsumers,
16
+ extractArrowBodyExpression,
17
+ isSupported,
18
+ parseExpression,
19
+ } from '@barefootjs/jsx'
20
+
21
+ import type { XslateMemoContext } from '../emit-context.ts'
22
+ import { referencedVarsAreAvailable } from '../lib/ir-scope.ts'
23
+
24
+ /** Kolon literal for a context-consumer's `createContext` default. */
25
+ export function contextDefaultKolon(c: ContextConsumer): string {
26
+ const d = c.defaultValue
27
+ if (d === null || d === undefined) return 'nil'
28
+ if (typeof d === 'string') return `'${d.replace(/[\\']/g, m => `\\${m}`)}'`
29
+ if (typeof d === 'boolean') return d ? '1' : '0'
30
+ return String(d)
31
+ }
32
+
33
+ /**
34
+ * Emit one `: my $<local> = $bf.use_context(...)` line-statement per
35
+ * context consumer so the body's bare `$<local>` resolves to the active
36
+ * provider value (or the `createContext` default). (#1297)
37
+ */
38
+ export function generateContextConsumerSeed(ir: ComponentIR): string {
39
+ const consumers = collectContextConsumers(ir.metadata)
40
+ if (consumers.length === 0) return ''
41
+ return (
42
+ consumers
43
+ .map(
44
+ c =>
45
+ `: my $${c.localName} = $bf.use_context('${c.contextName}', ${contextDefaultKolon(c)});`,
46
+ )
47
+ .join('\n') + '\n'
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Seed memos whose SSR default is `null` (not statically evaluable) by
53
+ * computing them in-template from the already-seeded prop / signal vars
54
+ * (`createMemo(() => props.value * 10)` → `: my $x = $value * 10;`). Without
55
+ * this the memo's `$x` renders empty — the reason
56
+ * `props-reactivity-comparison` was skipped. Only emitted when every var the
57
+ * lowering references is already in scope. (#1297)
58
+ */
59
+ export function generateDerivedMemoSeed(ctx: XslateMemoContext, ir: ComponentIR): string {
60
+ const memos = ir.metadata.memos ?? []
61
+ const signals = ir.metadata.signals ?? []
62
+ if (memos.length === 0 && signals.length === 0) return ''
63
+ // Props seed first; each signal/memo adds its own name as it lands.
64
+ const available = new Set<string>(ir.metadata.propsParams.map(p => p.name))
65
+ const lines: string[] = []
66
+
67
+ // Prop/signal-derived signals (`createSignal(props.defaultOn ?? false)`):
68
+ // a loop-child render gets no stash seed, so its `$on` would render nil;
69
+ // and the static default can't capture the per-call prop. Seed it
70
+ // in-template when the init lowers cleanly AND references an in-scope var.
71
+ // Object/array/constant inits keep the existing ssr-defaults seeding.
72
+ for (const signal of signals) {
73
+ const kolon = tryLowerToKolon(ctx, signal.initialValue, available)
74
+ // Kolon can't express `: my $x = … $x …` — declaring `my $x` makes the
75
+ // RHS `$x` an undefined lexical rather than the render var. A same-name
76
+ // signal (`createSignal(props.x ?? d)`, getter == prop) is just the prop
77
+ // with a default, which the harness already seeds correctly from the
78
+ // passed prop — skip the in-template seed for it. (Different-name
79
+ // prop-derived signals like toggle's `on` from `defaultOn` are unaffected.)
80
+ const refsSelf = kolon !== null && new RegExp(`\\$${signal.getter}\\b`).test(kolon)
81
+ if (kolon !== null && !refsSelf) lines.push(`: my $${signal.getter} = ${kolon};`)
82
+ available.add(signal.getter)
83
+ }
84
+
85
+ for (const memo of memos) {
86
+ // Seed every memo whose body lowers cleanly — not just the ones whose
87
+ // static SSR default is null. A statically-foldable prop-derived memo
88
+ // (`createMemo(() => props.disabled ?? false)` → default `false`)
89
+ // still depends on the per-call prop: the static stash seed bakes in
90
+ // the absent-prop fold, so a caller passing `disabled => 1` would
91
+ // render the default branch (#1897, select's disabled item). The
92
+ // in-template recomputation reads the prop lexical already in scope;
93
+ // block-bodied arrows / out-of-scope references fall back to the
94
+ // static ssr-defaults seed. Same self-reference guard as the signal
95
+ // loop above — Kolon's `my` shadows the render var on the RHS.
96
+ const body = extractArrowBodyExpression(memo.computation)
97
+ if (body !== null) {
98
+ const kolon = tryLowerToKolon(ctx, body, available)
99
+ const refsSelf = kolon !== null && new RegExp(`\\$${memo.name}\\b`).test(kolon)
100
+ if (kolon !== null && !refsSelf) lines.push(`: my $${memo.name} = ${kolon};`)
101
+ }
102
+ available.add(memo.name)
103
+ }
104
+ return lines.length > 0 ? lines.join('\n') + '\n' : ''
105
+ }
106
+
107
+ /**
108
+ * Lower a signal init / memo body to Kolon for an in-template SSR seed, or
109
+ * `null` when it shouldn't be seeded this way: not a supported shape
110
+ * (`isSupported` pre-check, so object/array literals don't fail the build),
111
+ * references no in-scope var (a constant — keep ssr-defaults seeding), or
112
+ * references an out-of-scope binding. (#1297)
113
+ */
114
+ export function tryLowerToKolon(
115
+ ctx: XslateMemoContext,
116
+ expr: string,
117
+ available: ReadonlySet<string>,
118
+ ): string | null {
119
+ const trimmed = expr.trim()
120
+ if (!trimmed) return null
121
+ if (!isSupported(parseExpression(trimmed)).supported) return null
122
+ const kolon = ctx.convertExpressionToKolon(trimmed)
123
+ if (kolon === '' || !/\$[A-Za-z_]\w*/.test(kolon)) return null
124
+ return referencedVarsAreAvailable(kolon, available) ? kolon : null
125
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Prop classification for the Text::Xslate (Kolon) template adapter.
3
+ *
4
+ * Extracted from `xslate-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Pure functions over `ir.metadata` that derive the per-compile
6
+ * prop/name sets the adapter consults during lowering. Mirror of the Go
7
+ * adapter's `props/prop-types.ts`. No adapter instance state.
8
+ */
9
+
10
+ import type { ComponentIR } from '@barefootjs/jsx'
11
+ import { isStringTypeInfo, isBareStringLiteral } from '../value/parsed-literal.ts'
12
+
13
+ /**
14
+ * Props whose declared TS type is boolean — a bare binding of one
15
+ * (`data-active={props.isActive}`) must stringify as JS `String(boolean)`
16
+ * ("true"/"false"), not Perl's native `1`/`''` (#1897, pagination's
17
+ * data-active).
18
+ */
19
+ export function collectBooleanTypedProps(ir: ComponentIR): Set<string> {
20
+ return new Set(
21
+ ir.metadata.propsParams
22
+ .filter(prop => prop.type?.primitive === 'boolean' || prop.type?.raw === 'boolean')
23
+ .map(prop => prop.name),
24
+ )
25
+ }
26
+
27
+ /**
28
+ * Bare references to optional, no-default, non-primitive props (e.g.
29
+ * textarea's `rows`) are `undef` when omitted → `defined`-guarded in
30
+ * `emitExpression`. See the `nullableOptionalProps` field docstring.
31
+ */
32
+ export function collectNullableOptionalProps(ir: ComponentIR): Set<string> {
33
+ return new Set(
34
+ ir.metadata.propsParams
35
+ .filter(
36
+ p =>
37
+ p.defaultValue === undefined &&
38
+ !p.isRest &&
39
+ p.type?.kind !== 'primitive',
40
+ )
41
+ .map(p => p.name),
42
+ )
43
+ }
44
+
45
+ /**
46
+ * String-typed signals and props. A signal is string-typed when its inferred
47
+ * type is `string` (or, defensively, when its initial value is a bare string
48
+ * literal); a prop when its annotated type is `string`. In the Mojo adapter
49
+ * this drives `eq`/`ne` selection for string equality; the Kolon emitters
50
+ * don't consume the distinction (Kolon's `==`/`!=` compare strings and numbers
51
+ * correctly), so this set is carried for parity with the Mojo adapter.
52
+ */
53
+ export function collectStringValueNames(ir: ComponentIR): Set<string> {
54
+ const names = new Set<string>()
55
+ for (const s of ir.metadata.signals) {
56
+ if (isStringTypeInfo(s.type) || isBareStringLiteral(s.initialValue)) {
57
+ names.add(s.getter)
58
+ }
59
+ }
60
+ for (const p of ir.metadata.propsParams) {
61
+ if (isStringTypeInfo(p.type)) names.add(p.name)
62
+ }
63
+ return names
64
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Object-literal / conditional-spread → Kolon hashref lowering for the
3
+ * Text::Xslate template adapter.
4
+ *
5
+ * Extracted from `xslate-adapter.ts` (domain-module refactor, issue #2018
6
+ * track D). Free functions taking a `XslateSpreadContext` (built by the adapter's
7
+ * `spreadCtx` getter) so the cluster depends on the narrow seam — the recursive
8
+ * expression entry plus per-compile bookkeeping — rather than the whole
9
+ * adapter class. Mirror of the Go / Mojo adapter's `spread/spread-codegen.ts`.
10
+ *
11
+ * The conditional-spread / object-literal entries read the IR-carried
12
+ * structured `ParsedExpr` tree (#2018, mirroring go-template's U5/U6) instead
13
+ * of re-parsing the source with `ts.createSourceFile`. The condition and scalar
14
+ * values are threaded straight into `ctx.convertExpressionToKolon` as its
15
+ * `preParsed` argument (cf. go-template's
16
+ * `convertExpressionToGo(jsExpr, out?, preParsed?)`), so no stringify→re-parse
17
+ * round-trip occurs — the emitted Kolon is byte-identical to the former path
18
+ * because the carried tree is exactly what re-parsing the stringified text
19
+ * produced. The `ts.factory` rebuild in `recordIndexAccessToKolon` only
20
+ * reconstructs the `IDENT[KEY]` node the shared `parseRecordIndexAccess` parser
21
+ * accepts; no source-text re-parse. `stringifyParsedExpr` is retained solely for
22
+ * the BF101 diagnostic message (display purposes).
23
+ */
24
+
25
+ import ts from 'typescript'
26
+ import { parseRecordIndexAccess, stringifyParsedExpr } from '@barefootjs/jsx'
27
+ import type { ParsedExpr } from '@barefootjs/jsx'
28
+
29
+ import type { XslateSpreadContext } from '../emit-context.ts'
30
+ import { escapeKolonSingleQuoted } from '../lib/kolon-naming.ts'
31
+
32
+ /**
33
+ * Lower a conditional inline-object spread
34
+ * `COND ? { 'aria-describedby': describedBy } : {}`
35
+ * to a Kolon inline ternary of hashrefs
36
+ * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`.
37
+ * Reads the IR-carried structured `ParsedExpr` tree; both branches must be
38
+ * object literals; the condition + values route through
39
+ * `convertExpressionToKolon`. Returns `null` for any other shape so the caller
40
+ * falls back to its normal lowering. Mirror of `conditionalSpreadToPerl`.
41
+ * `parseExpression` already strips redundant parentheses, so the conditional /
42
+ * object-literal shapes surface directly.
43
+ */
44
+ export function conditionalSpreadToKolon(
45
+ ctx: XslateSpreadContext,
46
+ expr: ParsedExpr | undefined,
47
+ ): string | null {
48
+ if (!expr || expr.kind !== 'conditional') return null
49
+ const whenTrue = expr.consequent
50
+ const whenFalse = expr.alternate
51
+ if (whenTrue.kind !== 'object-literal' || whenFalse.kind !== 'object-literal') {
52
+ return null
53
+ }
54
+ // Thread the condition's carried `ParsedExpr` tree straight through as
55
+ // `preParsed` (#2018) — no stringify→re-parse round-trip; `convertExpressionToKolon`
56
+ // uses the tree directly and derives any diagnostic text from it.
57
+ const condKolon = ctx.convertExpressionToKolon('', expr.test)
58
+ const trueKolon = objectLiteralToKolonHashref(ctx, whenTrue)
59
+ const falseKolon = objectLiteralToKolonHashref(ctx, whenFalse)
60
+ if (trueKolon === null || falseKolon === null) return null
61
+ return `${condKolon} ? ${trueKolon} : ${falseKolon}`
62
+ }
63
+
64
+ /**
65
+ * (#1971 Perl) Lower a bare object-literal expression (`{ align: 'start' }`),
66
+ * carried as the IR's structured `ParsedExpr` tree, to a Kolon hashref via
67
+ * `objectLiteralToKolonHashref`, or null when it isn't a plain object literal.
68
+ * Used for inline object-literal child props (carousel `opts`).
69
+ */
70
+ export function objectLiteralExprToKolonHashref(
71
+ ctx: XslateSpreadContext,
72
+ expr: ParsedExpr | undefined,
73
+ ): string | null {
74
+ if (!expr || expr.kind !== 'object-literal') return null
75
+ return objectLiteralToKolonHashref(ctx, expr)
76
+ }
77
+
78
+ /**
79
+ * Convert a static object literal into a Kolon hashref string for a
80
+ * conditional spread. Only static string/identifier keys are allowed; values
81
+ * resolve via `convertExpressionToKolon` (or the `Record[propKey]` index
82
+ * lowering). Returns `null` for any computed/spread/dynamic key. Empty object
83
+ * → `{}`. Mirror of `objectLiteralToPerlHashref`.
84
+ */
85
+ export function objectLiteralToKolonHashref(
86
+ ctx: XslateSpreadContext,
87
+ obj: Extract<ParsedExpr, { kind: 'object-literal' }>,
88
+ ): string | null {
89
+ const entries: string[] = []
90
+ for (const prop of obj.properties) {
91
+ // Shorthand `{ a }` was a `ShorthandPropertyAssignment` (not a
92
+ // `PropertyAssignment`), so the former parser rejected it — keep refusing.
93
+ if (prop.shorthand) return null
94
+ // A numeric key (`{ 1: x }`) was rejected by the former parser (only
95
+ // identifier / string-literal names were accepted); `keyKind`
96
+ // distinguishes it from a same-text string `'1'` key.
97
+ if (prop.keyKind === 'numeric') return null
98
+ const key = prop.key
99
+ const val = prop.value
100
+ const indexed = recordIndexAccessToKolon(ctx, val)
101
+ if (
102
+ indexed === null &&
103
+ val.kind === 'index-access' &&
104
+ !isLiteralIndex(val.index)
105
+ ) {
106
+ // Variable-index record access (`sizeMap[size]`) the static-inline
107
+ // path couldn't resolve (non-scalar value / non-const receiver).
108
+ // Since #1897 made variable indices parseable (`index-access`),
109
+ // the generic value lowering would emit `$sizeMap[$size]` against
110
+ // an UNBOUND module const instead of refusing — record BF101 and
111
+ // bail so the spread surfaces the out-of-shape diagnostic,
112
+ // matching pre-#1897 behaviour. (Mirrors the Mojo adapter.)
113
+ ctx.errors.push({
114
+ code: 'BF101',
115
+ severity: 'error',
116
+ message: `Spread object value '${stringifyParsedExpr(val)}' indexes a record map whose values aren't scalar literals — it can't lower to an inline Kolon hashref.`,
117
+ loc: { file: ctx.componentName + '.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } },
118
+ suggestion: {
119
+ message: 'Index a record whose values are number/string literals, or move the spread into a `\'use client\'` component so hydration computes it.',
120
+ },
121
+ })
122
+ return null
123
+ }
124
+ const valKolon =
125
+ indexed !== null
126
+ ? indexed
127
+ // Thread the carried `val` tree straight through as `preParsed` (#2018)
128
+ // — no stringify→re-parse round-trip.
129
+ : ctx.convertExpressionToKolon('', val)
130
+ entries.push(`'${escapeKolonSingleQuoted(key)}' => ${valKolon}`)
131
+ }
132
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
133
+ }
134
+
135
+ /** True when a parsed index is a numeric or string literal (`arr[0]`, `m['k']`). */
136
+ function isLiteralIndex(index: ParsedExpr): boolean {
137
+ return (
138
+ index.kind === 'literal' &&
139
+ (index.literalType === 'number' || index.literalType === 'string')
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Lower a spread-object VALUE of the form `IDENT[KEY]` (CheckIcon's
145
+ * `sizeMap[size]`) to an inline indexed Kolon hashref
146
+ * `{ 'sm' => 16, 'md' => 20, ... }[$size]`.
147
+ * Reuses the shared structural parse (`parseRecordIndexAccess`) — rebuilding
148
+ * the `IDENT[KEY]` node from the carried tree via `ts.factory` rather than
149
+ * re-parsing source text; this wrapper only does the single-quote escaping +
150
+ * Kolon index emit. NB: Kolon indexes a hashref literal with bracket syntax
151
+ * `{…}[$key]`, NOT Perl's arrow-deref `{…}->{$key}` (which Kolon's parser
152
+ * rejects) — this is the one divergence from the Mojo `recordIndexAccessToPerl`
153
+ * emit.
154
+ */
155
+ export function recordIndexAccessToKolon(ctx: XslateSpreadContext, val: ParsedExpr): string | null {
156
+ // The only shape `parseRecordIndexAccess` accepts is `IDENT[KEY]` with
157
+ // identifier object and index, so rebuild exactly that node from the carried
158
+ // tree via `ts.factory` — no source-text re-parse. Any other shape can't
159
+ // match and short-circuits to `null` here.
160
+ if (
161
+ val.kind !== 'index-access' ||
162
+ val.object.kind !== 'identifier' ||
163
+ val.index.kind !== 'identifier'
164
+ ) {
165
+ return null
166
+ }
167
+ const tsVal = ts.factory.createElementAccessExpression(
168
+ ts.factory.createIdentifier(val.object.name),
169
+ ts.factory.createIdentifier(val.index.name),
170
+ )
171
+ const parsed = parseRecordIndexAccess(tsVal, ctx.localConstants ?? [], ctx.propsParams)
172
+ if (!parsed) return null
173
+ const entries = parsed.entries.map(e => {
174
+ const mapVal =
175
+ e.value.kind === 'number' ? e.value.text : `'${escapeKolonSingleQuoted(e.value.text)}'`
176
+ return `'${escapeKolonSingleQuoted(e.key)}' => ${mapVal}`
177
+ })
178
+ return `{ ${entries.join(', ')} }[$${parsed.indexPropName}]`
179
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * String-literal value lowering for the Text::Xslate (Kolon) template adapter.
3
+ *
4
+ * Extracted from `xslate-adapter.ts` (domain-module refactor, issue #2018
5
+ * track D). Pure functions over const-initializer source text and analyzer
6
+ * type info — no adapter instance state.
7
+ *
8
+ * SHARED CANDIDATE: `isStringTypeInfo` and `isBareStringLiteral` are
9
+ * byte-identical to the Mojo adapter's copies and adapter-agnostic —
10
+ * extraction candidates for a shared Perl-family codegen module (groundwork
11
+ * for the future Perl evaluator integration, issue #2018 track D).
12
+ * `parsePureStringLiteral` deliberately differs (Xslate hand-parses; Mojo
13
+ * uses the TS parser), so it stays per-adapter.
14
+ */
15
+
16
+ import { evalStringArrayJoin, type TypeInfo } from '@barefootjs/jsx'
17
+
18
+ /**
19
+ * Parse a const initializer's source text. Returns the unescaped string value
20
+ * when the whole initializer is a single pure string literal — single/double
21
+ * quoted, or a no-substitution backtick template (no `${}`) — else `null`.
22
+ * Only such a value can be inlined byte-for-byte; template literals with
23
+ * interpolation, numbers, objects, and `Record<T,string>` maps are excluded.
24
+ */
25
+ export function parsePureStringLiteral(source: string): string | null {
26
+ let s = source.trim()
27
+ // Peel a single layer of wrapping parens.
28
+ while (s.startsWith('(') && s.endsWith(')')) s = s.slice(1, -1).trim()
29
+ const quote = s[0]
30
+ if ((quote === "'" || quote === '"') && s[s.length - 1] === quote) {
31
+ const body = s.slice(1, -1)
32
+ // Reject if an unescaped matching quote appears inside (not a single
33
+ // literal then).
34
+ if (containsUnescaped(body, quote)) return null
35
+ return unescapeStringLiteralBody(body)
36
+ }
37
+ if (quote === '`' && s[s.length - 1] === '`') {
38
+ const body = s.slice(1, -1)
39
+ if (body.includes('${')) return null
40
+ if (containsUnescaped(body, '`')) return null
41
+ return unescapeStringLiteralBody(body)
42
+ }
43
+ // `[<literals>].join(' ')` module consts (e.g. Switch's `trackStateClasses`)
44
+ // → inline the flattened string byte-for-byte. See `evalStringArrayJoin`.
45
+ return evalStringArrayJoin(source)
46
+ }
47
+
48
+ /** Whether `s` contains an unescaped occurrence of `ch`. */
49
+ export function containsUnescaped(s: string, ch: string): boolean {
50
+ for (let i = 0; i < s.length; i++) {
51
+ if (s[i] === '\\') { i++; continue }
52
+ if (s[i] === ch) return true
53
+ }
54
+ return false
55
+ }
56
+
57
+ /** Unescape a JS string-literal body's common escape sequences. */
58
+ export function unescapeStringLiteralBody(s: string): string {
59
+ return s.replace(/\\(.)/g, (_, c) => {
60
+ switch (c) {
61
+ case 'n': return '\n'
62
+ case 'r': return '\r'
63
+ case 't': return '\t'
64
+ case '0': return '\0'
65
+ default: return c
66
+ }
67
+ })
68
+ }
69
+
70
+ /** True when `type` is the `string` primitive. */
71
+ export function isStringTypeInfo(type: TypeInfo | undefined): boolean {
72
+ return type?.kind === 'primitive' && type.primitive === 'string'
73
+ }
74
+
75
+ /** True when `initialValue` is a bare string-literal expression. */
76
+ export function isBareStringLiteral(initialValue: string | undefined): boolean {
77
+ if (!initialValue) return false
78
+ const v = initialValue.trim()
79
+ return (v.startsWith("'") && v.endsWith("'")) || (v.startsWith('"') && v.endsWith('"'))
80
+ }