can1357/oh-my-pi · error

Parse error: unclosed block ${stack[stack.length - 1].node.e

Error message

Parse error: unclosed block ${stack[stack.length - 1].node.expression.name}

What it means

After the main parse loop finishes, parseTemplate() checks that every opened block (`{{#x}}`/`{{^x}}`/`{{else x}}`) was closed. If any block remains on the stack, it throws `Parse error: unclosed block <name>` reporting the innermost unclosed helper name. The template cannot be compiled until all blocks are balanced.

Source

Thrown at packages/utils/src/template.ts:300

			continue;
		}
		if (raw.startsWith("/")) {
			const current = stack.pop();
			if (!current || current.node.expression.name !== raw.slice(1).trim())
				throw new Error(`Parse error: mismatched ${raw}`);
			if (current.inverted) [current.node.body, current.node.inverse] = [current.node.inverse, current.node.body];
			target = current.target;
			continue;
		}
		if (raw.startsWith(">")) target.push({ kind: "partial", expression: parseCall(raw.slice(1)) });
		else
			target.push({
				kind: "output",
				expression: parseCall(raw.startsWith("&") ? raw.slice(1) : raw),
				escaped: !triple && !raw.startsWith("&"),
			});
	}
	if (stack.length) throw new Error(`Parse error: unclosed block ${stack[stack.length - 1].node.expression.name}`);
	return root;
}

function pathParts(path: string): string[] {
	const parts: string[] = [];
	let start = 0;
	let bracketStart = -1;
	for (let index = 0; index <= path.length; index++) {
		const char = path[index];
		if (char === "[" && bracketStart < 0) {
			if (index > start) parts.push(path.slice(start, index).replace(/[./]+$/, ""));
			bracketStart = index + 1;
		} else if (char === "]" && bracketStart >= 0) {
			parts.push(path.slice(bracketStart, index));
			start = index + 1;
			bracketStart = -1;
		} else if ((char === "." || char === "/" || char === undefined) && bracketStart < 0) {
			if (index > start) parts.push(path.slice(start, index));

View on GitHub (pinned to 9690622007)

Solutions

  1. Add the missing `{{/<name>}}` closer for the block named in the error.
  2. Check the end of the template/file for truncation if the block was supposed to be closed.
  3. Audit generated templates so every block-opening emission is paired with a closer emission.

Example fix

// before
{{#each items}}
  {{name}}
// after
{{#each items}}
  {{name}}
{{/each}}
Defensive patterns

Strategy: validation

Validate before calling

function allBlocksClosed(src) {
  let depth = 0;
  for (const t of src.match(/\{\{[#/^][^}]*\}\}/g) ?? []) {
    const raw = t.slice(2, -2).trim();
    if (raw.startsWith('#') || raw.startsWith('^')) depth++;
    else if (raw.startsWith('/')) depth--;
    if (depth < 0) return false;
  }
  return depth === 0;
}
if (!allBlocksClosed(src)) throw new Error('Template has unclosed {{#block}}');

Try / catch

try {
  render(src, data);
} catch (err) {
  const m = /^Parse error: unclosed block (.+)$/.exec(err?.message ?? '');
  if (m) throw new Error(`Template ends with open block {{#${m[1]}}} — add {{/${m[1]}}}`);
  throw err;
}

Prevention

When it happens

Trigger: Rendering a template containing `{{#if ...}}`, `{{#each ...}}`, or `{{^...}}` without a matching `{{/...}}` closer before end of input.

Common situations: Truncated template files; appending new block content and forgetting the closer; dynamically generated templates where a conditional closer was omitted by code.

Understand the failure class

Related errors


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/e2ec9b6e107806cc. Report an issue: GitHub.