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
- Add the missing `{{/<name>}}` closer for the block named in the error.
- Check the end of the template/file for truncation if the block was supposed to be closed.
- 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
- Write the closing tag immediately after the opening one and fill in the body between.
- Validate template files parse in CI before they ship.
- Watch for truncation when generating templates from code or copying between files.
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
- Parsing and encoding errors: unexpected token, malformed input — why parsers reject input and how to find the real culprit.
Related errors
- Parse error: unclosed comment
- Parse error: unexpected else
- Parse error: mismatched ${raw}
- Missing helper: "${call.name}"
- The partial ${node.expression.name} could not be found
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/e2ec9b6e107806cc.
Report an issue: GitHub.