@barefootjs/mojolicious 0.6.1 → 0.8.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.
@@ -4,8 +4,10 @@
4
4
  * Generates Mojolicious EP template files (.html.ep) from BarefootJS IR.
5
5
  */
6
6
 
7
+ import ts from 'typescript'
7
8
  import type {
8
9
  ComponentIR,
10
+ IRMetadata,
9
11
  IRNode,
10
12
  IRElement,
11
13
  IRText,
@@ -117,6 +119,32 @@ export interface MojoAdapterOptions {
117
119
  barefootJsPath?: string
118
120
  }
119
121
 
122
+ /**
123
+ * Parse a const initializer's source text. Returns the unescaped string
124
+ * value when the whole initializer is a single string literal (or a
125
+ * no-substitution template literal), else `null`. Uses the TS parser so
126
+ * escapes/quotes resolve exactly as JS would, matching the value the Hono
127
+ * reference inlines at runtime.
128
+ */
129
+ function parsePureStringLiteral(source: string): string | null {
130
+ const sf = ts.createSourceFile(
131
+ '__const.ts',
132
+ `const __x = (${source});`,
133
+ ts.ScriptTarget.Latest,
134
+ /*setParentNodes*/ false,
135
+ )
136
+ const stmt = sf.statements[0]
137
+ if (!stmt || !ts.isVariableStatement(stmt)) return null
138
+ const decl = stmt.declarationList.declarations[0]
139
+ let init = decl?.initializer
140
+ while (init && ts.isParenthesizedExpression(init)) init = init.expression
141
+ if (!init) return null
142
+ if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init)) {
143
+ return init.text
144
+ }
145
+ return null
146
+ }
147
+
120
148
  export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRenderCtx> {
121
149
  name = 'mojolicious'
122
150
  extension = '.html.ep'
@@ -159,6 +187,43 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
159
187
  * true — selecting the string operator from the operand's type avoids that.
160
188
  */
161
189
  private stringValueNames: Set<string> = new Set()
190
+ /**
191
+ * Module-scope pure string-literal constants (`const X = 'literal'` at
192
+ * file top-level), keyed by name → resolved literal value. Populated at
193
+ * `generate()` entry from `ir.metadata.localConstants`. When an identifier
194
+ * in an expression resolves to one of these, the adapter inlines the
195
+ * literal instead of emitting `$X` against a stash variable that is never
196
+ * bound (a module const isn't a prop, signal, or local — the value would
197
+ * render empty). Hono inlines it for free; this restores parity. Only
198
+ * module-scope pure string literals qualify (see `collectModuleStringConsts`).
199
+ */
200
+ private moduleStringConsts: Map<string, string> = new Map()
201
+ /**
202
+ * Names currently bound by an enclosing loop body — the `my $<param>` and
203
+ * `my $<index>` bindings `renderLoop` introduces — ref-counted so nested
204
+ * loops compose. `resolveModuleStringConst` consults this so a loop
205
+ * variable whose name happens to match a module string const is NOT
206
+ * inlined as the const literal (mirrors the Go adapter's loop-param /
207
+ * loop-var shadowing guards). (#1749 review)
208
+ */
209
+ private loopBoundNames: Map<string, number> = new Map()
210
+ /**
211
+ * Prop names whose value is `undef` in the template body when the caller
212
+ * omits them — so a bare-reference attribute should be dropped rather
213
+ * than rendered as `attr=""`. The actual population criterion (see
214
+ * `generate()`) is: NO destructure default (`defaultValue === undefined`)
215
+ * AND non-rest (`!isRest`) AND non-primitive type (`type.kind !==
216
+ * 'primitive'`). It deliberately does NOT consult `p.optional`: the
217
+ * analyzer derives `optional` from the presence of a default initializer,
218
+ * not the `?` token, so it's not the right witness here. Excluding
219
+ * concrete primitives (`string`/`number`/`boolean`) mirrors the Go
220
+ * adapter's scope, which guards only `interface{}` (nillable) fields.
221
+ * Used by `elementAttrEmitter.emitExpression` to guard such an attribute
222
+ * with a Perl `defined $x` check (`<textarea>` omits `rows`), matching
223
+ * Hono's nullish-attribute omission. Concrete/defaulted props are
224
+ * excluded and always emit unconditionally.
225
+ */
226
+ private nullableOptionalProps: Set<string> = new Set()
162
227
 
163
228
  constructor(options: MojoAdapterOptions = {}) {
164
229
  super()
@@ -172,6 +237,31 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
172
237
  this.componentName = ir.metadata.componentName
173
238
  this.propsObjectName = ir.metadata.propsObjectName ?? null
174
239
  this.propsParams = ir.metadata.propsParams.map(p => ({ name: p.name }))
240
+ // No-destructure-default props → `undef` when the caller omits them
241
+ // → guard their bare-reference attribute emission with Perl `defined`
242
+ // so the attribute drops instead of rendering `attr=""` (Hono-style
243
+ // nullish omission). A prop WITH a destructure default (`value = ''`)
244
+ // is never `undef` in the body and must stay unconditional, so it is
245
+ // excluded. This mirrors the Go adapter's nillable-field guard: there
246
+ // the witness is the resolved `interface{}` field type; here it is
247
+ // the absence of a default (the analyzer reports `rows` — a
248
+ // `TextareaHTMLAttributes` member destructured without a default — as
249
+ // no-default, `type.kind: 'unknown'`).
250
+ // Excludes concrete-primitive types (`string`/`number`/`boolean`)
251
+ // to match the Go adapter's scope, which guards only `interface{}`
252
+ // (nillable) fields and leaves concrete fields unconditional. So a
253
+ // required, no-default `string` prop still emits `attr=""` like Hono,
254
+ // and only nillable (`unknown`/object/array) no-default props guard.
255
+ this.nullableOptionalProps = new Set(
256
+ ir.metadata.propsParams
257
+ .filter(
258
+ p =>
259
+ p.defaultValue === undefined &&
260
+ !p.isRest &&
261
+ p.type?.kind !== 'primitive',
262
+ )
263
+ .map(p => p.name),
264
+ )
175
265
  // Record string-typed signals and props so equality comparisons against
176
266
  // them lower to `eq`/`ne` (#1672). A signal is string-typed when its
177
267
  // inferred type is `string` (the analyzer infers this from a string-literal
@@ -186,6 +276,8 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
186
276
  for (const p of ir.metadata.propsParams) {
187
277
  if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
188
278
  }
279
+ this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
280
+ this.loopBoundNames.clear()
189
281
  this.errors = []
190
282
  this.childrenCaptureCounter = 0
191
283
 
@@ -238,6 +330,41 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
238
330
  }
239
331
  }
240
332
 
333
+ /**
334
+ * Build the module pure-string-const map from the IR's localConstants.
335
+ * A const qualifies only when it is module-scope (`isModule`) and its
336
+ * initializer parses to a single string literal (`ts.StringLiteral` or
337
+ * `ts.NoSubstitutionTemplateLiteral`). Template literals with `${}`,
338
+ * numeric/object initializers, `Record<T,string>` maps, memos, and
339
+ * signals are all excluded — only a pure compile-time string can be
340
+ * inlined byte-for-byte.
341
+ */
342
+ private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
343
+ const map = new Map<string, string>()
344
+ for (const c of constants ?? []) {
345
+ if (!c.isModule) continue
346
+ if (c.value === undefined) continue
347
+ const literal = parsePureStringLiteral(c.value)
348
+ if (literal !== null) map.set(c.name, literal)
349
+ }
350
+ return map
351
+ }
352
+
353
+ /**
354
+ * Resolve an identifier to its inlined Perl single-quoted string literal
355
+ * when it names a module pure-string const, else `null` (the caller then
356
+ * falls back to its normal `$name` stash lowering). Returns the Perl
357
+ * literal form `'<escaped>'` ready to drop into an expression.
358
+ */
359
+ resolveModuleStringConst(name: string): string | null {
360
+ // A loop body introduces `my $<param>` / `my $<index>` bindings that
361
+ // shadow a module const of the same name — never inline inside one.
362
+ if (this.loopBoundNames.has(name)) return null
363
+ const value = this.moduleStringConsts.get(name)
364
+ if (value === undefined) return null
365
+ return `'${value.replace(/[\\']/g, m => `\\${m}`)}'`
366
+ }
367
+
241
368
  // ===========================================================================
242
369
  // Script Registration
243
370
  // ===========================================================================
@@ -587,6 +714,16 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
587
714
  const indexVar = loop.iterationShape === 'keys'
588
715
  ? `$${param}`
589
716
  : loop.index ? `$${loop.index}` : '$_i'
717
+ // Names this loop binds in body scope. Guard module-const inlining for
718
+ // the whole body (children + key + filter) so a same-named loop variable
719
+ // isn't replaced by the const literal (#1749 review). Ref-counted for
720
+ // nested loops; released after the body lines are assembled below.
721
+ const loopBound = loop.iterationShape === 'keys'
722
+ ? [param]
723
+ : [param, loop.index ?? '_i']
724
+ for (const n of loopBound) {
725
+ this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
726
+ }
590
727
  const prevInLoop = this.inLoop
591
728
  this.inLoop = true
592
729
  const renderedChildren = this.renderChildren(loop.children)
@@ -644,6 +781,13 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
644
781
  lines.push(children)
645
782
  }
646
783
 
784
+ // Body fully rendered — release the loop-bound names.
785
+ for (const n of loopBound) {
786
+ const c = (this.loopBoundNames.get(n) ?? 1) - 1
787
+ if (c <= 0) this.loopBoundNames.delete(n)
788
+ else this.loopBoundNames.set(n, c)
789
+ }
790
+
647
791
  lines.push(`% }`)
648
792
  lines.push(`<%== bf->comment("/loop:${loop.markerId}") %>`)
649
793
 
@@ -833,6 +977,32 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
833
977
  if (this.refuseUnsupportedAttrExpression(value.expr, name)) {
834
978
  return ''
835
979
  }
980
+ // Hono-style nullish-attribute omission (#textarea rows): when the
981
+ // attribute value is a BARE reference to an optional, no-default
982
+ // prop (which is `undef` when the caller omits it), guard the
983
+ // attribute with Perl `defined` so it DROPS rather than rendering
984
+ // `attr=""`. The guarded body reuses the exact normal emission, so
985
+ // value escaping (`<%= ... %>`) is unchanged; only the presence is
986
+ // conditional. The `% if`/`% end` line directives surround the
987
+ // attribute inline — the conformance comparator collapses the
988
+ // resulting whitespace, exactly like the existing boolean-attr and
989
+ // hydration-marker patterns. Scope is deliberately narrow (bare
990
+ // identifiers resolving to an optional-no-default prop) so member
991
+ // exprs, calls, concrete/defaulted props, and boolean attrs are
992
+ // unaffected and still emit unconditionally.
993
+ const bareId = value.expr.trim()
994
+ if (
995
+ !isBooleanAttr(name) &&
996
+ !value.presenceOrUndefined &&
997
+ this.nullableOptionalProps.has(bareId)
998
+ ) {
999
+ const perl = this.convertExpressionToPerl(value.expr)
1000
+ const body =
1001
+ isBooleanResultExpr(value.expr) || isAriaBooleanAttr(name)
1002
+ ? `${name}="<%= bf->bool_str(${perl}) %>"`
1003
+ : `${name}="<%= ${perl} %>"`
1004
+ return `<% if (defined ${perl}) { %>${body}<% } %>`
1005
+ }
836
1006
  if (isBooleanAttr(name) || value.presenceOrUndefined) {
837
1007
  // Boolean attributes: render conditionally (present or absent).
838
1008
  return `<%= ${this.convertExpressionToPerl(value.expr)} ? '${name}' : '' %>`
@@ -906,6 +1076,17 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
906
1076
  )
907
1077
  return `<%== bf->spread_attrs({${entries.join(', ')}}) %>`
908
1078
  }
1079
+ // Conditional inline-object spread:
1080
+ // `{...(COND ? { 'aria-describedby': describedBy } : {})}`
1081
+ // Emit a Perl inline ternary of hashrefs — Perl truthiness
1082
+ // handles the condition for free, and the falsy `{}` branch
1083
+ // OMITS the key (`bf->spread_attrs` does NOT filter empty
1084
+ // strings, so we cannot always-include it). Mirrors the Go
1085
+ // adapter's IIFE-of-maps lowering (#textarea).
1086
+ const ternaryHashref = this.conditionalSpreadToPerl(trimmed)
1087
+ if (ternaryHashref !== null) {
1088
+ return `<%== bf->spread_attrs(${ternaryHashref}) %>`
1089
+ }
909
1090
  const perlExpr = this.convertExpressionToPerl(value.expr)
910
1091
  return `<%== bf->spread_attrs(${perlExpr}) %>`
911
1092
  },
@@ -1192,6 +1373,77 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
1192
1373
  }
1193
1374
 
1194
1375
 
1376
+ /**
1377
+ * Lower a conditional inline-object spread expression
1378
+ * `(COND ? { 'aria-describedby': describedBy } : {})`
1379
+ * (either branch possibly `{}`) into a Perl inline ternary of
1380
+ * hashrefs for `bf->spread_attrs`:
1381
+ * `$describedBy ? { 'aria-describedby' => $describedBy } : {}`
1382
+ *
1383
+ * The condition is translated via `convertExpressionToPerl` (a bare
1384
+ * prop ident becomes `$describedBy`; Perl truthiness handles the
1385
+ * test). Object literals become Perl hashrefs with `=>`; string-
1386
+ * literal keys are quoted, values resolve via `convertExpressionToPerl`.
1387
+ *
1388
+ * Returns null when the expression is NOT this shape, or when a part
1389
+ * can't be faithfully lowered (non-static key, etc.) so the caller
1390
+ * falls back to the standard `convertExpressionToPerl` path (which
1391
+ * records BF101). Scoped strictly to ternary-of-object-literals so no
1392
+ * other spread shape regresses.
1393
+ */
1394
+ private conditionalSpreadToPerl(expr: string): string | null {
1395
+ const sf = ts.createSourceFile('__spread.ts', `(${expr})`, ts.ScriptTarget.Latest, true)
1396
+ if (sf.statements.length !== 1) return null
1397
+ const stmt = sf.statements[0]
1398
+ if (!ts.isExpressionStatement(stmt)) return null
1399
+ let node: ts.Expression = stmt.expression
1400
+ while (ts.isParenthesizedExpression(node)) node = node.expression
1401
+ if (!ts.isConditionalExpression(node)) return null
1402
+ const unwrap = (e: ts.Expression): ts.Expression => {
1403
+ let n = e
1404
+ while (ts.isParenthesizedExpression(n)) n = n.expression
1405
+ return n
1406
+ }
1407
+ const whenTrue = unwrap(node.whenTrue)
1408
+ const whenFalse = unwrap(node.whenFalse)
1409
+ if (!ts.isObjectLiteralExpression(whenTrue) || !ts.isObjectLiteralExpression(whenFalse)) {
1410
+ return null
1411
+ }
1412
+ const condPerl = this.convertExpressionToPerl(node.condition.getText(sf))
1413
+ const truePerl = this.objectLiteralToPerlHashref(whenTrue, sf)
1414
+ const falsePerl = this.objectLiteralToPerlHashref(whenFalse, sf)
1415
+ if (truePerl === null || falsePerl === null) return null
1416
+ return `${condPerl} ? ${truePerl} : ${falsePerl}`
1417
+ }
1418
+
1419
+ /**
1420
+ * Convert a static object literal into a Perl hashref string for a
1421
+ * conditional spread. Only static string/identifier keys are allowed;
1422
+ * values resolve via `convertExpressionToPerl`. Returns null for any
1423
+ * computed/spread/dynamic key. Empty object → `{}`.
1424
+ */
1425
+ private objectLiteralToPerlHashref(
1426
+ obj: ts.ObjectLiteralExpression,
1427
+ sf: ts.SourceFile,
1428
+ ): string | null {
1429
+ const entries: string[] = []
1430
+ for (const prop of obj.properties) {
1431
+ if (!ts.isPropertyAssignment(prop)) return null
1432
+ let key: string
1433
+ if (ts.isIdentifier(prop.name)) {
1434
+ key = prop.name.text
1435
+ } else if (ts.isStringLiteral(prop.name) || ts.isNoSubstitutionTemplateLiteral(prop.name)) {
1436
+ key = prop.name.text
1437
+ } else {
1438
+ return null
1439
+ }
1440
+ const valPerl = this.convertExpressionToPerl(prop.initializer.getText(sf))
1441
+ entries.push(`'${key.replace(/'/g, "\\'")}' => ${valPerl}`)
1442
+ }
1443
+ return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`
1444
+ }
1445
+
1446
+
1195
1447
  private convertExpressionToPerl(expr: string): string {
1196
1448
  // Parse-first lowering — parity with the Go adapter's
1197
1449
  // `convertExpressionToGo`. Parse the JS expression once, gate it on
@@ -1307,7 +1559,7 @@ function renderArrayMethod(
1307
1559
  // runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
1308
1560
  // and dispatches: ARRAY ref scans the list with `eq`, scalar
1309
1561
  // falls back to `index(..., ...) != -1`. Helper lives in
1310
- // packages/adapter-mojolicious/lib/BarefootJS.pm.
1562
+ // packages/adapter-perl/lib/BarefootJS.pm.
1311
1563
  //
1312
1564
  // The `bf->` (no `$`) form matches every other helper emit —
1313
1565
  // in real Mojolicious `bf` is a controller helper; the
@@ -1817,6 +2069,11 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1817
2069
  constructor(private readonly adapter: MojoAdapter) {}
1818
2070
 
1819
2071
  identifier(name: string): string {
2072
+ // Module pure-string const (e.g. `const baseClasses = '...'` used in a
2073
+ // className template literal): inline the literal value rather than emit
2074
+ // `$baseClasses` against a stash variable that is never bound.
2075
+ const inlined = this.adapter.resolveModuleStringConst(name)
2076
+ if (inlined !== null) return inlined
1820
2077
  return `$${name}`
1821
2078
  }
1822
2079
 
@@ -11,7 +11,12 @@ import { mkdir, rm } from 'node:fs/promises'
11
11
  import { resolve } from 'node:path'
12
12
 
13
13
  const RENDER_TEMP_DIR = resolve(import.meta.dir, '../.render-temp')
14
+ // Mojo-specific lib (BarefootJS::Backend::Mojo + the plugin) lives in this
15
+ // package; the engine-agnostic core (BarefootJS.pm) moved to @barefootjs/perl.
16
+ // Both dirs must be on the render script's @INC so `use BarefootJS` and
17
+ // `use BarefootJS::Backend::Mojo` resolve.
14
18
  const LIB_DIR = resolve(import.meta.dir, '../lib')
19
+ const PERL_CORE_LIB_DIR = resolve(import.meta.dir, '../../adapter-perl/lib')
15
20
 
16
21
  export class PerlNotAvailableError extends Error {
17
22
  constructor(message: string) {
@@ -216,7 +221,7 @@ use strict;
216
221
  use warnings;
217
222
  use utf8;
218
223
 
219
- use lib '${LIB_DIR}';
224
+ use lib '${LIB_DIR}', '${PERL_CORE_LIB_DIR}';
220
225
  use Mojolicious;
221
226
  use Mojo::Template;
222
227
  # Boolean values in spread bags arrive as Mojo::JSON::true /
@@ -428,6 +433,25 @@ function buildPerlProps(
428
433
  entries.push(`${param.name} => undef`)
429
434
  }
430
435
 
436
+ // A `{...props}` rest spread means props that aren't declared named
437
+ // params flow through the rest bag (`bf->spread_attrs($<restPropsName>)`),
438
+ // not their own top-level template var. Route them into the bag hashref so
439
+ // a fixture passing e.g. `placeholder` to `Input` (whose declared params
440
+ // are `className` / `type`) renders `placeholder="..."` via the spread
441
+ // rather than silently dropping it into an unused `my $placeholder`. (#1467
442
+ // Phase 2b — mirrors the Go harness fix in the sibling `test-render.ts`.)
443
+ const restPropsName = ir.metadata.restPropsName
444
+ const declaredParams = new Set(ir.metadata.propsParams.map(p => p.name))
445
+ const restBagEntries: Array<[string, unknown]> = []
446
+ if (restPropsName && props) {
447
+ for (const [key, value] of Object.entries(props)) {
448
+ if (key.startsWith('__')) continue
449
+ if (key === restPropsName || declaredParams.has(key)) continue
450
+ restBagEntries.push([key, value])
451
+ }
452
+ }
453
+ const routedKeys = new Set(restBagEntries.map(([k]) => k))
454
+
431
455
  // (#1407 follow-up) Default the rest-binding identifier to an
432
456
  // empty hashref so `bf->spread_attrs($extras)` in the generated
433
457
  // Mojo template doesn't trip Perl's strict-mode "Global symbol
@@ -436,13 +460,16 @@ function buildPerlProps(
436
460
  // the COMPILE path on Go, where the bag plumbing matters; the
437
461
  // runtime is a no-op when the caller leaves the bag unset, which
438
462
  // mirrors the empty-spread case on every adapter).
439
- if (ir.metadata.restPropsName && !(props && ir.metadata.restPropsName in props)) {
440
- entries.push(`${ir.metadata.restPropsName} => {}`)
463
+ // When the fixture supplied undeclared props, seed the bag with those
464
+ // routed entries instead of an empty hashref.
465
+ if (restPropsName && !(props && restPropsName in props)) {
466
+ entries.push(`${restPropsName} => ${toPerlLiteral(Object.fromEntries(restBagEntries))}`)
441
467
  }
442
468
 
443
469
  // Add user props
444
470
  if (props) {
445
471
  for (const [key, value] of Object.entries(props)) {
472
+ if (routedKeys.has(key)) continue
446
473
  if (typeof value === 'string') {
447
474
  entries.push(`${key} => '${value.replace(/'/g, "\\'")}'`)
448
475
  } else if (typeof value === 'number') {