@barefootjs/xslate 0.15.2 → 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.
- package/dist/adapter/analysis/component-tree.d.ts +30 -0
- package/dist/adapter/analysis/component-tree.d.ts.map +1 -0
- package/dist/adapter/emit-context.d.ts +94 -0
- package/dist/adapter/emit-context.d.ts.map +1 -0
- package/dist/adapter/expr/array-method.d.ts +63 -0
- package/dist/adapter/expr/array-method.d.ts.map +1 -0
- package/dist/adapter/expr/emitters.d.ts +97 -0
- package/dist/adapter/expr/emitters.d.ts.map +1 -0
- package/dist/adapter/expr/operand.d.ts +25 -0
- package/dist/adapter/expr/operand.d.ts.map +1 -0
- package/dist/adapter/index.js +1428 -1324
- package/dist/adapter/lib/constants.d.ts +22 -0
- package/dist/adapter/lib/constants.d.ts.map +1 -0
- package/dist/adapter/lib/ir-scope.d.ts +34 -0
- package/dist/adapter/lib/ir-scope.d.ts.map +1 -0
- package/dist/adapter/lib/kolon-naming.d.ts +22 -0
- package/dist/adapter/lib/kolon-naming.d.ts.map +1 -0
- package/dist/adapter/lib/types.d.ts +27 -0
- package/dist/adapter/lib/types.d.ts.map +1 -0
- package/dist/adapter/memo/seed.d.ts +38 -0
- package/dist/adapter/memo/seed.d.ts.map +1 -0
- package/dist/adapter/props/prop-classes.d.ts +32 -0
- package/dist/adapter/props/prop-classes.d.ts.map +1 -0
- package/dist/adapter/spread/spread-codegen.d.ts +69 -0
- package/dist/adapter/spread/spread-codegen.d.ts.map +1 -0
- package/dist/adapter/value/parsed-literal.d.ts +32 -0
- package/dist/adapter/value/parsed-literal.d.ts.map +1 -0
- package/dist/adapter/xslate-adapter.d.ts +38 -92
- package/dist/adapter/xslate-adapter.d.ts.map +1 -1
- package/dist/build.js +1428 -1324
- package/dist/index.js +1428 -1324
- package/lib/BarefootJS/Backend/Xslate.pm +1 -1
- package/package.json +3 -3
- package/src/__tests__/query-href.test.ts +96 -0
- package/src/__tests__/xslate-adapter.test.ts +94 -2
- package/src/adapter/analysis/component-tree.ts +123 -0
- package/src/adapter/emit-context.ts +104 -0
- package/src/adapter/expr/array-method.ts +311 -0
- package/src/adapter/expr/emitters.ts +530 -0
- package/src/adapter/expr/operand.ts +35 -0
- package/src/adapter/lib/constants.ts +34 -0
- package/src/adapter/lib/ir-scope.ts +64 -0
- package/src/adapter/lib/kolon-naming.ts +27 -0
- package/src/adapter/lib/types.ts +30 -0
- package/src/adapter/memo/seed.ts +125 -0
- package/src/adapter/props/prop-classes.ts +64 -0
- package/src/adapter/spread/spread-codegen.ts +179 -0
- package/src/adapter/value/parsed-literal.ts +80 -0
- package/src/adapter/xslate-adapter.ts +176 -1233
- 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
|
+
}
|