@barefootjs/xslate 0.16.0 → 0.17.1

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 +73 -0
  6. package/dist/adapter/expr/array-method.d.ts.map +1 -0
  7. package/dist/adapter/expr/emitters.d.ts +96 -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 +1412 -1323
  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 +29 -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 +1412 -1323
  31. package/dist/index.js +1412 -1323
  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 +200 -8
  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 +332 -0
  39. package/src/adapter/expr/emitters.ts +548 -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 +53 -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 +78 -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 +190 -1236
  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,78 @@
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
+ computeSsrSeedPlan,
17
+ } from '@barefootjs/jsx'
18
+
19
+ import type { XslateMemoContext } from '../emit-context.ts'
20
+
21
+ /** Kolon literal for a context-consumer's `createContext` default. */
22
+ export function contextDefaultKolon(c: ContextConsumer): string {
23
+ const d = c.defaultValue
24
+ if (d === null || d === undefined) return 'nil'
25
+ if (typeof d === 'string') return `'${d.replace(/[\\']/g, m => `\\${m}`)}'`
26
+ if (typeof d === 'boolean') return d ? '1' : '0'
27
+ return String(d)
28
+ }
29
+
30
+ /**
31
+ * Emit one `: my $<local> = $bf.use_context(...)` line-statement per
32
+ * context consumer so the body's bare `$<local>` resolves to the active
33
+ * provider value (or the `createContext` default). (#1297)
34
+ */
35
+ export function generateContextConsumerSeed(ir: ComponentIR): string {
36
+ const consumers = collectContextConsumers(ir.metadata)
37
+ if (consumers.length === 0) return ''
38
+ return (
39
+ consumers
40
+ .map(
41
+ c =>
42
+ `: my $${c.localName} = $bf.use_context('${c.contextName}', ${contextDefaultKolon(c)});`,
43
+ )
44
+ .join('\n') + '\n'
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Emit `: my $<name> = <kolon>;` line-statements for every `derived` step of
50
+ * the backend-neutral SSR seed plan — the scope/availability/ordering
51
+ * analysis lives in `computeSsrSeedPlan` (packages/jsx/src/ssr-seed-plan.ts);
52
+ * this only lowers each step's expression to Kolon and applies the
53
+ * backend-specific emit guards: skip an empty lowering, skip a lowering that
54
+ * references no `$var` at all (a constant init/body — e.g. a `derived` step
55
+ * with empty `frees` — keeps the existing static ssr-defaults seed instead),
56
+ * and skip a self-referencing lowering (Kolon's `my` shadows the RHS, so
57
+ * `: my $x = … $x …` would read the just-declared undefined lexical rather
58
+ * than the render var — the plan rules out SOURCE-level self-refs, but a
59
+ * lowered canonical name could still collide, so this stays as the cheap
60
+ * defense it always was). `env-reader` and `opaque` steps emit nothing (the
61
+ * runtime supplies the reader, or the adapter's ssr-defaults path already
62
+ * covers it). (#1297, #2075)
63
+ */
64
+ export function generateDerivedMemoSeed(ctx: XslateMemoContext, ir: ComponentIR): string {
65
+ // Package G attached this to metadata at compile time; the `??` fallback
66
+ // only covers hand-built metadata in older tests that predate the attached
67
+ // plan — same shared function, so there's no divergence from the compiler.
68
+ const plan = ir.metadata.ssrSeedPlan ?? computeSsrSeedPlan(ir.metadata)
69
+ const lines: string[] = []
70
+ for (const step of plan.steps) {
71
+ if (step.kind !== 'derived') continue
72
+ const kolon = ctx.convertExpressionToKolon(step.expr, step.parsed)
73
+ if (kolon === '' || !/\$[A-Za-z_]\w*/.test(kolon)) continue
74
+ if (new RegExp(`\\$${step.name}\\b`).test(kolon)) continue
75
+ lines.push(`: my $${step.name} = ${kolon};`)
76
+ }
77
+ return lines.length > 0 ? lines.join('\n') + '\n' : ''
78
+ }
@@ -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
+ }