@barefootjs/go-template 0.5.3 → 0.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/go-template",
3
- "version": "0.5.3",
3
+ "version": "0.6.1",
4
4
  "description": "Go html/template adapter for BarefootJS - generates Go template files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,6 +54,6 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@barefootjs/adapter-tests": "0.1.0",
57
- "@barefootjs/jsx": "0.5.3"
57
+ "@barefootjs/jsx": "0.6.1"
58
58
  }
59
59
  }
@@ -2182,11 +2182,26 @@ import { fixture as arrayLastIndexOfFixture } from '../../../adapter-tests/fixtu
2182
2182
  import { fixture as arrayAtFixture } from '../../../adapter-tests/fixtures/methods/array-at'
2183
2183
  import { fixture as arrayConcatFixture } from '../../../adapter-tests/fixtures/methods/array-concat'
2184
2184
  import { fixture as arraySliceFixture } from '../../../adapter-tests/fixtures/methods/array-slice'
2185
+ import { fixture as arraySliceCopyFixture } from '../../../adapter-tests/fixtures/methods/array-slice-copy'
2186
+ import { fixture as arrayJoinDefaultFixture } from '../../../adapter-tests/fixtures/methods/array-join-default'
2187
+ import { fixture as arrayAtDefaultFixture } from '../../../adapter-tests/fixtures/methods/array-at-default'
2188
+ import { fixture as arrayConcatCopyFixture } from '../../../adapter-tests/fixtures/methods/array-concat-copy'
2185
2189
  import { fixture as arrayReverseFixture } from '../../../adapter-tests/fixtures/methods/array-reverse'
2186
2190
  import { fixture as arrayToReversedFixture } from '../../../adapter-tests/fixtures/methods/array-toReversed'
2187
2191
  import { fixture as stringToLowerCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toLowerCase'
2188
2192
  import { fixture as stringToUpperCaseFixture } from '../../../adapter-tests/fixtures/methods/string-toUpperCase'
2189
2193
  import { fixture as stringTrimFixture } from '../../../adapter-tests/fixtures/methods/string-trim'
2194
+ // #1448 Tier B — string methods.
2195
+ import { fixture as stringSplitFixture } from '../../../adapter-tests/fixtures/methods/string-split'
2196
+ import { fixture as stringSplitLimitFixture } from '../../../adapter-tests/fixtures/methods/string-split-limit'
2197
+ import { fixture as stringStartsWithFixture } from '../../../adapter-tests/fixtures/methods/string-startsWith'
2198
+ import { fixture as stringStartsWithPositionFixture } from '../../../adapter-tests/fixtures/methods/string-startsWith-position'
2199
+ import { fixture as stringEndsWithFixture } from '../../../adapter-tests/fixtures/methods/string-endsWith'
2200
+ import { fixture as stringEndsWithPositionFixture } from '../../../adapter-tests/fixtures/methods/string-endsWith-position'
2201
+ import { fixture as stringReplaceFixture } from '../../../adapter-tests/fixtures/methods/string-replace'
2202
+ import { fixture as stringRepeatFixture } from '../../../adapter-tests/fixtures/methods/string-repeat'
2203
+ import { fixture as stringPadStartFixture } from '../../../adapter-tests/fixtures/methods/string-padStart'
2204
+ import { fixture as stringPadEndFixture } from '../../../adapter-tests/fixtures/methods/string-padEnd'
2190
2205
  // #1448 Tier B — .sort / .toSorted fixtures.
2191
2206
  import { fixture as arraySortFieldAscFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-asc'
2192
2207
  import { fixture as arraySortFieldDescFixture } from '../../../adapter-tests/fixtures/methods/array-sort-field-desc'
@@ -2214,6 +2229,13 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
2214
2229
  { fixture: arrayAtFixture, expect: 'bf_at .Items (bf_neg 1)' },
2215
2230
  { fixture: arrayConcatFixture, expect: 'bf_concat .Left .Right' },
2216
2231
  { fixture: arraySliceFixture, expect: 'bf_slice .Items 1 3' },
2232
+ // #1448 full-arity — zero-arg defaults. `.slice()` → start 0 (full
2233
+ // copy); `.join()` → default `,` separator.
2234
+ { fixture: arraySliceCopyFixture, expect: 'bf_slice .Items 0' },
2235
+ { fixture: arrayJoinDefaultFixture, expect: 'bf_join (.Items) ","' },
2236
+ // `.at()` → index 0; `.concat()` → the receiver (shallow copy).
2237
+ { fixture: arrayAtDefaultFixture, expect: 'bf_at .Items 0' },
2238
+ { fixture: arrayConcatCopyFixture, expect: 'bf_join (.Items) "|"' },
2217
2239
  { fixture: arrayReverseFixture, expect: 'bf_reverse .Items' },
2218
2240
  // .toReversed shares the helper with .reverse — pinning both
2219
2241
  // routings catches a future divergence between them.
@@ -2221,6 +2243,24 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
2221
2243
  { fixture: stringToLowerCaseFixture,expect: 'bf_lower .Value' },
2222
2244
  { fixture: stringToUpperCaseFixture,expect: 'bf_upper .Value' },
2223
2245
  { fixture: stringTrimFixture, expect: 'bf_trim .Value' },
2246
+ // #1448 Tier B — string → array. `.split(',')` lowers to
2247
+ // `bf_split`, here chained into `.join('|')` so the slice is
2248
+ // observable (`bf_join (bf_split .Value ",") "|"`).
2249
+ { fixture: stringSplitFixture, expect: 'bf_split .Value ","' },
2250
+ { fixture: stringSplitLimitFixture, expect: 'bf_split .Value "," 2' },
2251
+ // #1448 Tier B — string → boolean at condition position, so the
2252
+ // emit lands inside `{{if ...}}`.
2253
+ { fixture: stringStartsWithFixture, expect: '{{if bf_starts_with .Value .Prefix}}' },
2254
+ { fixture: stringStartsWithPositionFixture, expect: '{{if bf_starts_with .Value "world" 6}}' },
2255
+ { fixture: stringEndsWithFixture, expect: '{{if bf_ends_with .Value .Suffix}}' },
2256
+ { fixture: stringEndsWithPositionFixture, expect: '{{if bf_ends_with .Value "hello" 5}}' },
2257
+ // #1448 Tier B — string → string, first-occurrence replace.
2258
+ { fixture: stringReplaceFixture, expect: 'bf_replace .Value "o" "0"' },
2259
+ // #1448 Tier B — string → string, repeat n times.
2260
+ { fixture: stringRepeatFixture, expect: 'bf_repeat .Value 3' },
2261
+ // #1448 Tier B — string → string, padded to a target width.
2262
+ { fixture: stringPadStartFixture, expect: 'bf_pad_start .Value 5 "0"' },
2263
+ { fixture: stringPadEndFixture, expect: 'bf_pad_end .Value 5 "."' },
2224
2264
  // #1448 Tier B — sort / toSorted. Loop-chained shapes wrap the
2225
2265
  // iterable in `bf_sort .Items <kind> <key> <type> <dir>`;
2226
2266
  // standalone shapes inline the helper at the call site.
@@ -2278,6 +2318,81 @@ describe('GoTemplateAdapter - #1448 Tier A/B fixture-driven lowering pins', () =
2278
2318
  // now listed in `UNSUPPORTED_METHODS`, so `isSupported` refuses them
2279
2319
  // and `convertExpressionToGo` records BF101 — the same treatment the
2280
2320
  // unsupported array methods already got. These tests pin that parity.
2321
+ describe('GoTemplateAdapter - #1448 Tier C reduce field capitalisation', () => {
2322
+ // #1728 review: `bf_reduce`'s projected field name must use the same
2323
+ // initialism-aware capitalisation the adapter applies when generating
2324
+ // struct fields (`capitalizeFieldName`), or the runtime reflect lookup
2325
+ // misses the exported field (`id` → struct `ID`, not `Id`) and folds a
2326
+ // zero value. Pin the emitted key so a regression to plain
2327
+ // first-letter capitalisation fails here.
2328
+ test('reduce over an initialism field emits the Go-initialism key (ID, not Id)', () => {
2329
+ const adapter = new GoTemplateAdapter()
2330
+ const ir = compileToIR(`
2331
+ function C({ items }: { items: { id: number }[] }) {
2332
+ return <div>{items.reduce((sum, x) => sum + x.id, 0)}</div>
2333
+ }
2334
+ export { C }
2335
+ `, adapter)
2336
+ const template = adapter.generate(ir).template ?? ''
2337
+ expect(template).toContain('bf_reduce .Items "+" "field" "ID" "numeric" "0"')
2338
+ expect(template).not.toContain('"Id"')
2339
+ })
2340
+ })
2341
+
2342
+ describe('GoTemplateAdapter - #1448 Tier C .flat(depth?)', () => {
2343
+ function emitFlat(expr: string): string {
2344
+ const adapter = new GoTemplateAdapter()
2345
+ const ir = compileToIR(`
2346
+ function C({ rows }: { rows: number[][] }) {
2347
+ return <div>{${expr}}</div>
2348
+ }
2349
+ export { C }
2350
+ `, adapter)
2351
+ return adapter.generate(ir).template ?? ''
2352
+ }
2353
+
2354
+ test('.flat() emits bf_flat with default depth 1', () => {
2355
+ expect(emitFlat('rows.flat()')).toContain('bf_flat .Rows 1')
2356
+ })
2357
+
2358
+ test('.flat(2) emits the explicit depth', () => {
2359
+ expect(emitFlat('rows.flat(2)')).toContain('bf_flat .Rows 2')
2360
+ })
2361
+
2362
+ test('.flat(Infinity) emits the -1 full-depth sentinel', () => {
2363
+ expect(emitFlat('rows.flat(Infinity)')).toContain('bf_flat .Rows -1')
2364
+ })
2365
+ })
2366
+
2367
+ describe('GoTemplateAdapter - #1448 Tier C .flatMap(field projection)', () => {
2368
+ function emitFlatMap(expr: string): string {
2369
+ const adapter = new GoTemplateAdapter()
2370
+ const ir = compileToIR(`
2371
+ function C({ rows }: { rows: { a: string; b: string; tags: string[] }[] }) {
2372
+ return <div>{${expr}}</div>
2373
+ }
2374
+ export { C }
2375
+ `, adapter)
2376
+ return adapter.generate(ir).template ?? ''
2377
+ }
2378
+
2379
+ test('.flatMap(i => i.field) emits bf_flat_map with the Go-capitalised field', () => {
2380
+ expect(emitFlatMap('rows.flatMap(i => i.tags).join(" ")')).toContain('bf_flat_map .Rows "field" "Tags"')
2381
+ })
2382
+
2383
+ test('.flatMap(i => i) emits the self projection', () => {
2384
+ expect(emitFlatMap('rows.flatMap(i => i).join(" ")')).toContain('bf_flat_map .Rows "self" ""')
2385
+ })
2386
+
2387
+ test('.flatMap(i => [i.a, i.b]) emits bf_flat_map_tuple with capitalised leaves', () => {
2388
+ expect(emitFlatMap('rows.flatMap(i => [i.a, i.b]).join(" ")')).toContain('bf_flat_map_tuple .Rows "field" "A" "field" "B"')
2389
+ })
2390
+
2391
+ test('tuple self + field leaves', () => {
2392
+ expect(emitFlatMap('rows.flatMap(i => [i, i.a]).join(" ")')).toContain('bf_flat_map_tuple .Rows "self" "" "field" "A"')
2393
+ })
2394
+ })
2395
+
2281
2396
  describe('GoTemplateAdapter - #1448 @client escape hatch (unsupported methods)', () => {
2282
2397
  // Compile a single expression placed in `<div>` text position, with
2283
2398
  // and without the directive, and return both the build errors and
@@ -2323,19 +2438,30 @@ export function C() {
2323
2438
  // Go fragment that must NOT survive into the template (the pre-fix
2324
2439
  // silent-footgun output for the string rows).
2325
2440
  const unsupported: Array<{ name: string; expr: string; badEmit: string }> = [
2326
- // Tier C array methods.
2327
- { name: 'reduce', expr: `items().reduce((a, b) => a + b.n, 0)`, badEmit: '.Reduce' },
2328
- { name: 'flatMap', expr: `items().flatMap(i => i.tags)`, badEmit: '.FlatMap' },
2329
- { name: 'flat', expr: `items().flat()`, badEmit: '.Flat' },
2441
+ // Tier C array methods. The arithmetic-fold `.reduce(fn, init)`
2442
+ // catalogue now lowers (pinned in the positive reduce-* fixtures);
2443
+ // the no-initial-value form stays refused JS throws on an empty
2444
+ // array there, which a template can't mirror.
2445
+ { name: 'reduce (no init)', expr: `items().reduce((a, b) => a + b.n)`, badEmit: '.Reduce' },
2446
+ // Self / field / field-tuple `.flatMap` now lowers (#1448 Tier C); a
2447
+ // tuple with a non-leaf element (here a string literal) stays refused.
2448
+ { name: 'flatMap (literal element)', expr: `items().flatMap(i => [i.name, "x"])`, badEmit: '.FlatMap' },
2449
+ // Lowered methods whose MEANINGFUL extra argument isn't lowered yet
2450
+ // (#1448): the `fromIndex` of `.includes`/`.indexOf`/`.lastIndexOf`
2451
+ // and the variadic `.concat`. The parser refuses these (silently
2452
+ // dropping the arg would change the result) rather than emitting a
2453
+ // render-crashing `.Includes` / `.Concat` field access. (The
2454
+ // zero-arg defaults `.join()`/`.slice()` and JS-ignored trailing
2455
+ // args like `.trim(1)` are accepted — pinned in the positive blocks.)
2456
+ { name: 'includes (2-arg fromIndex)', expr: `items().includes("a", 1)`, badEmit: '.Includes' },
2457
+ { name: 'concat (variadic)', expr: `items().concat(items(), items())`, badEmit: '.Concat' },
2330
2458
  // Tier B/C string methods — previously slipped through with no
2331
- // diagnostic; now gated by `UNSUPPORTED_METHODS`.
2332
- { name: 'split', expr: `name().split(",")`, badEmit: '.Name.Split' },
2333
- { name: 'startsWith', expr: `name().startsWith("a")`, badEmit: '.Name.StartsWith' },
2334
- { name: 'endsWith', expr: `name().endsWith("z")`, badEmit: '.Name.EndsWith' },
2335
- { name: 'replace', expr: `name().replace("a", "b")`, badEmit: '.Name.Replace' },
2336
- { name: 'repeat', expr: `name().repeat(3)`, badEmit: '.Name.Repeat' },
2337
- { name: 'padStart', expr: `name().padStart(5, "0")`, badEmit: '.Name.PadStart' },
2338
- { name: 'padEnd', expr: `name().padEnd(5, "0")`, badEmit: '.Name.PadEnd' },
2459
+ // diagnostic; now gated by `UNSUPPORTED_METHODS`. The full Tier B
2460
+ // string set (`split`, `startsWith`, `endsWith`, `replace`,
2461
+ // `repeat`, `padStart`, `padEnd`) has since landed its full-arity
2462
+ // lowering and moved to the positive fixture-pin block above. The
2463
+ // regex-pattern `replace` form is pinned separately below; `charAt`
2464
+ // is Tier C and stays refused entirely.
2339
2465
  { name: 'charAt', expr: `name().charAt(0)`, badEmit: '.Name.CharAt' },
2340
2466
  ]
2341
2467
  for (const { name, expr, badEmit } of unsupported) {
@@ -2354,23 +2480,76 @@ export function C() {
2354
2480
  })
2355
2481
  }
2356
2482
 
2483
+ // The diagnostic's `suggestion.message` is shaped by `isSupported`'s
2484
+ // `selfContained` flag: reasons that already spell out the fix are shown
2485
+ // as-is, while low-level parser reasons still get the adapter's generic
2486
+ // "Options" remediation appended so users never lose actionable steps.
2487
+ function suggestionFor(expr: string): string {
2488
+ const { errors } = emit(expr, false)
2489
+ const e = errors.find(e => e.code === 'BF101' || e.code === 'BF102')
2490
+ return e?.suggestion?.message ?? ''
2491
+ }
2492
+
2493
+ test('self-contained reason is shown without the generic Options block', () => {
2494
+ // Generic unsupported-method reason already carries the remedy.
2495
+ const msg = suggestionFor('items().reduce((a, b) => a + b.n)')
2496
+ expect(msg).toContain('no SSR')
2497
+ expect(msg).not.toContain('Options:')
2498
+ })
2499
+
2500
+ test('tailored forEach reason keeps its own guidance, no Options block', () => {
2501
+ const msg = suggestionFor('items().forEach(x => x)')
2502
+ expect(msg).toContain("'.map(")
2503
+ // The forEach message deliberately steers away from @client; the
2504
+ // generic Options block (which re-suggests it) must not be appended.
2505
+ expect(msg).not.toContain('Options:')
2506
+ expect(msg).not.toContain('@client')
2507
+ })
2508
+
2509
+ test('low-level reason still gets the actionable Options block appended', () => {
2510
+ // `typeof` has no structured remedy reason → users must keep the
2511
+ // generic next steps (regression guard for #1730 review).
2512
+ const msg = suggestionFor('typeof items()')
2513
+ expect(msg).toContain('Options:')
2514
+ expect(msg).toContain('@client')
2515
+ })
2516
+
2357
2517
  // Predicate-level use of an unsupported string method also fails the
2358
- // build loudly (intended): a `.filter(t => t.name.startsWith("a"))`
2518
+ // build loudly (intended): a `.filter(t => t.name.charAt(0) === "a")`
2359
2519
  // whose predicate calls one of the gated methods now refuses the whole
2360
2520
  // loop with BF101 (via the shared `isSupported` predicate gate in
2361
- // jsx-to-ir) rather than lowering to a broken `.StartsWith` inside the
2521
+ // jsx-to-ir) rather than lowering to a broken `.CharAt` inside the
2362
2522
  // range. Pinning this so the loud-failure contract can't silently
2363
- // regress back to the old emit-broken-template behaviour.
2523
+ // regress back to the old emit-broken-template behaviour. (`charAt`
2524
+ // is a Tier C method that stays refused — earlier this test used
2525
+ // `startsWith`, which has since landed its Tier B lowering.)
2364
2526
  test('unsupported string method inside a .filter() predicate raises BF101', () => {
2365
2527
  const result = compileJSX(`
2366
2528
  "use client"
2367
2529
  import { createSignal } from "@barefootjs/client"
2368
2530
  export function C() {
2369
2531
  const [items, setItems] = createSignal<{ name: string }[]>([])
2370
- return <ul>{items().filter(t => t.name.startsWith("a")).map(t => <li key={t.name}>{t.name}</li>)}</ul>
2532
+ return <ul>{items().filter(t => t.name.charAt(0) === "a").map(t => <li key={t.name}>{t.name}</li>)}</ul>
2533
+ }
2534
+ `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter() })
2535
+ expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
2536
+ })
2537
+
2538
+ // The string-pattern form of `.replace` lowers (#1448 Tier B), but
2539
+ // the regex-pattern form stays refused with BF101 — the Perl `s///`
2540
+ // vs Go `regexp.ReplaceAllString` flavour gap is the open design
2541
+ // question. Pinning the refusal so the string-form lowering can't
2542
+ // accidentally start emitting a broken `.Replace` for the regex form.
2543
+ test('regex-pattern .replace raises BF101 (string-pattern form is lowered)', () => {
2544
+ const result = compileJSX(`
2545
+ function C({ value }: { value: string }) {
2546
+ return <div>{value.replace(/o/g, "0")}</div>
2371
2547
  }
2548
+ export { C }
2372
2549
  `.trimStart(), 'test.tsx', { adapter: new GoTemplateAdapter() })
2373
2550
  expect(result.errors?.some(e => e.code === 'BF101')).toBe(true)
2551
+ const template = result.files?.find(f => f.path.endsWith('.tmpl'))?.content ?? ''
2552
+ expect(template).not.toContain('.Replace')
2374
2553
  })
2375
2554
 
2376
2555
  // Tier B `.sort` / `.toSorted` follow-ups still refused with BF021.
@@ -2404,7 +2583,9 @@ export function C() {
2404
2583
  // more `can't evaluate field …` crash), so we assert the build error
2405
2584
  // rather than a render crash. Skipped on hosts without Go.
2406
2585
  test('e2e: @client renders placeholder; bare is caught at build with BF101', async () => {
2407
- const bare = emit(`name().repeat(3)`, false)
2586
+ // Uses the Tier C `charAt` (still refused) — earlier this test used
2587
+ // `repeat`, which has since landed its #1448 Tier B lowering.
2588
+ const bare = emit(`name().charAt(0)`, false)
2408
2589
  expect(bare.errors.some(e => e.code === 'BF101')).toBe(true)
2409
2590
 
2410
2591
  try {
@@ -2414,7 +2595,7 @@ export function C() {
2414
2595
  import { createSignal } from "@barefootjs/client"
2415
2596
  export function C() {
2416
2597
  const [name, setName] = createSignal("hello")
2417
- return <div>{/* @client */ name().repeat(3)}</div>
2598
+ return <div>{/* @client */ name().charAt(0)}</div>
2418
2599
  }
2419
2600
  `.trimStart(),
2420
2601
  adapter: new GoTemplateAdapter(),
@@ -27,6 +27,9 @@ import type {
27
27
  ParsedExpr,
28
28
  ParsedStatement,
29
29
  SortComparator,
30
+ ReduceOp,
31
+ FlatDepth,
32
+ FlatMapOp,
30
33
  TemplatePart,
31
34
  IRIfStatement,
32
35
  IRProvider,
@@ -45,6 +48,7 @@ import {
45
48
  type IRNodeEmitter,
46
49
  type EmitIRNode,
47
50
  type AttrValueEmitter,
51
+ type SupportResult,
48
52
  isBooleanAttr,
49
53
  parseExpression,
50
54
  isSupported,
@@ -208,8 +212,56 @@ function emitBfSort(recv: string, c: SortComparator): string {
208
212
  return `bf_sort ${wrapIfMultiToken(recv)} ${groups.join(' ')}`
209
213
  }
210
214
 
215
+ /**
216
+ * Emit the `bf_reduce` call for a `.reduce(fn, init)` arithmetic fold
217
+ * (#1448 Tier C):
218
+ *
219
+ * bf_reduce <recv> "<op>" "<keyKind>" "<keyName>" "<type>" "<init>" "<direction>"
220
+ *
221
+ * op: "+" | "*"
222
+ * keyKind: "self" | "field"
223
+ * keyName: "" when keyKind=self; capitalised field name otherwise
224
+ * (matches the Go struct-field convention, mirroring
225
+ * `emitBfSort`)
226
+ * type: "numeric" | "string"
227
+ * init: the fold's start value — the numeric literal's text for a
228
+ * numeric fold, or the string literal's contents for a
229
+ * concat fold
230
+ * direction: "left" (reduce) | "right" (reduceRight)
231
+ *
232
+ * The runtime folds `init <op> key(item)` in `direction` order and
233
+ * returns the accumulated value (float64 for numeric, string for
234
+ * concat). The order is only observable for string concat — numeric
235
+ * sum / product commute.
236
+ */
237
+ function emitBfReduce(recv: string, op: ReduceOp, direction: 'left' | 'right'): string {
238
+ const keyName = op.key.kind === 'field' ? capitalize(op.key.field) : ''
239
+ // `op.init` is already the decoded seed value (canonical decimal for
240
+ // numeric folds — `strconv.ParseFloat`-safe; escape-free contents for
241
+ // concat folds). Pass it as a quoted operand the runtime interprets
242
+ // by `type`. `direction` is "left" (reduce) or "right" (reduceRight)
243
+ // — only observable for string concatenation; numeric folds are
244
+ // commutative.
245
+ return `bf_reduce ${wrapIfMultiToken(recv)} "${op.op}" "${op.key.kind}" "${keyName}" "${op.type}" "${escapeGoString(op.init)}" "${direction}"`
246
+ }
247
+
248
+ /** Escape a value for embedding in a Go-template double-quoted string. */
249
+ function escapeGoString(s: string): string {
250
+ return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
251
+ }
252
+
211
253
  function capitalize(s: string): string {
212
- return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
254
+ if (s.length === 0) return s
255
+ // Match the adapter's struct-field naming (`capitalizeFieldName`):
256
+ // a whole-word Go initialism uppercases entirely (`id` → `ID`,
257
+ // `url` → `URL`) so the `bf_sort` / `bf_reduce` reflect lookup
258
+ // resolves the generated exported field instead of silently folding
259
+ // a zero value. The class is fully initialised by the time any emit
260
+ // helper runs, so referencing the static set here is safe.
261
+ if (GoTemplateAdapter.GO_INITIALISMS.has(s.toLowerCase())) {
262
+ return s.toUpperCase()
263
+ }
264
+ return s[0].toUpperCase() + s.slice(1)
213
265
  }
214
266
 
215
267
  /**
@@ -239,6 +291,22 @@ interface PrimitiveSpec {
239
291
  emit: (args: string[]) => string
240
292
  }
241
293
 
294
+ // Generic remediation appended to BF101 / BF102 diagnostics whose reason
295
+ // doesn't already carry actionable next steps.
296
+ const GO_REMEDIATION_OPTIONS =
297
+ 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code'
298
+
299
+ // Build the `suggestion.message` for an unsupported expression/condition.
300
+ // A self-contained reason (it already spells out the fix — e.g. the
301
+ // pre-compute / @client hint or the tailored forEach message) is shown
302
+ // as-is; a low-level reason gets the generic options appended; with no
303
+ // reason at all we fall back to the options alone.
304
+ function buildUnsupportedSuggestion(support: SupportResult): string {
305
+ if (!support.reason) return GO_REMEDIATION_OPTIONS
306
+ if (support.selfContained) return support.reason
307
+ return `${support.reason}\n\n${GO_REMEDIATION_OPTIONS}`
308
+ }
309
+
242
310
  const GO_TEMPLATE_PRIMITIVES: Record<string, PrimitiveSpec> = {
243
311
  'JSON.stringify': { arity: 1, emit: (args) => `bf_json ${args[0]}` },
244
312
  'String': { arity: 1, emit: (args) => `bf_string ${args[0]}` },
@@ -2579,7 +2647,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
2579
2647
  private static GO_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
2580
2648
 
2581
2649
  /** Go common initialisms that should be fully uppercased (https://go.dev/wiki/CodeReviewComments#initialisms) */
2582
- private static GO_INITIALISMS = new Set([
2650
+ // Not `private`: the module-level `capitalize` helper reads this so
2651
+ // `bf_sort` / `bf_reduce` field projection matches `capitalizeFieldName`.
2652
+ static GO_INITIALISMS = new Set([
2583
2653
  'id', 'url', 'http', 'https', 'api', 'json', 'xml', 'html', 'css', 'sql',
2584
2654
  'ip', 'tcp', 'udp', 'dns', 'ssh', 'tls', 'ssl', 'uri', 'uid', 'uuid',
2585
2655
  'ascii', 'utf8', 'eof', 'grpc', 'rpc', 'cpu', 'gpu', 'ram', 'os',
@@ -3089,7 +3159,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3089
3159
  message: `Higher-order method '.${method}' shape cannot be lowered to a Go template action`,
3090
3160
  loc: this.makeLoc(),
3091
3161
  suggestion: {
3092
- message: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
3162
+ message: GO_REMEDIATION_OPTIONS,
3093
3163
  },
3094
3164
  })
3095
3165
  return `""`
@@ -3109,7 +3179,9 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3109
3179
  switch (method) {
3110
3180
  case 'join': {
3111
3181
  const obj = emit(object)
3112
- const sep = emit(args[0])
3182
+ // `.join()` defaults the separator to `,` (JS); any extra
3183
+ // argument is ignored. Only `args[0]` is read.
3184
+ const sep = args.length >= 1 ? emit(args[0]) : '","'
3113
3185
  // Both operands need paren-wrapping when they emit a
3114
3186
  // multi-token prefix-call form (e.g. `sep` lowering to
3115
3187
  // `bf_trim .Raw` would make Go template parse
@@ -3145,30 +3217,36 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3145
3217
  // `.at(i)` supports negative indices (`.at(-1)` → last
3146
3218
  // element). The Go `bf_at` helper was already registered in
3147
3219
  // the FuncMap for the runtime — this PR wires it to the JS
3148
- // method name at the adapter layer.
3220
+ // method name at the adapter layer. `.at()` with no argument is
3221
+ // `.at(0)` (the first element); extra arguments are ignored.
3149
3222
  const obj = emit(object)
3150
- const idx = emit(args[0])
3223
+ const idx = args.length >= 1 ? emit(args[0]) : '0'
3151
3224
  return `bf_at ${wrapIfMultiToken(obj)} ${wrapIfMultiToken(idx)}`
3152
3225
  }
3153
3226
  case 'concat': {
3154
3227
  // `.concat(other)` merges two arrays. The runtime helper
3155
3228
  // `bf_concat` reflects over both operands so callers can
3156
3229
  // mix `[]string` + `[]string` or `[]any` + `[]string` etc.
3157
- // without per-call-site type-juggling.
3230
+ // without per-call-site type-juggling. `.concat()` with no
3231
+ // argument is a shallow copy — indistinguishable from the
3232
+ // receiver in an SSR snapshot, so it lowers to the receiver.
3233
+ if (args.length === 0) {
3234
+ return emit(object)
3235
+ }
3158
3236
  const a = emit(object)
3159
3237
  const b = emit(args[0])
3160
3238
  return `bf_concat ${wrapIfMultiToken(a)} ${wrapIfMultiToken(b)}`
3161
3239
  }
3162
3240
  case 'slice': {
3163
- // `.slice(start)` / `.slice(start, end)` — both forms route
3164
- // through `bf_slice`. The runtime helper treats a `nil`
3165
- // `end` (the variadic-arg absence) as "to length", matching
3166
- // the JS semantic. Out-of-bounds indices clamp instead of
3167
- // panicking (also JS-compat); same with `start > end`
3168
- // returning an empty slice.
3241
+ // `.slice()` / `.slice(start)` / `.slice(start, end)` — route
3242
+ // through `bf_slice`. A missing `start` defaults to 0 (full
3243
+ // copy); the runtime helper treats an absent `end` as "to
3244
+ // length". Out-of-bounds indices clamp instead of panicking
3245
+ // (JS-compat); `start > end` returns an empty slice. JS ignores
3246
+ // a third+ argument, so only `args[0]` / `args[1]` are read.
3169
3247
  const recv = emit(object)
3170
- const start = emit(args[0])
3171
- if (args.length === 1) {
3248
+ const start = args.length >= 1 ? emit(args[0]) : '0'
3249
+ if (args.length <= 1) {
3172
3250
  return `bf_slice ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(start)}`
3173
3251
  }
3174
3252
  const end = emit(args[1])
@@ -3204,6 +3282,78 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3204
3282
  const recv = emit(object)
3205
3283
  return `bf_trim ${wrapIfMultiToken(recv)}`
3206
3284
  }
3285
+ case 'split': {
3286
+ // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
3287
+ // `[]any`. No separator → the whole string as a single element
3288
+ // (`bf_arr`). Otherwise `bf_split` (wraps `strings.Split`,
3289
+ // normalised to `[]any`); a second `limit` argument caps the
3290
+ // pieces. JS ignores a third+ argument. See #1448 Tier B.
3291
+ const recv = emit(object)
3292
+ if (args.length === 0) {
3293
+ return `bf_arr ${wrapIfMultiToken(recv)}`
3294
+ }
3295
+ const sep = emit(args[0])
3296
+ if (args.length === 1) {
3297
+ return `bf_split ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(sep)}`
3298
+ }
3299
+ const limit = emit(args[1])
3300
+ return `bf_split ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(sep)} ${wrapIfMultiToken(limit)}`
3301
+ }
3302
+ case 'startsWith':
3303
+ case 'endsWith': {
3304
+ // `.startsWith(prefix, position?)` / `.endsWith(suffix,
3305
+ // endPosition?)` — string → boolean via the `bf_starts_with` /
3306
+ // `bf_ends_with` helpers (`strings.HasPrefix` /
3307
+ // `strings.HasSuffix`). The optional second argument re-anchors
3308
+ // the test; JS ignores a third+ argument. See #1448 Tier B.
3309
+ const fn = method === 'startsWith' ? 'bf_starts_with' : 'bf_ends_with'
3310
+ const recv = emit(object)
3311
+ const arg = emit(args[0])
3312
+ const base = `${fn} ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(arg)}`
3313
+ if (args.length >= 2) {
3314
+ return `${base} ${wrapIfMultiToken(emit(args[1]))}`
3315
+ }
3316
+ return base
3317
+ }
3318
+ case 'replace': {
3319
+ // `.replace(old, new)` — string-pattern form, first occurrence
3320
+ // only, via the new `bf_replace` helper (`strings.Replace` with
3321
+ // n=1). The regex-pattern form is refused upstream at the
3322
+ // parser. See #1448 Tier B.
3323
+ const recv = emit(object)
3324
+ const oldS = emit(args[0])
3325
+ const newS = emit(args[1])
3326
+ return `bf_replace ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(oldS)} ${wrapIfMultiToken(newS)}`
3327
+ }
3328
+ case 'repeat': {
3329
+ // `.repeat(n)` — string repeated `n` times via the `bf_repeat`
3330
+ // helper. The helper clamps a negative count to "" instead of
3331
+ // letting `strings.Repeat` panic. Full JS arity: the no-argument
3332
+ // form is `repeat(0)` → ""; a second+ argument is ignored.
3333
+ // See #1448 Tier B.
3334
+ const recv = emit(object)
3335
+ const count = args.length === 0 ? '0' : emit(args[0])
3336
+ return `bf_repeat ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(count)}`
3337
+ }
3338
+ case 'padStart':
3339
+ case 'padEnd': {
3340
+ // `.padStart(target, pad?)` / `.padEnd(target, pad?)` — pad to
3341
+ // `target` runes with `pad` (default a single space, supplied
3342
+ // by the variadic helper when the arg is absent). Full JS arity:
3343
+ // the no-argument form is `padStart(0)` → the receiver
3344
+ // unchanged; a third+ argument is ignored. See #1448 Tier B.
3345
+ const fn = method === 'padStart' ? 'bf_pad_start' : 'bf_pad_end'
3346
+ const recv = emit(object)
3347
+ if (args.length === 0) {
3348
+ return `${fn} ${wrapIfMultiToken(recv)} 0`
3349
+ }
3350
+ const target = emit(args[0])
3351
+ if (args.length === 1) {
3352
+ return `${fn} ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(target)}`
3353
+ }
3354
+ const pad = emit(args[1])
3355
+ return `${fn} ${wrapIfMultiToken(recv)} ${wrapIfMultiToken(target)} ${wrapIfMultiToken(pad)}`
3356
+ }
3207
3357
  default: {
3208
3358
  const _exhaustive: never = method
3209
3359
  throw new Error(`Go arrayMethod: unhandled ArrayMethod '${(_exhaustive as string)}'`)
@@ -3231,6 +3381,51 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3231
3381
  return emitBfSort(emit(object), comparator)
3232
3382
  }
3233
3383
 
3384
+ reduceMethod(
3385
+ method: 'reduce' | 'reduceRight',
3386
+ object: ParsedExpr,
3387
+ reduceOp: ReduceOp,
3388
+ emit: (e: ParsedExpr) => string,
3389
+ ): string {
3390
+ // `.reduce(fn, init)` / `.reduceRight(fn, init)` arithmetic-fold
3391
+ // lowering (#1448 Tier C). The structured `ReduceOp` (op / key /
3392
+ // type / init) plus the fold direction feed the `bf_reduce` runtime
3393
+ // helper, which folds the receiver into a scalar.
3394
+ const direction = method === 'reduceRight' ? 'right' : 'left'
3395
+ return emitBfReduce(emit(object), reduceOp, direction)
3396
+ }
3397
+
3398
+ flatMethod(object: ParsedExpr, depth: FlatDepth, emit: (e: ParsedExpr) => string): string {
3399
+ // `.flat(depth?)` → `bf_flat <recv> <depth>`. The `Infinity` form
3400
+ // lowers to the `-1` sentinel (flatten fully); a finite depth flattens
3401
+ // that many levels (`0` = shallow copy). See packages/adapter-go-
3402
+ // template/runtime/bf.go.
3403
+ const d = depth === 'infinity' ? -1 : depth
3404
+ return `bf_flat ${wrapIfMultiToken(emit(object))} ${d}`
3405
+ }
3406
+
3407
+ flatMapMethod(object: ParsedExpr, op: FlatMapOp, emit: (e: ParsedExpr) => string): string {
3408
+ const recv = wrapIfMultiToken(emit(object))
3409
+ const proj = op.projection
3410
+ // Tuple projection `i => [i.a, i.b]` → `bf_flat_map_tuple <recv>
3411
+ // "<kind>" "<name>" ...` (one quoted pair per leaf). flat(1) removes
3412
+ // only the literal's wrapper, so the runtime appends each leaf verbatim.
3413
+ if (proj.kind === 'tuple') {
3414
+ const pairs = proj.elements
3415
+ .map(l => (l.kind === 'self' ? `"self" ""` : `"field" "${capitalize(l.field)}"`))
3416
+ .join(' ')
3417
+ return `bf_flat_map_tuple ${recv} ${pairs}`
3418
+ }
3419
+ // Scalar `.flatMap(i => i)` / `.flatMap(i => i.field)` → `bf_flat_map
3420
+ // <recv> "<kind>" "<name>"`. The runtime projects each item then
3421
+ // flattens one level. The field name uses the Go struct-field
3422
+ // capitalisation, matching `bf_reduce` / `bf_sort`.
3423
+ if (proj.kind === 'self') {
3424
+ return `bf_flat_map ${recv} "self" ""`
3425
+ }
3426
+ return `bf_flat_map ${recv} "field" "${capitalize(proj.field)}"`
3427
+ }
3428
+
3234
3429
  unsupported(raw: string, _reason: string): string {
3235
3430
  // Should not happen if `isSupported` was checked at parse time.
3236
3431
  return `[UNSUPPORTED: ${raw}]`
@@ -3966,9 +4161,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
3966
4161
  message: `Expression not supported: ${trimmed}`,
3967
4162
  loc: this.makeLoc(),
3968
4163
  suggestion: {
3969
- message: support.reason
3970
- ? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
3971
- : 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
4164
+ message: buildUnsupportedSuggestion(support),
3972
4165
  },
3973
4166
  })
3974
4167
  // Return empty string - Go template comments must be separate actions
@@ -4006,7 +4199,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4006
4199
  message: `Complex predicate in else-if is not supported: ${altIfStmt.condition}`,
4007
4200
  loc: this.makeLoc(),
4008
4201
  suggestion: {
4009
- message: 'Options:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code',
4202
+ message: GO_REMEDIATION_OPTIONS,
4010
4203
  },
4011
4204
  })
4012
4205
  }
@@ -4103,9 +4296,7 @@ export class GoTemplateAdapter extends BaseAdapter implements ParsedExprEmitter,
4103
4296
  message: `Condition not supported: ${trimmed}`,
4104
4297
  loc: this.makeLoc(),
4105
4298
  suggestion: {
4106
- message: support.reason
4107
- ? `${support.reason}\n\nOptions:\n1. Use @client directive for client-side evaluation\n2. Pre-compute the value in Go code`
4108
- : 'Expression contains unsupported syntax',
4299
+ message: buildUnsupportedSuggestion(support),
4109
4300
  },
4110
4301
  })
4111
4302
  // Return false - Go template comments must be separate actions