mikser-io-render-liquid 5.0.0 → 5.0.2

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/index.js CHANGED
@@ -73,9 +73,29 @@ export async function render({ entity, runtime, state, track }) {
73
73
  // Expose every function on runtime as a Liquid filter, so render-
74
74
  // helper plugins (markdown, href, ...) keep working without per-
75
75
  // plugin glue.
76
+ //
77
+ // The filter body resolves the runtime from the per-render CONTEXT rather
78
+ // than closing over this render's. `engine` is module-level and
79
+ // registerFilter is a global mutation, so a closure means the last render
80
+ // to register owns the binding for every render still in flight — and
81
+ // renders are concurrent, and any `{% render %}` / `{% include %}` is an
82
+ // await point that guarantees the overlap.
83
+ //
84
+ // What that produced: a page resolved `asset` against a DIFFERENT page's
85
+ // entity, so the relative url was computed from someone else's depth. It
86
+ // is well-formed and points at nothing, the build is green, and the value
87
+ // changes between builds because it follows render order. Every
88
+ // entity-dependent helper was affected the same way, not just asset —
89
+ // href and resource compute from entity.destination too.
76
90
  for (let key in runtime) {
77
91
  if (typeof runtime[key] === 'function') {
78
- engine.registerFilter(key, (input, ...args) => runtime[key](input, ...args))
92
+ engine.registerFilter(key, (input, ...args) => {
93
+ // Falls back to the captured runtime only for a call with no
94
+ // context — runtime.liquid is public and can be invoked
95
+ // outside a render.
96
+ const active = renderContext.getStore()?.runtime ?? runtime
97
+ return active[key](input, ...args)
98
+ })
79
99
  }
80
100
  }
81
101
  const source = entity.layout.content ?? ''
@@ -86,7 +106,7 @@ export async function render({ entity, runtime, state, track }) {
86
106
  // the async chain and reports the resolved name to the
87
107
  // engine's track via the wrapped methods above.
88
108
  return await renderContext.run(
89
- { track, layouts },
109
+ { track, layouts, runtime },
90
110
  () => runtime.liquid(source, runtime),
91
111
  )
92
112
  } catch (err) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mikser-io-render-liquid",
3
- "version": "5.0.0",
4
- "description": "",
3
+ "version": "5.0.2",
4
+ "description": "LiquidJS renderer for Mikser. Renders entities whose layout uses the `.liquid` template engine.",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "scripts": {
@@ -0,0 +1,81 @@
1
+ // Filters must resolve against the runtime of the render that is CALLING them,
2
+ // not the one that registered them last.
3
+ //
4
+ // The engine is module-level and registerFilter is a global mutation, so when
5
+ // each render re-registered every runtime function as a filter, the last
6
+ // render to start owned the binding for every render still in flight. Renders
7
+ // are concurrent, and `{% render %}` / `{% include %}` reads a partial from
8
+ // disk — an await point that guarantees the overlap.
9
+ //
10
+ // The symptom in production was an `asset` url computed from a DIFFERENT
11
+ // page's entity: a relative prefix with the wrong number of `..` for the page
12
+ // it was emitted on. Well-formed, pointing at nothing, green build, and
13
+ // unstable between builds because it followed render order. `href` and
14
+ // `resource` compute from entity.destination too, so all of them were exposed.
15
+
16
+ import { describe, it, before, after } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+ import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'
19
+ import { tmpdir } from 'node:os'
20
+ import path from 'node:path'
21
+
22
+ import { load, render } from '../index.js'
23
+
24
+ let layoutsFolder
25
+
26
+ // A runtime whose `mark` filter answers with this render's own identity —
27
+ // standing in for asset()/href(), which answer from this render's entity.
28
+ function makeRuntime(id) {
29
+ const runtime = { mark: () => id }
30
+ load({ runtime, options: { layoutsFolder }, config: {} })
31
+ return runtime
32
+ }
33
+
34
+ const entityFor = (body) => ({
35
+ id: `/documents/${body}.yml`,
36
+ layout: { uri: 'page.liquid', content: body },
37
+ })
38
+
39
+ const renderWith = (runtime, body) =>
40
+ render({ entity: entityFor(body), runtime, state: {}, track: {} })
41
+
42
+ describe('concurrent renders do not steal each other filters', () => {
43
+ before(async () => {
44
+ layoutsFolder = await mkdtemp(path.join(tmpdir(), 'liquid-concurrent-'))
45
+ await mkdir(path.join(layoutsFolder, 'ui'), { recursive: true })
46
+ // The partial is the point: reading it from disk is the await during
47
+ // which another render can register over this one's filters.
48
+ await writeFile(path.join(layoutsFolder, 'ui', 'mark.liquid'), "{{ '' | mark }}")
49
+ })
50
+ after(() => rm(layoutsFolder, { recursive: true, force: true }))
51
+
52
+ it('each render sees its own runtime through a partial', async () => {
53
+ const a = makeRuntime('A')
54
+ const b = makeRuntime('B')
55
+
56
+ // Both in flight at once. A registers, hits the partial read, and B
57
+ // registers while A is suspended — the exact interleaving that was
58
+ // silently producing another page's answer.
59
+ const [outA, outB] = await Promise.all([
60
+ renderWith(a, "{% render 'ui/mark' %}"),
61
+ renderWith(b, "{% render 'ui/mark' %}"),
62
+ ])
63
+
64
+ assert.equal(outA, 'A', 'render A resolved a filter against another render\'s runtime')
65
+ assert.equal(outB, 'B', 'render B resolved a filter against another render\'s runtime')
66
+ })
67
+
68
+ it('holds with many overlapping renders', async () => {
69
+ const ids = Array.from({ length: 12 }, (_, i) => `R${i}`)
70
+ const outputs = await Promise.all(
71
+ ids.map(id => renderWith(makeRuntime(id), "{% render 'ui/mark' %}")),
72
+ )
73
+ assert.deepEqual(outputs, ids,
74
+ 'every render must answer with its own identity, whatever the interleaving')
75
+ })
76
+
77
+ it('still works with no partial, where nothing suspends', async () => {
78
+ const a = makeRuntime('A')
79
+ assert.equal(await renderWith(a, "{{ '' | mark }}"), 'A')
80
+ })
81
+ })