@barefootjs/pebble
v0.39.2
Published
Pebble adapter for BarefootJS — compiles IR to .peb templates and ships the Java BarefootJS rendering runtime; runs under any JVM web framework (Spring Boot, Ktor, etc.)
Maintainers
Readme
@barefootjs/pebble
Pebble adapter for BarefootJS: compiles the BarefootJS IR (JSX → IR, see
spec/compiler.md) into .peb template files plus the client JS bundle
every other adapter produces, and ships a Java rendering runtime (java/)
that renders those templates through
Pebble — no framework is required (Spring
Boot, Ktor, plain Servlet apps all work the same way).
Status: Phase 3a + 3b landed (#2101) — the Java runtime (java/) now
exists: the bf.* helper surface, the ParsedExpr evaluator, a CLI entry
point (Main), and a custom set tag extension (ext/) supporting
{% set NAME %}...{% endset %} block-capture, all tested against the
shared packages/adapter-tests/vectors/ corpus (396/396 helper-vector
cases, 102/102 evaluator cases, zero pinned divergences) plus direct
engine-level and end-to-end tag-parsing tests — see "Java runtime" below
for the full research writeup, empirical confirmations, and the TS-side
fixes this pass surfaced. Phase 4 (the conformance loop against the
~190 shared fixtures, wiring this runtime into
runAdapterConformanceTests) is next. PebbleAdapter's render
methods emit real .peb template text (see "Template output shape" below
and src/adapter/pebble-adapter.ts's file header for the full
confirmed-Pebble-syntax table and every documented divergence) — Phase 3a's
research pass empirically confirmed most of that file header's flagged
assumptions against a real Pebble engine and refuted one significant one
(?? does not exist in Pebble at all — see "Java runtime" below). See the
tracking issue
piconic-ai/barefootjs#2101
for the full stacked-PR plan.
Design decisions (Phase 0)
Recorded here per the add-adapter skill's Phase 0 scoping step, so later
PRs in the stack don't re-litigate them:
- Runtime model: DSL. Pebble cannot execute arbitrary JS the way the
Hono/JSX reference adapter's runtime can — this adapter only ever
populates
templatePrimitives, neverclientShimSource/acceptsTemplateCall. - TS-side port source:
@barefootjs/jinja. Pebble's syntax ({{ }}/{% if %}/{% for %}/{% set %}) is Twig/Jinja-family; the adapter core PR ports frompackages/adapter-jinja/src/adapter/ jinja-adapter.ts(comparing againstadapter-twigwhere a given construct's Twig-family answer diffs from Jinja's, per #2101's own suggestion), not written from scratch. templatesPerComponent = true, extension.peb— one.pebfile per component, matching the Jinja/Twig/ERB/Blade family (filename-based template lookup).- Helper naming convention:
bf.*(e.g.bf.renderChild(...),bf.scopeAttr(...)), matching the Jinja/Twig/ERB/Blade family's dot-method convention rather than Go'sbf_*or Perl'sbf->*. Java's own idiom (camelCasemethods on an object) fits this directly. - Native runtime location: in-package,
packages/adapter-pebble/java/— a Gradle project (not Maven), since Gradle's Shadow plugin gives the conformance harness a simple way to produce one fat jar the harness reuses across all fixtures (see next point). - Conformance harness: build-once, like
adapter-rust. Pebble/Java is a compiled-target harness, not an interpreter-per-fixture one (unlike Jinja/Twig/ERB) — 190+ shared fixtures × a JVM cold compile each would not be viable.test-render.tsbuilds the Java renderer's fat jar once per test run (or reuses a cached one) and shells outjava -jar renderer.jar <template-dir> <fixture-json>per fixture, mirroringadapter-rust's prebuilt-binary caching. - ParsedExpr evaluator watchpoints (for
.map()/.filter()/.reduce()/.sort()callback bodies) to verify against the shared vector corpus once the Java runtime lands:long/doublenumber split,String()-compatible float formatting, SameValueZero.includes, JS%sign, and Pebble's strict-variables/null handling vs. Jinja'sChainableUndefined(pin the policy explicitly, don't assume it transfers).
Template output shape
name: 'pebble',extension: '.peb',templatesPerComponent: true— one.pebfile per component, named by snake-casing the PascalCase component name (UserCard→user_card.peb).- Hydration markers (
bf-s,bf-h/bf-m/bf-r,bf-p, slot/conditional comment markers, loop boundary comments) use the SAME runtime method names as every other adapter'sbf.*calls (bf.scope_attr(),bf.hydration_attrs(),bf.text_start/text_end,bf.comment(...), …) — seespec/template-helpers.mdfor the shared helper contract. - Every text/attribute interpolation of a possibly-non-string value is
routed through
bf.string(...)(orbf.bool_str(...)for boolean-shaped values); every non-comparison condition position is routed throughbf.truthy(...). Both are pure Java-runtime helpers, to be implemented in Phase 3. - Control flow uses Pebble's confirmed
{% if %}/{% elseif %}/{% else %}/{% endif %}and{% for %}/{% endfor %}tags, and its confirmed symbolic ternary (cond ? a : b) — seesrc/adapter/pebble-adapter.ts's file header for the full syntax table and every point where this port took Twig's answer over Jinja's (most of them — Pebble is Twig-inspired) or landed on something genuinely Pebble-specific (0-basedloop.index, no.items()-style method calls). JS??is NOT native Pebble syntax (confirmed absent — see below) and routes throughbf.coalesce(l, r)instead. - One finding worth calling out here too: stock Pebble has NO
block-capture
{% set NAME %}…{% endset %}form the way Jinja/Twig do (confirmed via a 2018 upstream feature request that was never implemented) — this adapter still emits that syntax for JSX-children/ named-slot/async-fallback forwarding, as a deliberate, documented requirement that Phase 3's Java runtime register a custom PebbleTokenParserextension implementing it (Pebble'sExtensionAPI is confirmed to support custom tags). See the adapter file header, divergence 6, for the full rationale — this is the headline Phase 3 dependency, not a quiet TODO. - Member/index access, JS
===/!==, JS??, and JS+on string operands all route through dedicatedbf.*runtime helpers (bf.get,bf.eq/bf.neq,bf.coalesce, and Pebble's own~concat operator — both~operandsbf.string(...)-wrapped — respectively) rather than trusting a native Pebble operator:??is CONFIRMED ABSENT from Pebble's grammar entirely (not just unverified), and===/!=='s native cross-type-numeric behavior remains unverified — see the file header, divergences 3, 4, 6, and 7.
Java runtime
Phase 3a landed (#2101): Gradle project, bf.* helpers, ParsedExpr
evaluator. Phase 3b landed: a custom set tag extension
(ext/SetBlockExtension.java) supporting {% set NAME %}...{% endset %}
block-capture — see "The {% set %}...{% endset %} extension (Phase 3b)"
below for the design and the API research it's built on.
Project layout
packages/adapter-pebble/java/
build.gradle.kts # Kotlin DSL; io.pebbletemplates:pebble + Shadow + JUnit 5
settings.gradle.kts
.gitignore # .gradle/, build/
src/main/java/dev/barefootjs/pebble/
Bf.java # the `bf` global: every helper the adapter's .peb output calls
JsNumber.java # JS-compatible number coercion/formatting (String(n), toFixed, %, round)
JsValue.java # JS-compatible stringify/truthy/strict-equals/SameValueZero
Main.java # CLI entry point (`java -jar <jar> <templatesDir> <entry> <varsFile>`)
eval/Evaluator.java # ParsedExpr evaluator (packages/adapter-tests/vectors/eval-reference.ts port)
eval/EvalUnsupported.java
ext/SetBlockExtension.java # registers SetBlockTokenParser as the engine's `set` tag handler
ext/SetBlockTokenParser.java # parses BOTH `{% set NAME = EXPR %}` and `{% set NAME %}...{% endset %}`
ext/SetBlockNode.java # renders a captured block body to a String and binds it in scope
src/test/java/dev/barefootjs/pebble/
HelperVectorsTest.java # vectors.json-driven (396 dynamic tests incl. the divergence-ledger check)
EvalVectorsTest.java # eval-vectors.json-driven (102 dynamic tests, no divergence allowance)
ext/SetBlockExtensionTest.java # direct engine-level parse/render tests for the `set` tag extension
src/test/resources/vector-divergences.json # currently EMPTY — see "Vector conformance results" belowThe {% set %}...{% endset %} extension (Phase 3b)
Stock Pebble's set tag (SetTokenParser) only parses
set NAME = EXPRESSION — there is no block-capture form (confirmed absent
during Phase 3a's research, "Divergence 6" below). ext/ replaces the
engine's set tag handler entirely with SetBlockTokenParser, which
parses both forms:
- After the
NAMEtoken, an immediately-following=starts the stock assignment form — delegated to the sameExpression<?>parse and the sameio.pebbletemplates.pebble.node.SetNodestock Pebble itself constructs, so this path is behavior-identical to the original. - An immediately-following
%}(tag closes with no=) starts the new block-capture form: the body up to a matching{% endset %}is read viaParser#subparse, exactly mirroring how the built-inBlockTokenParserreads a{% block %}...{% endblock %}body. The resultingSetBlockNoderenders that body to an in-memory buffer at evaluation time and bindsNAMEto the resulting string viaEvaluationContextImpl.getScopeChain() .set(...)— the same mechanismSetNodeitself uses — so a captured variable is indistinguishable from an ordinary one to every downstream reference ({{ NAME }},bf.async_boundary(id, NAME),bf.render_child(..., {'default': NAME})).
Why replacing (not adding to) the set tag is correct, not a conflict:
ExtensionRegistry stores token parsers in a single Map<String,
TokenParser> keyed by tag name, populated by a plain Map.put per
extension in registration order; CoreExtension (the stock set tag's
owner) is always registered before any user-supplied extension. A later
put for the same key ("set") unconditionally overwrites the earlier one
— confirmed by decompiling ExtensionRegistry/ExtensionRegistryFactory
(no pebble-sources.jar is published for 4.1.2; io.pebbletemplates:
pebble:4.1.2's own class files were decompiled instead, quoted at the
bytecode level in SetBlockTokenParser's doc comment). This is also why
SetBlockTokenParser must reimplement the assignment form rather than
delegate to SetTokenParser: once installed, nothing else handles set.
Nested {% set %}...{% endset %} blocks need no special handling.
Parser#subparse's stopping condition is checked only between tags — every
{% ... %} tag encountered while subparsing (including a nested set) is
dispatched back through the ordinary tag-parser lookup and fully consumes
its own matching endset before the outer subparse's stopping condition is
ever re-evaluated. Verified both by inspecting the mechanism BlockTokenParser
already relies on for nested {% block %} and empirically in
SetBlockExtensionTest.nestedSetBlocksResolveIndependently.
Not in this PR: bf.render_child (cross-template child-component
rendering) still throws — it additionally needs the Java runtime to hold a
reference to the PebbleEngine so a child template can be looked up and
evaluated by name with its own scoped props, which is multi-template
dispatch work best done alongside Phase 4 (the conformance loop), where
child-component fixtures first need it. bf.async_boundary and
JSX-children/named-slot forwarding need only the capture mechanism itself
(no cross-template lookup) and are fully covered here.
No Gradle wrapper is committed. The task environment provisions a matching
gradle (8.14.3) directly on PATH, and packages/adapter-pebble/src/
test-render.ts invokes plain gradle shadowJar (memoized, build-once,
mirroring packages/adapter-rust/runtime's prebuilt-binary caching — see
that package's src/test-render.ts for the pattern this one ports). If a
committed wrapper later becomes the repo convention for this adapter (e.g.
to pin the exact Gradle version for CI), that is a small follow-up, not a
blocker — gradle wrapper generates it from the version already in use.
Maven coordinates / versions used
- Pebble:
io.pebbletemplates:pebble:4.1.2— the latest release on Maven Central at time of writing (central.sonatype.com/artifact/io.pebbletemplates/pebble/4.1.2). Minimum Java version: 8 — confirmed viaio.pebbletemplates:pebble's ownpom.xmlon themasterbranch (tag4.1.3-SNAPSHOTat time of writing;github.com/PebbleTemplates/pebble/blob/master/pom.xml),<java.version>1.8</java.version>feedingmaven-compiler-plugin's<source>/<target>. This runtime's own Gradle toolchain targets 21 (matching the environment) since nothing here needs the Java 8 floor. - Shadow plugin:
com.gradleup.shadow:8.3.6(Gradle plugin portal idcom.gradleup.shadow) — the actively maintained fork; the legacycom.github.johnrengelman.shadowcoordinates receive no new releases (plugins.gradle.org/plugin/com.gradleup.shadow, github.com/GradleUp/shadow). Note for anyone editingbuild.gradle.kts: despite the new plugin/Maven coordinates, the Kotlin DSL import path is still the historicalcom.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar— the fork kept the original Java package name. - Gson:
com.google.code.gson:gson:2.11.0— for thebf.jsonhelper's parsing half (decoding the evaluator's serialized-ParsedExprJSON payload and the CLI's vars file);bf.json's own JS-JSON.stringifyoutput is hand-written (Bf.writeJson), NOT delegated to Gson's serializer — see "Vector conformance results" below for why.
Vector conformance results
gradle test is fully green: HelperVectorsTest — 396/396 dynamic
tests pass (395 cases from packages/adapter-tests/vectors/vectors.json
plus the static "every divergence declaration matches a real vector case"
check); EvalVectorsTest — 102/102 dynamic tests pass (all of
packages/adapter-tests/vectors/eval-vectors.json, which allows no
divergence per the vectors README's evaluator-strictness rule).
src/test/resources/vector-divergences.json is empty ({"divergences":
{}, "unsupported": {}}) — every helper in the shared catalogue has a
working Java binding that matches the JS reference EXACTLY, no pinned
departures needed. This is notable because several sibling backends (Ruby,
Perl) DO need divergences here (arbitrary-precision integer arithmetic,
String#<=> byte order instead of ICU collation, native % following the
divisor's sign) — none of those apply to Java: double arithmetic is
IEEE-754 by construction (matches JS exactly, including the safe-integer
rounding edge), java.text.Collator reproduces ICU-style
case-insensitive-ish ordering closely enough to match the one localeCompare
vector, and Java's native % already follows the DIVIDEND's sign (see
divergence-adjacent finding below). Three real implementation bugs were
caught and fixed BY these vectors during development (documented here since
they're genuine "verify empirically, don't assume" lessons for anyone
porting a fifth backend):
Math.min/Math.max/Math.absneed FULL JSNumber()coercion of a non-numeric-string operand (Math.max("not", 5)→NaN), not a "the operand is already a number" assumption — unlikeadd/sub/mul, whose vector domain never probes a non-number operand.reduce'stypefield ("numeric"vs"string") decodes ONLY the initial seed literal's type — folding itself must still apply genuine JS+semantics from the actual runtime value types at each step, so atype: "numeric"reduction over STRING items correctly transitions to string concatenation mid-fold (0 + "5" + "6"→"056", even though the declared type says "numeric"). A first implementation that branched once ontypeand stayed in "always add as numbers" mode for the whole fold got this case wrong.reduceRight's direction changes ITERATION ORDER only — the fold expression is alwaysacc + x(never swapped tox + acc) even when walking right-to-left. A first implementation swapped the operand order for the rightward case and got.reduceRightstring-concat backwards.bf.jsoncannot delegate to Gson's own serializer: Gson always renders adoublewith a decimal point (42.0), which reintroduces exactly the long/double-split artifactJsNumber.numberToStringexists to prevent (JSON.stringify(42)must be"42"). Fixed with a small hand-written JSON writer that routes every number throughJsNumber.numberToString.
Research findings: the adapter file header's numbered divergences
Verified against io.pebbletemplates:pebble's actual source (cloned from
github.com/PebbleTemplates/pebble, master @ commit 6cfecef /
4.1.3-SNAPSHOT) and its GitHub wiki
(github.com/PebbleTemplates/pebble.wiki, cloned directly since
pebbletemplates.io itself is not reachable from this environment) — real
source code and real PebbleEngine renders, not documentation alone,
wherever a claim was checkable that way. pebble-adapter.ts's file header
currently numbers ONE MORE divergence (9) than the task's "7 numbered
divergences" framing — all nine are covered below; the two beyond 7 are
divergence 8 (object-entries iteration routing) and divergence 9
(in-template self-reference seeding).
- Divergence 1 (JS truthiness /
bf.truthyrouting) — CONFIRMED, and REFINED to something stronger than "Python/PHP-style truthiness". Pebble's{% if %}tag (IfNode.java) does NOT have implicit empty-container-is-falsy coercion at all: it accepts ONLYBoolean,Number, orString— anything else (a rawList/Map) makes it throwPebbleException: "Unsupported value type ... Expected Boolean, String, Number in if statement". Verified empirically:{% if items %}withitems: []throws exactly that exception (io.pebbletemplates.pebble.node.IfNode.render,TypeUtils.compatibleCastforNumber/Stringcoercion only). The ternary form used for&&/||is even less forgiving —TernaryExpression.evaluateunboxes an unconvertible test value with a raw, UNCAUGHTClassCastException: class java.util.ArrayList cannot be cast to class java.lang.Boolean(verified empirically:{{ items ? "yes" : "no" }}withitems: []). Bottom line: the adapter's OWN, ALREADY-implemented decision to route every non-boolean-shaped condition-test position throughbf.truthy(...)is not just correct but load-bearing — a missed spot doesn't silently produce Python/PHP-style wrong output, it CRASHES the render. No code change needed (the routing is already universal), but the file header's characterization ("follows the same ... convention as Python/PHP") should be corrected to something like "no native coercion for non-primitives at all;bf.truthysupplies the entire truthiness story; skipping it for a non-primitive value is a hard runtime crash, not silent wrong output." - Divergence 2 (stringification via
bf.string) — CONFIRMED, with an empirical illustration of why it's needed everywhere. Verified: a raw{{ bf.floor(3.7) }}(skipping thebf.string(...)wrap real templates always apply) prints3.0, not3— Pebble's default print path calls the boxedDouble's own.toString(), which does not match JSString(3). Confirms the universalbf.string(...)wrap at every text/attribute position is required, not optional. - Divergence 3 (
??is Pebble-native) — REFUTED.??does not exist in Pebble at all, in any form, at any version.grep -rF '??'over Pebble's entirepebble/src/main/javatree (the actual operator-token string) matches ZERO files;CoreExtension.getBinaryOperators()(the complete operator registry) listsor/and/is/is not/contains/==/equals/!=/>/</>=/<=/+/-/*///%/|/~/..and nothing else. Confirmed independently via Pebble's OWN Twig-compatibility comparison page (docs/src/orchid/resources/data/ twig-compatibility/operators.yml, the project's own authoritative Twig-vs-Pebble operator table): the "Others (..,|,~,.,[],?:)" row is markedsupport: 'full'— the ELVIS operator?:, not Twig's??— and Twig's??appears nowhere in that file at all. Verified empirically a third way: rendering{{ a ?? b }}through the real engine throwsio.pebbletemplates.pebble.error.ParserException: Unexpected token "PUNCTUATION" of value "?"— a hard PARSE failure, not a runtime one. Every.pebtemplate the current TS adapter emits that contains a JS??will fail to even parse. See "TS-side fixes needed" below — this is the most severe finding of this research pass. (Pebble's closest analogue is thedefaultfilter,x | default(y), confirmedsupport: 'full'against Twig'sdefaultfilter in the same compatibility table — but it fires on EVERY "empty" value per Pebble's own broaderemptytest, i.e. also on"", not justnull/undefined like JS??, so it is not a safe drop-in either; a dedicatedbf.*helper is the correct fix, mirroring how===/!==already avoid a native operator.) - Divergence 4 (
===/!==viabf.eq/bf.neq, never a native operator) — CONFIRMED, and the defensive choice was justified in hindsight. Pebble's==(EqualsExpression) is documented as usingjava.util.Objects.equals(a, b)(documentation/operator/comparisons.md) — a NULL-SAFE but NOT cross-type-numeric-aware comparison: two boxed numbers of different types (e.g.IntegervsDouble) are.equals()- unequal in Java even when numerically identical, so a native1 == 1.0would very likely befalseon Pebble — exactly backwards from JS's1 === 1.0(true, same double). The adapter's decision to never trust the native operator and always route through the ONE sharedbf.eq(this runtime'sJsValue.strictEquals, which compares numbers bydoublevalue regardless of Java boxed type) was the right call. - Divergence 5 (no Pebble lambda; evaluator-JSON
*_evalpayload is the one higher-order-callback mechanism) — CONFIRMED as a design decision (not an empirical claim to verify) — implemented exactly as specified; seeBf.java's*_evalmethods andeval/Evaluator.java. - Divergence 6 (
{% set %}...{% endset %}block-capture requires a custom extension) — CONFIRMED, RESOLVED in Phase 3b (see "The{% set %}...{% endset %}extension (Phase 3b)" above).SetTokenParser(tokenParser/SetTokenParser.java) parses ONLYset NAME = EXPRESSION(a singleExpression<?>, no body/block form); there is noendsettoken recognized anywhere in the grammar. Pebble'sExtension.getTokenParsers()API is confirmed (viadocumentation/guide/extending-pebble.md's own workedSetTokenParserexample) to support exactly this kind of custom tag —ext/implements it. - Divergence 7 (reserved-word identifier mangling) — not independently re-verified in this pass (no new empirical test performed beyond what Phase 2 already established from Pebble's grammar/keyword list); nothing in Phase 3a's research contradicts it.
- Divergence 8 (object-entries/keys/values routes through dedicated
bf.*helpers, notfor key, value in map) — the specific claim in the syntax table ("Pebble's confirmedfortag supportskey, value in <map>directly") is REFUTED, but this has ZERO code impact.ForTokenParser.parse()(tokenParser/ForTokenParser.java) callsparser.getExpressionParser().parseNewVariableName()exactly ONCE for the iteration variable — there is no comma-separated two-name form in the grammar at all;{% for k, v in map %}would fail to parse (expect "in"after the first name, sees,instead). Separately confirmed via the wiki's ownfortag doc (documentation/tag/for.md): Pebble's documented single-variable map-iteration form binds the loop variable to aMap.Entry({{ entry.key }} - {{ entry.value }}), which is NEITHER Twig's "values" answer nor a bare key — a third, genuinely Pebble-specific shape. Since divergence 8 was ALREADY designed to avoid depending on any native map-iteration shape (routing everything throughbf.entries/bf.keys/bf.valuesinstead), this refutation changes nothing about the adapter's emitted code — only the syntax-table documentation comment (pebble-adapter.tsline ~39) is inaccurate and should be corrected to describe the real (single-variable,Map.Entry- binding) grammar, or simply removed as irrelevant now that it's confirmed unused. - Divergence 9 (in-template self-reference seeding,
{% set x = x + 1 %}) — CONFIRMED empirically. Rendered{% set x = x + 1 %}{{ bf.string(x) }}against{"x": 5}through the real engine: output is6— the right-handxresolves from the enclosing (pre-existing context) scope before thesetshadows it, exactly like the already-confirmed Jinja/Twig behavior.
TS-side fixes needed — all three FIXED in follow-up commits on #2971
This Java-runtime task's own scope kept packages/adapter-pebble/src/adapter/
read-only, so the three items below were originally flagged here for a
separate fix. The orchestrating session applied all three directly to
PR2 (#2971, already merged into this branch's history) once this research
pass surfaced them, rather than deferring them past this PR:
- (Critical — every
??in a component broke the template's PARSE, not just its output. FIXED.)expr/emitters.ts'slogical()(both copies) now emitsbf.coalesce(${l}, ${r})instead of the native (nonexistent)(${l} ?? ${r}), calling this Java runtime'sBf.coalesce(Object a, Object b). The file header's divergence 3 text was updated to mark it REFUTED rather than confirmed. - (Documentation-only, no runtime-behavior impact. FIXED.) The file
header's syntax table, divergence 8, and a code comment near the
for-loop emission all claimed a confirmed nativefor key, value in maptwo-variable form; corrected to describe Pebble's real grammar (single-variable, binds toMap.Entry). Divergence 8 already routed throughbf.entries/bf.keys/bf.valuesunconditionally, so no code changed. - (Minor, likely latent. FIXED.) Both
binary()implementations now wrap both~operands inbf.string(...), matching every other text/attribute interpolation position (divergence 2) — closing thenull-contributes-nothing /Double.toString()-not-JS-String()gap this research pass identified.
Empirical confirmations beyond the numbered divergences
.lengthaccess must route throughbf.length(...), confirmed the hard way while writing this PR's own smoke tests. Ajava.util.Listhas no.lengthproperty Pebble's attribute resolution can find (ListResolveronly resolves a NUMERIC index attribute, e.g.items.0; there is nogetLength()/isLength()/length()/.lengthfield onArrayListfor the default reflection-based resolver to find either) — a bareitems.lengthsilently resolves to nothing (null, under the assumed non-strict-variables config) rather than throwing.expr/emitters.tsalready routes every.lengthaccess throughbf.length(...)(confirmed at both call sites, ~line 234 and ~452) — this is independent confirmation that decision is not just stylistic but load-bearing, same as divergence 1'sbf.truthyfinding.
Sources consulted
io.pebbletemplates:pebbleMaven Central listing: central.sonatype.com/artifact/io.pebbletemplates/pebble/4.1.2- Pebble source (cloned):
github.com/PebbleTemplates/pebble@master(6cfecef,4.1.3-SNAPSHOT) —CoreExtension.java(operator/filter registry),IfNode.java+TernaryExpression.java+TypeUtils.java(truthiness/coercion),ConcatenateExpression.java(~),ForTokenParser.java+SetTokenParser.java(grammar),FileLoader.javaPebbleEngine.java(embedding API).
- Pebble wiki (cloned):
github.com/PebbleTemplates/pebble.wiki—documentation/{tag,operator,filter,test,guide}/*.md. - Pebble's own Twig-compatibility data:
github.com/PebbleTemplates/pebble/blob/master/docs/src/orchid/resources/data/twig-compatibility/operators.yml(and siblingfilters.yml/functions.yml). - Shadow Gradle plugin: gradleup.com/shadow, plugins.gradle.org/plugin/com.gradleup.shadow.
pebbletemplates.ioitself was NOT reachable from this environment (egress-blocked) — the GitHub wiki clone (the same content's source of truth; the website is generated from it) was used instead throughout.
