survos / js-twig-bundle
Render Twig blocks in the browser from Stimulus via twig-browser, with path(), stimulus_* and ux_icon helpers. Routes load as JSON through AssetMapper, no build step.
Package info
github.com/survos/js-twig-bundle
Language:JavaScript
Type:symfony-bundle
pkg:composer/survos/js-twig-bundle
Fund package maintenance!
Requires
- php: ^8.5
- survos/kit-bundle: ^2.5
- symfony/asset-mapper: ^7.4.20||^8.1.8
- symfony/config: ^7.4||^8.1
- symfony/dependency-injection: ^7.4||^8.1
- symfony/dom-crawler: ^7.4||^8.1
- symfony/http-kernel: ^7.4||^8.1
- symfony/routing: ^7.4||^8.1
- symfony/ux-twig-component: ^3
- twig/twig: ^3.4|^4.0
Requires (Dev)
- phpstan/phpstan: ^2.0
- symfony/browser-kit: ^7.4||^8.1
- symfony/framework-bundle: ^7.4||^8.1
- symfony/phpunit-bridge: ^7.4||^8.1
- symfony/twig-bundle: ^7.4||^8.1
- symfony/var-dumper: ^7.4||^8.1
Suggests
None
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.36.0
- 2.35.6
- 2.34.38
- 2.34.37
- 2.34.36
- 2.34.34
- 2.34.29
- 2.33.0
- 2.18.18
- 2.18.4
- 2.17.6
- 2.15.3
- 2.10.19
- 2.10.18
- 2.10.17
- 2.10.16
- 2.10.15
- 2.10.14
- 2.10.13
- 2.10.12
- 2.10.11
- 2.10.10
- 2.10.9
- 2.10.8
- 2.10.7
- 2.10.6
- 2.10.5
- 2.10.4
- 2.10.3
- 2.10.2
- 2.10.1
- 2.10.0
- 2.9.4
- 2.9.3
- 2.9.2
- 2.9.1
- 2.9.0
- 2.8.4
- 2.8.3
- 2.8.1
- 2.8.0
- 2.7.23
- 2.7.22
- 2.7.21
- 2.7.20
- 2.7.19
- 2.7.18
- 2.7.17
- 2.7.16
- 2.7.15
- 2.7.14
- 2.7.13
- 2.7.12
- 2.7.11
- 2.7.10
- 2.7.9
- 2.7.8
- 2.7.7
- 2.7.6
- 2.7.5
- 2.7.4
- 2.7.3
- 2.7.2
- 2.7.1
- 2.7.0
- 2.6.0
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.0
- 2.2.5
- 2.2.4
- 2.2.3
- 2.2.2
- 2.2.1
- 2.2.0
- 2.1.2
- 2.1.1
- 2.0.220
- 2.0.219
- 2.0.218
- 2.0.217
- 2.0.216
- 2.0.215
- 2.0.214
- 2.0.213
- 2.0.212
- 2.0.211
- 2.0.210
- 2.0.209
- 2.0.208
- 2.0.207
- 2.0.206
- 2.0.205
- 2.0.204
- 2.0.203
- 2.0.202
- 2.0.201
- 2.0.200
- 2.0.199
- 2.0.198
- 2.0.197
- 2.0.196
- 2.0.195
- 2.0.194
- 2.0.193
- 2.0.192
- 2.0.191
- 2.0.190
- 2.0.189
- 2.0.188
- 2.0.187
- 2.0.186
- 2.0.185
- 2.0.184
- 2.0.183
- 2.0.182
- 2.0.181
- 2.0.180
- 2.0.179
- 2.0.178
- 2.0.177
- 2.0.176
- 2.0.175
- 2.0.174
- 2.0.173
- 2.0.172
- 2.0.171
- 2.0.170
- 2.0.169
- 2.0.168
- 2.0.167
- 2.0.166
- 2.0.165
- 2.0.164
- 2.0.163
- 2.0.162
- 2.0.161
- 2.0.160
- 2.0.159
- 2.0.158
- 2.0.156
- 2.0.155
- 2.0.154
- 2.0.146
- 2.0.145
- 2.0.144
- 2.0.143
- 2.0.142
- 2.0.141
- 2.0.140
- 2.0.139
- 2.0.138
- 2.0.137
- 2.0.136
- 2.0.135
- 2.0.134
- 2.0.133
- 2.0.132
- 2.0.131
- 2.0.130
- 2.0.129
- 2.0.128
- 2.0.127
- 2.0.126
- 2.0.125
- 2.0.124
- 2.0.123
- 2.0.122
- 2.0.121
- 2.0.120
- 2.0.119
- 2.0.117
- 2.0.116
- 2.0.115
- 2.0.114
- 2.0.113
- 2.0.112
- 2.0.111
- 2.0.110
- 2.0.109
- 2.0.108
- 2.0.107
- 2.0.106
- 2.0.105
- 2.0.104
- 2.0.103
- 2.0.102
- 2.0.101
- 2.0.100
- 2.0.99
- 2.0.98
- 2.0.97
- 2.0.96
- 2.0.95
- 2.0.94
- 2.0.93
- 2.0.92
- 2.0.91
- 2.0.90
- 2.0.89
- 2.0.88
- 2.0.87
- 2.0.86
- 2.0.85
- 2.0.84
- 2.0.83
- 2.0.82
- 2.0.81
- 2.0.80
- 2.0.79
- 2.0.78
- 2.0.77
- 2.0.76
- 2.0.75
- 2.0.74
- 2.0.73
- 2.0.72
- 2.0.71
- 2.0.70
- 2.0.69
- 2.0.68
- 2.0.67
- 2.0.66
- 2.0.65
- 2.0.64
- 2.0.63
- 2.0.62
- 2.0.61
- 2.0.60
- 2.0.59
- 2.0.58
- 2.0.57
- 2.0.56
- 2.0.55
- 2.0.54
- 2.0.53
- 2.0.51
- 2.0.50
- 2.0.49
- 2.0.48
- 2.0.47
- 2.0.46
- 2.0.45
- 2.0.44
- 2.0.43
- 2.0.42
- 2.0.41
- 2.0.40
- 2.0.39
- 2.0.38
- 2.0.37
- 2.0.36
- 2.0.35
- 2.0.34
- 2.0.33
- 2.0.32
- 2.0.31
- 2.0.30
- 2.0.29
- 2.0.28
- 2.0.27
- 2.0.26
- 2.0.25
- 2.0.24
- 2.0.23
- 2.0.22
- 2.0.21
- 2.0.20
- 2.0.19
- 2.0.18
- 2.0.17
- 2.0.16
- 2.0.15
- 2.0.14
- 2.0.13
- 2.0.12
- 2.0.11
- 1.6.44
- 1.6.43
- 1.6.42
- 1.6.41
- 1.6.40
- 1.6.39
- 1.6.38
- 1.6.37
- 1.6.36
- 1.6.35
- 1.6.34
- 1.6.33
- 1.6.32
- 1.6.31
- 1.6.30
- 1.6.29
- 1.6.28
- 1.6.27
- 1.6.26
- 1.6.25
- 1.6.24
- 1.6.23
- 1.6.22
- 1.6.21
- 1.6.20
- 1.6.19
- 1.6.18
- 1.6.17
- 1.6.16
- 1.6.15
- 1.6.14
- 1.6.13
- 1.6.12
- 1.6.11
- 1.6.10
- 1.6.9
- 1.6.8
- 1.6.7
- 1.6.6
- 1.6.5
- 1.6.4
- 1.6.3
- 1.6.2
- 1.6.1
- 1.6.0
- 1.5.529
- 1.5.528
- 1.5.527
- 1.5.526
- 1.5.525
- 1.5.524
- 1.5.523
- 1.5.522
- 1.5.521
- 1.5.520
- 1.5.519
- 1.5.518
- 1.5.517
- 1.5.516
- 1.5.515
- 1.5.514
- 1.5.513
- 1.5.512
- 1.5.511
- 1.5.510
- 1.5.509
- 1.5.508
- 1.5.507
- 1.5.506
- 1.5.505
- 1.5.504
- 1.5.503
- 1.5.502
- 1.5.501
- 1.5.500
- 1.5.499
- 1.5.498
- 1.5.497
- 1.5.496
- 1.5.495
- 1.5.494
- 1.5.493
- 1.5.492
- 1.5.491
- 1.5.490
- 1.5.489
- 1.5.488
- 1.5.487
- 1.5.486
- 1.5.485
- 1.5.484
- 1.5.483
- 1.5.482
- 1.5.481
- 1.5.480
- 1.5.479
- 1.5.478
- 1.5.477
- 1.5.476
- 1.5.475
- 1.5.474
- 1.5.473
- 1.5.472
- 1.5.471
- 1.5.470
- 1.5.469
- 1.5.468
- 1.5.467
- 1.5.466
- 1.5.465
- 1.5.464
- 1.5.463
- 1.5.462
- 1.5.461
- 1.5.460
- 1.5.459
- 1.5.458
- 1.5.457
- 1.5.456
- 1.5.455
- 1.5.454
- 1.5.453
- 1.5.452
- 1.5.451
- 1.5.450
- 1.5.449
- 1.5.448
- 1.5.447
- 1.5.446
- 1.5.445
- 1.5.444
- 1.5.443
- 1.5.442
- 1.5.441
- 1.5.440
- 1.5.439
- 1.5.438
- 1.5.437
- 1.5.436
- 1.5.435
- 1.5.434
- 1.5.433
- 1.5.432
- 1.5.431
- 1.5.430
- 1.5.429
- 1.5.428
- 1.5.427
- 1.5.426
- 1.5.425
- 1.5.424
- 1.5.423
- 1.5.422
- 1.5.421
- 1.5.420
- 1.5.419
- 1.5.418
- 1.5.417
- 1.5.416
- 1.5.415
- 1.5.414
- 1.5.413
- 1.5.412
- 1.5.411
- 1.5.410
- 1.5.409
- 1.5.408
- 1.5.407
- 1.5.406
- 1.5.405
- 1.5.404
- 1.5.403
- 1.5.402
- 1.5.401
- 1.5.400
- 1.5.399
- 1.5.398
- 1.5.397
- 1.5.396
- 1.5.395
- 1.5.394
- 1.5.393
- 1.5.392
- 1.5.391
- 1.5.390
- 1.5.389
- 1.5.388
- 1.5.387
- 1.5.386
- 1.5.385
- 1.5.384
- 1.5.383
- 1.5.382
- 1.5.381
- 1.5.380
- 1.5.379
- 1.5.378
- 1.5.377
- 1.5.376
- 1.5.375
- 1.5.374
- 1.5.373
- 1.5.372
- 1.5.371
- 1.5.370
- 1.5.369
- 1.5.368
- 1.5.367
- 1.5.366
- 1.5.365
- 1.5.364
- 1.5.363
- 1.5.362
- 1.5.361
- 1.5.360
- 1.5.359
- 1.5.358
- 1.5.357
- 1.5.356
- 1.5.355
- 1.5.354
- 1.5.353
- 1.5.352
- 1.5.351
- 1.5.350
- 1.5.345
- 1.5.344
- 1.5.343
- 1.5.342
- 1.5.341
- 1.5.340
- 1.5.339
- 1.5.338
- 1.5.337
- 1.5.336
- 1.5.335
- 1.5.334
- 1.5.333
- 1.5.332
- 1.5.331
- 1.5.330
- 1.5.329
- 1.5.328
- 1.5.327
- 1.5.326
- 1.5.325
- 1.5.324
- 1.5.323
- 1.5.322
- 1.5.321
- 1.5.320
- 1.5.319
- 1.5.318
- 1.5.317
- 1.5.316
- 1.5.315
- 1.5.314
- 1.5.313
- 1.5.312
- 1.5.311
- 1.5.310
- 1.5.309
- 1.5.308
- 1.5.307
- 1.5.306
- 1.5.305
- 1.5.304
- 1.5.303
- 1.5.302
- 1.5.301
- 1.5.300
- 1.5.299
- 1.5.298
- 1.5.297
- 1.5.296
- 1.5.295
- 1.5.294
- 1.5.293
- 1.5.292
- 1.5.291
- 1.5.290
- 1.5.289
- 1.5.288
- 1.5.287
- 1.5.286
- 1.5.285
- 1.5.284
- 1.5.283
- 1.5.282
- 1.5.281
- 1.5.280
- 1.5.279
- 1.5.278
- 1.5.277
- 1.5.276
- 1.5.275
- 1.5.274
- 1.5.273
- 1.5.272
- 1.5.271
- 1.5.270
- 1.5.269
- 1.5.268
- 1.5.267
- 1.5.266
- 1.5.265
- 1.5.264
- 1.5.263
- 1.5.262
- 1.5.261
- 1.5.260
- 1.5.259
- 1.5.258
- 1.5.257
- 1.5.256
- 1.5.255
- 1.5.254
- 1.5.253
- 1.5.252
- 1.5.251
- 1.5.250
- 1.5.249
- 1.5.248
- 1.5.247
- 1.5.246
- 1.5.245
- 1.5.244
- 1.5.243
- 1.5.242
- 1.5.241
- 1.5.240
- 1.5.239
- 1.5.238
- 1.5.237
- 1.5.236
- 1.5.235
- 1.5.234
- 1.5.233
- 1.5.232
- 1.5.231
- 1.5.230
- 1.5.229
- 1.5.228
- 1.5.227
- 1.5.226
- 1.5.225
- 1.5.224
- 1.5.223
- 1.5.222
- 1.5.221
- 1.5.220
- 1.5.219
- 1.5.218
- 1.5.217
- 1.5.216
- 1.5.215
- 1.5.214
- 1.5.213
- 1.5.212
- 1.5.211
- 1.5.210
- 1.5.209
- 1.5.208
- 1.5.207
- 1.5.206
- 1.5.205
- 1.5.204
- 1.5.203
This package is auto-updated.
Last update: 2026-10-09 15:20:56 UTC
README
Write a Twig block once, render it on the server and in the browser.
survos/js-twig-bundle lets Stimulus controllers render <twig:block> templates
client-side, after async data arrives (API rows, Meilisearch hits, IndexedDB
records), with the same Symfony helpers you use on the server: path(),
stimulus_*, ux_icon(), render(). No Node, no bundler, no build step:
everything ships through AssetMapper and importmap.
Rendering is done by twig-browser
(@tacman1123/twig-browser),
a browser-only Twig engine inspired by twig.js. It parses a focused Twig subset
in the browser, ships as source-first ESM, and fails fast on unsupported tags.
This bundle supplies the Symfony side: path() from your exposed routes, and
the AssetMapper wiring.
Live demo: searchbench.survos.com/search.
Every search hit there is a Twig template rendered in the browser. The reference
implementation is search-bundle's
instantsearch_controller.js.
The same pattern runs in meili-bundle (insta search, chat), api-grid-bundle
(grid cells, modals) and tree-bundle (node labels).
What you get
- Twig in the browser. Compile a Twig source string once, then render it for each row or hit as data arrives.
- Symfony helpers in client templates.
path()andux_icon()come from the Symfony adapter.stimulus_controller,stimulus_target,stimulus_actionandrenderare built into twig-browser. Amarkdown_to_htmlfilter is available from this bundle. path()without FOSJsRoutingBundle. Exposed routes are written toroutes.jsonduringcache:warmupand loaded with AssetMapper's JSON import. No dump command, and no routing entry in yourimportmap.php. See Routing.
Requirements
- PHP 8.5, Symfony 7.4 or 8.1
- AssetMapper 7.4.20+ or 8.1.8+ (JSON imports, symfony/symfony#61133, with the es-module-shims loader fix, symfony/symfony#66282)
symfony/ux-twig-component
composer require survos/js-twig-bundle
Flex adds @tacman1123/twig-browser and its Symfony adapter (plus marked and
dexie) to your importmap.
Quick start
The pattern is always the same: get Twig source (not rendered HTML) into
the browser, compileBlock() it once, then renderBlock() it with data.
A) Fetch a template from a URL
This is what searchbench does. Serve the raw, unrendered .html.twig file from
a route (for example meili-bundle's meili_template route returns
file_get_contents() of the template):
{# templates/js/movie.html.twig, served as text, never rendered on the server #} <article class="hit"> <a href="{{ path('movie_show', {id: hit.id}) }}">{{ hit.title }}</a> {% if hit.year %}<span class="text-secondary">{{ hit.year }}</span>{% endif %} </article>
import { Controller } from '@hotwired/stimulus'; import { createEngine } from '@tacman1123/twig-browser'; import { installSymfonyTwigAPI } from '@tacman1123/twig-browser/adapters/symfony'; import { path } from '@survos/js-twig-bundle/routing'; export default class extends Controller { static values = { template: String }; async connect() { const source = await (await fetch(this.templateValue)).text(); this.engine = createEngine(); installSymfonyTwigAPI(this.engine, { pathGenerator: path }); this.engine.compileBlock('hit', source); } renderHit(hit) { return this.engine.renderBlock('hit', { hit }); } }
Mount it like any app controller (here assets/controllers/hits_controller.js):
<div {{ stimulus_controller('hits', { template: path('meili_template', {templateName: 'movie'}), }) }}></div>
Controllers that ship in a Survos bundle are always referenced through the kit
helper, never by a hard-coded name: stimulus_controller(survos_stimulus('search-bundle', 'instantsearch')).
The helper fails loudly in dev when a name changes; a hard-coded string just
silently stops mounting.
B) Inline blocks in the page
When the template belongs with the page, put one or more <twig:block>
sources in a <script type="text/twig"> element and compile them all with
compileTwigBlocks(engine, registry, elementId). It reads the element's
innerHTML; a script element keeps that as raw text (a <template> would let
the HTML parser drop table tags). Wrap the blocks in {% verbatim %} so the
server doesn't render them:
<script type="text/twig" id="row-blocks">{% verbatim %} <twig:block name="row"> <tr><td>{{ row.name }}</td><td>{{ row.count|number_format }}</td></tr> </twig:block> {% endverbatim %}</script>
import { createEngine, compileTwigBlocks } from '@tacman1123/twig-browser'; const engine = createEngine(); const blocks = {}; compileTwigBlocks(engine, blocks, 'row-blocks'); // blocks.row === true tbody.innerHTML = rows.map((row) => engine.renderBlock('row', { row })).join('');
compileTwigBlocks() only finds literal <twig:block name="…"> markup; it
throws when none is present.
Twig functions available in browser templates
| Name | Provided by |
|---|---|
path(route, params) |
installSymfonyTwigAPI(engine, { pathGenerator: path }) |
ux_icon(name, attrs) |
installSymfonyTwigAPI(engine, { uxIconResolver }), or window.__survosIconsMap (bin/console ux:icons:lock) |
stimulus_controller, stimulus_target, stimulus_action |
built into twig-browser |
render(blockName, vars) |
built into twig-browser |
markdown_to_html filter |
installMarkdownFilter(engine) from this bundle |
import { installMarkdownFilter } from '@survos/js-twig-bundle/src/lib/markdown_filter.js'; installMarkdownFilter(engine);
{{ hit.observation|markdown_to_html|raw }}
The filter uses marked. It is optional:
without marked in the importmap the filter shows plain text and logs a notice.
It mirrors the server-side |markdown filter (League\CommonMark), so prose
renders the same on the server and in the browser.
twig-browser supports a Twig subset: output, if/elseif/else, for, set,
and the common filters and tests. See its
README for the
full list. Keep client-rendered templates within it.
Routing without FOSJsRoutingBundle
cache:clear / cache:warmup writes exposed route data to
var/js_twig_bundle/generated/routes.json. The bundle's static
assets/routing.js imports that JSON with AssetMapper's JSON import and exports
path(name, params = {}, absolute = false) and generate(name, params = {})
(the same, always absolute):
import routesPromise from './routes.json'; const data = await routesPromise; // JSON imports resolve to a promise
In your code:
import { path, generate } from '@survos/js-twig-bundle/routing'; path('product_show', { id: 42 }); // "/product/42" path('about', { q: 'a b' }); // "/about?q=a+b" (extra params become the query) generate('product_show', { id: 42 }); // "http://example.com/product/42"
The bundle resolves this import and its JSON dependency automatically, including
when installed through a Composer path symlink. You don't need a routing entry
in the application's importmap.php, FOSJsRoutingBundle, or a dump command.
Expose routes with options: ['expose' => true] or configure
survos_js_twig.routing.routes_to_expose. Warm the cache before
asset-map:compile in production. Rewarm after changing exposed routes in development.
Older specifiers (@survos/js-twig/routing from before the asset namespace
matched the Composer package name, and @survos/js-twig/generated/fos_routes.js
from the generated-JS approach) still work as compiler aliases. Old importmap
entries pointing to a generated JS file must be removed. See the
migration guide.
Matches Symfony
The generator is a port of isychev/fos-routing,
corrected to match Symfony's UrlGenerator: a missing required parameter throws
(only parameters with a default are optional), and a route's schemes option is
honored as well as the legacy _scheme requirement.
Tests
cd vendor/survos/js-twig-bundle # or bu/js-twig-bundle in the mono repo node --import ./assets/test/routes-loader.mjs --test assets/test/
Plain Node (22.15+), nothing to install. The fixture
assets/test/fixtures/routes.json is generated from real Symfony routes by
assets/test/fixtures/build-routes.php.
Legacy APIs
These predate twig-browser's compileBlock() API. They are kept for existing
callers and are not recommended for new code.
TwigBlocksTrait+<twig:jsTwig>+js_twig_controller.js. The trait extracts<twig:block>contents from a component template into a JSON map ({name: {html, wrapper, extra}}) in a<script type="application/json">tag. Two compilers accept that JSON: the deprecatedcompileTwigBlocks(registry, id)inassets/src/lib/twig_blocks.js, andtree-bundle'ssourceFromScriptContent()(inapi_tree_controller.js), which turns it back into<twig:block>markup.js_twig_controller.jsdoes neither: it passes the JSON tag's id to twig-browser'scompileTwigBlocks(), which needs literal<twig:block>markup and throws "No<twig:block name=…>tags were found".<twig:jsTwig>is broken as shipped; don't build on it.<twig:dexie>+dexie_controller.js. Loads API data into Dexie (IndexedDB) and renders list/detail blocks withcompileBlock(). Used for offline/mobile experiments. Configuration keys (insrc/SurvosJsTwigBundle.php):db,version, andstores[]withname,schema,url,batch,response_key.mobile-bundleuses itsDbUtilities(assets/src/lib/dexieDatabase.js).assets/src/lib/twig_api.js/twig_blocks.js. Deprecated shims (installTwigAPI()logs a deprecation error;compileTwigBlocks(registry, id),twigRender()). Nothing in the Survos bundles uses them any more. Use@tacman1123/twig-browserdirectly.- Debug manifest.
{{ jstwig_debug_panel() }}andwindow.__jstwigDebugSnapshot()only see blocks that go through the legacy component andtwig_blocks.js, not thecompileBlock()flow above.
Docs
docs/ARCHITECTURE.md: data flow, including the legacy block-registry path.docs/routing-migration.md: moving off FOSJsRoutingBundle / generated JS routes.docs/EVENT_DRIVEN_EXAMPLE.md: a real-world event-driven rendering pattern.docs/AI_AGENT_GUIDE.md: conventions for contributors and agents.docs/IMPROVEMENTS.md: prioritized code improvements.
License
MIT