@barefootjs/mojolicious 0.6.1 → 0.7.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,26 @@ 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()
162
210
 
163
211
  constructor(options: MojoAdapterOptions = {}) {
164
212
  super()
@@ -186,6 +234,8 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
186
234
  for (const p of ir.metadata.propsParams) {
187
235
  if (isStringTypeInfo(p.type)) this.stringValueNames.add(p.name)
188
236
  }
237
+ this.moduleStringConsts = this.collectModuleStringConsts(ir.metadata.localConstants)
238
+ this.loopBoundNames.clear()
189
239
  this.errors = []
190
240
  this.childrenCaptureCounter = 0
191
241
 
@@ -238,6 +288,41 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
238
288
  }
239
289
  }
240
290
 
291
+ /**
292
+ * Build the module pure-string-const map from the IR's localConstants.
293
+ * A const qualifies only when it is module-scope (`isModule`) and its
294
+ * initializer parses to a single string literal (`ts.StringLiteral` or
295
+ * `ts.NoSubstitutionTemplateLiteral`). Template literals with `${}`,
296
+ * numeric/object initializers, `Record<T,string>` maps, memos, and
297
+ * signals are all excluded — only a pure compile-time string can be
298
+ * inlined byte-for-byte.
299
+ */
300
+ private collectModuleStringConsts(constants: IRMetadata['localConstants']): Map<string, string> {
301
+ const map = new Map<string, string>()
302
+ for (const c of constants ?? []) {
303
+ if (!c.isModule) continue
304
+ if (c.value === undefined) continue
305
+ const literal = parsePureStringLiteral(c.value)
306
+ if (literal !== null) map.set(c.name, literal)
307
+ }
308
+ return map
309
+ }
310
+
311
+ /**
312
+ * Resolve an identifier to its inlined Perl single-quoted string literal
313
+ * when it names a module pure-string const, else `null` (the caller then
314
+ * falls back to its normal `$name` stash lowering). Returns the Perl
315
+ * literal form `'<escaped>'` ready to drop into an expression.
316
+ */
317
+ resolveModuleStringConst(name: string): string | null {
318
+ // A loop body introduces `my $<param>` / `my $<index>` bindings that
319
+ // shadow a module const of the same name — never inline inside one.
320
+ if (this.loopBoundNames.has(name)) return null
321
+ const value = this.moduleStringConsts.get(name)
322
+ if (value === undefined) return null
323
+ return `'${value.replace(/[\\']/g, m => `\\${m}`)}'`
324
+ }
325
+
241
326
  // ===========================================================================
242
327
  // Script Registration
243
328
  // ===========================================================================
@@ -587,6 +672,16 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
587
672
  const indexVar = loop.iterationShape === 'keys'
588
673
  ? `$${param}`
589
674
  : loop.index ? `$${loop.index}` : '$_i'
675
+ // Names this loop binds in body scope. Guard module-const inlining for
676
+ // the whole body (children + key + filter) so a same-named loop variable
677
+ // isn't replaced by the const literal (#1749 review). Ref-counted for
678
+ // nested loops; released after the body lines are assembled below.
679
+ const loopBound = loop.iterationShape === 'keys'
680
+ ? [param]
681
+ : [param, loop.index ?? '_i']
682
+ for (const n of loopBound) {
683
+ this.loopBoundNames.set(n, (this.loopBoundNames.get(n) ?? 0) + 1)
684
+ }
590
685
  const prevInLoop = this.inLoop
591
686
  this.inLoop = true
592
687
  const renderedChildren = this.renderChildren(loop.children)
@@ -644,6 +739,13 @@ export class MojoAdapter extends BaseAdapter implements IRNodeEmitter<MojoRender
644
739
  lines.push(children)
645
740
  }
646
741
 
742
+ // Body fully rendered — release the loop-bound names.
743
+ for (const n of loopBound) {
744
+ const c = (this.loopBoundNames.get(n) ?? 1) - 1
745
+ if (c <= 0) this.loopBoundNames.delete(n)
746
+ else this.loopBoundNames.set(n, c)
747
+ }
748
+
647
749
  lines.push(`% }`)
648
750
  lines.push(`<%== bf->comment("/loop:${loop.markerId}") %>`)
649
751
 
@@ -1307,7 +1409,7 @@ function renderArrayMethod(
1307
1409
  // runtime's `bf->includes($recv, $elem)` inspects `ref($recv)`
1308
1410
  // and dispatches: ARRAY ref scans the list with `eq`, scalar
1309
1411
  // falls back to `index(..., ...) != -1`. Helper lives in
1310
- // packages/adapter-mojolicious/lib/BarefootJS.pm.
1412
+ // packages/adapter-perl/lib/BarefootJS.pm.
1311
1413
  //
1312
1414
  // The `bf->` (no `$`) form matches every other helper emit —
1313
1415
  // in real Mojolicious `bf` is a controller helper; the
@@ -1817,6 +1919,11 @@ class MojoTopLevelEmitter implements ParsedExprEmitter {
1817
1919
  constructor(private readonly adapter: MojoAdapter) {}
1818
1920
 
1819
1921
  identifier(name: string): string {
1922
+ // Module pure-string const (e.g. `const baseClasses = '...'` used in a
1923
+ // className template literal): inline the literal value rather than emit
1924
+ // `$baseClasses` against a stash variable that is never bound.
1925
+ const inlined = this.adapter.resolveModuleStringConst(name)
1926
+ if (inlined !== null) return inlined
1820
1927
  return `$${name}`
1821
1928
  }
1822
1929
 
@@ -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') {