@mrhenry/twig-parser 0.1.1 → 0.1.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/CHANGELOG.md CHANGED
@@ -1 +1,20 @@
1
1
  # Changelog
2
+
3
+ ## 0.1.2 (2026-09-26)
4
+
5
+ * Added the sandbox `render_sandboxed` function to the core function set, so it
6
+ can be parsed (its `output_strategy` argument is validated at compile time).
7
+
8
+ * Signature capture now defaults to off. Signature capture backs lossless
9
+ `Node#serialize` (it lets a node detect whether its subtree was mutated) and
10
+ hashes the whole tree during parsing, so it is now opt-in via
11
+ `{ captureSignatures: true }`. Parsers that mutate and serialize the AST
12
+ must now pass it; compile-only callers (like `@mrhenry/twig-js`) pay nothing.
13
+ * Signature computation now hashes incrementally instead of building an
14
+ intermediate string per node, and captures the whole tree in a single walk.
15
+
16
+ * Bounded parser recursion. `parseExpression` and `subparse` now track their
17
+ own depth (cap `256`) and throw a `SyntaxError` — "Expression is too deeply
18
+ nested." / "Template nesting is too deep." — instead of overflowing the call
19
+ stack. Previously a crafted template of nested `not not …`, `((( … )))`,
20
+ `[[[ … ]]]` or `{% if %}` tags crashed the process with a `RangeError`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrhenry/twig-parser",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -8,6 +8,6 @@
8
8
  ".": "./src/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@mrhenry/twig-tokenizer": "^0.1.1"
11
+ "@mrhenry/twig-tokenizer": "^0.1.2"
12
12
  }
13
13
  }
package/src/callables.js CHANGED
@@ -89,6 +89,7 @@ export const CORE_FUNCTION_NAMES = [
89
89
  'enum_cases',
90
90
  'enum',
91
91
  'template_from_string',
92
+ 'render_sandboxed',
92
93
  ];
93
94
 
94
95
  /**
@@ -21,6 +21,18 @@ import { ArrayExpression } from './array-expression.js';
21
21
  /** Name pattern used to accept word-operators as variable names. */
22
22
  const REGULAR_EXPRESSION_NAME = /^[a-zA-Z_\u007f-\uffff][a-zA-Z0-9_\u007f-\uffff]*$/;
23
23
 
24
+ /**
25
+ * The deepest expression nesting the precedence-climbing parser accepts before
26
+ * it refuses to recurse further. Prefix chains (`not not …`, `---…`), grouped
27
+ * expressions and nested sequences/mappings all recurse through
28
+ * {@link ExpressionParser.parseExpression}; without a bound a crafted template
29
+ * would grow the call stack until it overflows the process.
30
+ *
31
+ * Well below the point at which a real stack overflow occurs, so the failure is
32
+ * a controlled {@link SyntaxError} rather than a `RangeError`.
33
+ */
34
+ const MAX_EXPRESSION_DEPTH = 256;
35
+
24
36
  /**
25
37
  * Binary operator metadata: precedence and associativity.
26
38
  *
@@ -124,6 +136,8 @@ export class ExpressionParser {
124
136
  constructor(parser) {
125
137
  /** @type {import('./parser.js').Parser} */
126
138
  this.parser = parser;
139
+ /** @type {number} Current recursion depth of {@link parseExpression}. */
140
+ this.expressionDepth = 0;
127
141
  }
128
142
 
129
143
  /**
@@ -147,6 +161,29 @@ export class ExpressionParser {
147
161
  * @returns {Node} The parsed expression node.
148
162
  */
149
163
  parseExpression(precedence = 0) {
164
+ if (this.expressionDepth >= MAX_EXPRESSION_DEPTH) {
165
+ const token = this.current();
166
+ throw new SyntaxError(
167
+ 'Expression is too deeply nested.',
168
+ token.getLine(),
169
+ this.getStream().getSourceContext(),
170
+ );
171
+ }
172
+ this.expressionDepth += 1;
173
+ try {
174
+ return this.parseExpressionInternal(precedence);
175
+ } finally {
176
+ this.expressionDepth -= 1;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * The unguarded recursive body of {@link parseExpression}.
182
+ *
183
+ * @param {number} precedence The minimum precedence of infix operators to consume.
184
+ * @returns {Node} The parsed expression node.
185
+ */
186
+ parseExpressionInternal(precedence = 0) {
150
187
  const stream = this.getStream();
151
188
  const token = this.current();
152
189
  let expression;
package/src/node.js CHANGED
@@ -349,43 +349,85 @@ export function n(type, children = {}, attributes = {}, lineNumber = 0, tag = nu
349
349
  }
350
350
 
351
351
  /**
352
- * A tiny FNV-1a hash, used to compare node subtrees cheaply.
352
+ * A streaming FNV-1a hasher, used to compare node subtrees cheaply.
353
353
  *
354
- * @param {string} text
355
- * @returns {string} A base-36 digest.
354
+ * Hashing is incremental so signatures never build intermediate strings: each
355
+ * code unit is folded into the running hash as it is visited.
356
356
  */
357
- function fnv1a(text) {
358
- let hash = 0x811c9dc5;
359
- for (let i = 0; i < text.length; i += 1) {
360
- hash ^= text.charCodeAt(i);
361
- hash = Math.imul(hash, 0x01000193) >>> 0;
357
+ class Fnv1a {
358
+ constructor() {
359
+ /** @type {number} */
360
+ this.hash = 0x811c9dc5;
361
+ }
362
+
363
+ /**
364
+ * @param {number} codeUnit
365
+ */
366
+ byte(codeUnit) {
367
+ this.hash ^= codeUnit;
368
+ this.hash = Math.imul(this.hash, 0x01000193) >>> 0;
369
+ }
370
+
371
+ /**
372
+ * @param {string} text
373
+ */
374
+ str(text) {
375
+ for (let i = 0; i < text.length; i += 1) {
376
+ this.byte(text.charCodeAt(i));
377
+ }
378
+ }
379
+
380
+ /**
381
+ * @returns {string} A base-36 digest.
382
+ */
383
+ digest() {
384
+ return this.hash.toString(36);
362
385
  }
363
- return hash.toString(36);
364
386
  }
365
387
 
366
388
  /**
367
- * Computes a content signature for a value contained in a node (children or
368
- * attributes). Nodes are folded in via their own content signature.
389
+ * Folds a value contained in a node (children or attributes) into `hasher`.
390
+ * Nodes are folded in via their own content signature.
369
391
  *
392
+ * @param {Fnv1a} hasher
370
393
  * @param {unknown} value
371
394
  * @param {WeakMap<Node, string>} cache
372
- * @returns {string}
395
+ * @param {'originalSignature'|'currentSignature'|null} field The node property to capture the signature on, if any.
373
396
  */
374
- function valueSignature(value, cache) {
397
+ function hashValue(hasher, value, cache, field) {
375
398
  if (value instanceof Node) {
376
- return `N${computeSignature(value, cache)}`;
399
+ hasher.str('N');
400
+ hasher.str(computeSignature(value, cache, field));
401
+ return;
377
402
  }
378
403
  if (Array.isArray(value)) {
379
- return `[${value.map((item) => valueSignature(item, cache)).join(',')}]`;
404
+ hasher.str('[');
405
+ for (let i = 0; i < value.length; i += 1) {
406
+ if (i > 0) {
407
+ hasher.str(',');
408
+ }
409
+ hashValue(hasher, value[i], cache, field);
410
+ }
411
+ hasher.str(']');
412
+ return;
380
413
  }
381
414
  if (value !== null && typeof value === 'object') {
382
415
  const object = /** @type {Record<string, unknown>} */ (value);
383
- return `{${Object.keys(object)
384
- .sort()
385
- .map((key) => `${key}:${valueSignature(object[key], cache)}`)
386
- .join(',')}}`;
416
+ hasher.str('{');
417
+ const keys = Object.keys(object).sort();
418
+ for (let i = 0; i < keys.length; i += 1) {
419
+ const key = keys[i];
420
+ if (i > 0) {
421
+ hasher.str(',');
422
+ }
423
+ hasher.str(key);
424
+ hasher.str(':');
425
+ hashValue(hasher, object[key], cache, field);
426
+ }
427
+ hasher.str('}');
428
+ return;
387
429
  }
388
- return String(value);
430
+ hasher.str(String(value));
389
431
  }
390
432
 
391
433
  /**
@@ -393,58 +435,42 @@ function valueSignature(value, cache) {
393
435
  *
394
436
  * @param {Node} node
395
437
  * @param {WeakMap<Node, string>} cache
438
+ * @param {'originalSignature'|'currentSignature'|null} [field] When set, the
439
+ * signature is also stored on this node property, so a single traversal
440
+ * captures the whole tree.
396
441
  * @returns {string}
397
442
  */
398
- export function computeSignature(node, cache) {
443
+ export function computeSignature(node, cache, field = null) {
399
444
  const cached = cache.get(node);
400
445
  if (cached !== undefined) {
401
446
  return cached;
402
447
  }
403
- let material = `${node.type}\u0000${node.tag ?? ''}\u0000${node.explicitParentheses}`;
448
+ const hasher = new Fnv1a();
449
+ hasher.str(node.type);
450
+ hasher.byte(0);
451
+ hasher.str(node.tag ?? '');
452
+ hasher.byte(0);
453
+ hasher.str(String(node.explicitParentheses));
404
454
  for (const key of Object.keys(node.attributes).sort()) {
405
- material += `\u0001${key}=${valueSignature(node.attributes[key], cache)}`;
455
+ hasher.byte(1);
456
+ hasher.str(key);
457
+ hasher.str('=');
458
+ hashValue(hasher, node.attributes[key], cache, field);
406
459
  }
407
460
  for (const key of Object.keys(node.children).sort()) {
408
- material += `\u0002${key}=${valueSignature(node.children[key], cache)}`;
461
+ hasher.byte(2);
462
+ hasher.str(key);
463
+ hasher.str('=');
464
+ hashValue(hasher, node.children[key], cache, field);
409
465
  }
410
- const signature = fnv1a(material);
466
+ const signature = hasher.digest();
411
467
  cache.set(node, signature);
412
- return signature;
413
- }
414
-
415
- /**
416
- * Visits every node reachable from `node` through children and attributes.
417
- *
418
- * @param {Node} node
419
- * @param {(node: Node) => void} callback
420
- * @param {WeakSet<Node>} [seen]
421
- */
422
- function walkNodes(node, callback, seen = new WeakSet()) {
423
- if (seen.has(node)) {
424
- return;
425
- }
426
- seen.add(node);
427
- callback(node);
428
- /** @param {unknown} value */
429
- const visit = (value) => {
430
- if (value instanceof Node) {
431
- walkNodes(value, callback, seen);
432
- } else if (Array.isArray(value)) {
433
- for (const item of value) {
434
- visit(item);
435
- }
436
- } else if (value !== null && typeof value === 'object') {
437
- for (const item of Object.values(/** @type {Record<string, unknown>} */ (value))) {
438
- visit(item);
439
- }
440
- }
441
- };
442
- for (const value of Object.values(node.children)) {
443
- visit(value);
444
- }
445
- for (const value of Object.values(node.attributes)) {
446
- visit(value);
468
+ if (field === 'originalSignature') {
469
+ node.originalSignature = signature;
470
+ } else if (field === 'currentSignature') {
471
+ node.currentSignature = signature;
447
472
  }
473
+ return signature;
448
474
  }
449
475
 
450
476
  /**
@@ -453,11 +479,7 @@ function walkNodes(node, callback, seen = new WeakSet()) {
453
479
  * @param {Node} root
454
480
  */
455
481
  export function captureSignatures(root) {
456
- const cache = new WeakMap();
457
- computeSignature(root, cache);
458
- walkNodes(root, (node) => {
459
- node.originalSignature = cache.get(node) ?? null;
460
- });
482
+ computeSignature(root, new WeakMap(), 'originalSignature');
461
483
  }
462
484
 
463
485
  /**
@@ -466,9 +488,5 @@ export function captureSignatures(root) {
466
488
  * @param {Node} root
467
489
  */
468
490
  export function refreshSignatures(root) {
469
- const cache = new WeakMap();
470
- computeSignature(root, cache);
471
- walkNodes(root, (node) => {
472
- node.currentSignature = cache.get(node) ?? null;
473
- });
491
+ computeSignature(root, new WeakMap(), 'currentSignature');
474
492
  }
package/src/parser.js CHANGED
@@ -40,12 +40,29 @@ import {
40
40
  * @property {(name: string) => CallableDescriptor|null} getTest
41
41
  */
42
42
 
43
+ /**
44
+ * Parser options.
45
+ *
46
+ * @typedef {object} ParserOptions
47
+ * @property {boolean} [captureSignatures] Capture original content signatures
48
+ * for lossless {@link Node#serialize} support (default false). Set to true
49
+ * before parsing when the resulting AST will be mutated and serialized.
50
+ */
51
+
43
52
  /** Name pattern for word-operators used as variable names. */
44
53
  const REGULAR_EXPRESSION_NAME = /^[a-zA-Z_\u007f-\uffff][a-zA-Z0-9_\u007f-\uffff]*$/;
45
54
 
46
55
  /** Reserved literal words that cannot be assigned to. */
47
56
  const RESERVED_WORDS = new Set(['true', 'TRUE', 'false', 'FALSE', 'none', 'NONE', 'null', 'NULL']);
48
57
 
58
+ /**
59
+ * The deepest tag nesting {@link Parser.subparse} accepts. Every block tag
60
+ * (`if`, `for`, `block`, `macro`, `embed`, …) recurses back into `subparse`, so
61
+ * without a bound a template of nested `{% if %}` tags would overflow the call
62
+ * stack. The limit fails with a controlled {@link SyntaxError} first.
63
+ */
64
+ const MAX_SUBPARSE_DEPTH = 256;
65
+
49
66
  /**
50
67
  * An imported macro symbol.
51
68
  *
@@ -81,10 +98,21 @@ const RESERVED_WORDS = new Set(['true', 'TRUE', 'false', 'FALSE', 'none', 'NONE'
81
98
  export class Parser {
82
99
  /**
83
100
  * @param {ParserEnvironment} environment The parser environment.
101
+ * @param {ParserOptions} [options] Parser options.
84
102
  */
85
- constructor(environment) {
103
+ constructor(environment, options = {}) {
86
104
  /** @type {ParserEnvironment} */
87
105
  this.environment = environment;
106
+ /**
107
+ * Whether to capture each node's original content signature while
108
+ * parsing. Signatures back lossless {@link Node#serialize} (they let a
109
+ * node detect whether its subtree was mutated); capturing them hashes
110
+ * the whole tree during parsing, so they are opt-in for consumers that
111
+ * mutate and serialize the AST.
112
+ *
113
+ * @type {boolean}
114
+ */
115
+ this.captureSignatures = options.captureSignatures === true;
88
116
  /** @type {ExpressionParser} */
89
117
  this.expressionParser = new ExpressionParser(this);
90
118
  /** @type {TokenStream|null} */
@@ -117,6 +145,8 @@ export class Parser {
117
145
  this.macroDepth = 0;
118
146
  /** @type {number} */
119
147
  this.capturingNodeDepth = 0;
148
+ /** @type {number} Current recursion depth of {@link Parser.subparse}. */
149
+ this.subparseDepth = 0;
120
150
  /** @type {Array<Node|null>} */
121
151
  this.tagStack = [];
122
152
  /** @type {string|null} */
@@ -242,7 +272,9 @@ export class Parser {
242
272
  module.setAttribute('trailing', stream.getCurrent().leading ?? '');
243
273
 
244
274
  this.correctnessCheck(module);
245
- captureSignatures(module);
275
+ if (this.captureSignatures) {
276
+ captureSignatures(module);
277
+ }
246
278
 
247
279
  // restore the previous state so an enclosing (outer) parse can resume
248
280
  const previous = this.saveStack.pop();
@@ -275,6 +307,30 @@ export class Parser {
275
307
  * @returns {Node} The parsed body (single node or a `nodes` list).
276
308
  */
277
309
  subparse(test = null, dropNeedle = false) {
310
+ if (this.subparseDepth >= MAX_SUBPARSE_DEPTH) {
311
+ const token = this.getCurrentToken();
312
+ throw new SyntaxError(
313
+ 'Template nesting is too deep.',
314
+ token.getLine(),
315
+ this.getStream().getSourceContext(),
316
+ );
317
+ }
318
+ this.subparseDepth += 1;
319
+ try {
320
+ return this.parseUntil(test, dropNeedle);
321
+ } finally {
322
+ this.subparseDepth -= 1;
323
+ }
324
+ }
325
+
326
+ /**
327
+ * The unguarded recursive body of {@link subparse}.
328
+ *
329
+ * @param {((token: Token) => boolean)|null} [test]
330
+ * @param {boolean} [dropNeedle]
331
+ * @returns {Node} The parsed body (single node or a `nodes` list).
332
+ */
333
+ parseUntil(test = null, dropNeedle = false) {
278
334
  const stream = this.getStream();
279
335
  const lineNumber = this.getCurrentToken().getLine();
280
336
  /** @type {Node[]} */