diff --git a/README.md b/README.md
index 4a54b80..6df590a 100644
--- a/README.md
+++ b/README.md
@@ -118,13 +118,14 @@ Both `toTree` and `parse` accept an options object as their second argument.
All options are optional.
-| Option | Type | Description |
-| -------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
-| `filePath` | `string` | Used for language detection. |
-| `tokens` | `boolean` | Generate a flat `ast.tokens` array. Required by ESLint; skipped by default so codemods and type-checkers pay nothing. |
-| `templateOnly` | `boolean` | Parse the source as a raw Glimmer template. Use for `.hbs` files. |
-| `parser` | `(placeholderJS: string) => { ast, ... }` | Use a custom JS/TS parser instead of the default oxc-parser. See [Custom parser](#custom-parser). |
-| `visitors` | `VisitorMap`
or `(outerAst) => VisitorMap` | Callbacks fired on every node during traversal — JS/TS and Glimmer — in a single pass. See [Visitors](#visitors). |
+| Option | Type | Description |
+| ----------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| `filePath` | `string` | Used for language detection. |
+| `tokens` | `boolean` | Generate a flat `ast.tokens` array. Required by ESLint; skipped by default so codemods and type-checkers pay nothing. |
+| `templateOnly` | `boolean` | Parse the source as a raw Glimmer template. Use for `.hbs` files. |
+| `parser` | `(placeholderJS: string) => { ast, ... }` | Use a custom JS/TS parser instead of the default oxc-parser. See [Custom parser](#custom-parser). |
+| `visitors` | `VisitorMap`
or `(outerAst) => VisitorMap` | Callbacks fired on every node during traversal — JS/TS and Glimmer — in a single pass. See [Visitors](#visitors). |
+| `onTemplateError` | `(error, { range, contentRange, path }) => void` | Called when a `` fails to parse; `path` is the visitor path its `GlimmerTemplate` would have had. When provided, parsing continues with that template left as its placeholder instead of throwing. |
Handler signature is `(node, path) => void`, where `path = { node, parent, parentPath }` — a linked list that walks all the way back through the JS/TS root, so visitors can locate the enclosing scope or class from within a Glimmer subtree.
diff --git a/src/index.d.ts b/src/index.d.ts
index f5e62f8..a8b2654 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -37,6 +37,16 @@ export interface ParseOptions {
* Must return at least `{ ast }`.
*/
parser?: (placeholderJS: string) => { ast: ASTNode; [key: string]: unknown };
+ /**
+ * Called when a `` fails to parse, with the error, the template's
+ * offsets in the source, and the visitor path its `GlimmerTemplate` would
+ * have had. When provided, parsing continues and that template is left as
+ * its placeholder instead of the whole parse throwing.
+ */
+ onTemplateError?: (
+ error: Error,
+ template: { range: [number, number]; contentRange: [number, number]; path: VisitorPath },
+ ) => void;
/**
* Callbacks fired on each node during traversal — outer JS/TS nodes AND
* spliced Glimmer subtrees — so callers can gather information or mutate
diff --git a/src/parse.js b/src/parse.js
index 4ce476c..dd5d6b8 100644
--- a/src/parse.js
+++ b/src/parse.js
@@ -70,6 +70,10 @@ const PLACEHOLDER_TYPES = new Set([
* @param {function} [options.parser] - Custom JS/TS parser: (placeholderJS) => { ast, scopeManager?, visitorKeys?, services?, ... }.
* Recommended to return `visitorKeys` describing the parser's AST; when omitted, oxc-parser's
* keys are used (fine for oxc-compatible ASTs, incomplete for parsers that emit bespoke node types).
+ * @param {function} [options.onTemplateError] - Called as `(error, { range, contentRange, path })`
+ * when a `` fails to parse; `path` is the visitor path the GlimmerTemplate would have
+ * had. When provided, parsing continues with that template left as its placeholder instead of
+ * throwing.
* @param {object|function} [options.visitors] - Either a map of `{ [Type]: (node, path) => void }`
* handlers, or a factory `(outerAst) => handlers` invoked once after parsing (before any
* template splicing) to give callers a view of the raw JS/TS tree. Handlers fire on every
@@ -157,7 +161,12 @@ export function toTree(source, options = {}) {
// `placeholderNode` is the original JS/TS node being swapped out; we stash
// it on templateInfos so consumers can forward its parser-services mapping
// (e.g. esTreeNodeToTSNodeMap) onto the GlimmerTemplate that replaces it.
- function processPlaceholder(parseResult, placeholderNode) {
+ //
+ // Returns `null` when the template fails to parse and the caller opted into
+ // `onTemplateError`; the placeholder then stays in the tree untouched.
+ // `placeholderPath` is where the GlimmerTemplate would have gone, handed to
+ // `onTemplateError` so callers can still tell where the template sits.
+ function processPlaceholder(parseResult, placeholderNode, placeholderPath) {
let templateContent = parseResult.contents;
let contentRange = [
parseResult.contentRange.startUtf16Codepoint,
@@ -165,10 +174,18 @@ export function toTree(source, options = {}) {
];
let fullRange = [parseResult.range.startUtf16Codepoint, parseResult.range.endUtf16Codepoint];
- const { ast } = processTemplate(templateContent, codeLines, {
- templateRange: contentRange,
- tokens: generateTokens,
- });
+ let processed;
+ try {
+ processed = processTemplate(templateContent, codeLines, {
+ templateRange: contentRange,
+ tokens: generateTokens,
+ });
+ } catch (error) {
+ if (!options.onTemplateError) throw error;
+ options.onTemplateError(error, { range: fullRange, contentRange, path: placeholderPath });
+ return null;
+ }
+ const { ast } = processed;
// Fix the Template root to cover the full ... range
ast.range = fullRange;
@@ -237,27 +254,51 @@ export function toTree(source, options = {}) {
function walkWithKeys(node, parentPath) {
if (!node || !node.type) return;
+ const path = { node, parent: parentPath?.node ?? null, parentPath };
+
if (hasTemplates && PLACEHOLDER_TYPES.has(node.type)) {
const parseResult = matchPlaceholder(node);
- if (parseResult) {
+ if (parseResult && node.type === "ExportDefaultDeclaration") {
+ // `export default ...`: keep the export wrapper
+ // and splice its declaration, so the tree (and `print`) still carry
+ // the `export default`.
+ const ast = processPlaceholder(parseResult, node.declaration, {
+ node: node.declaration,
+ parent: node,
+ parentPath: path,
+ });
+ if (ast) {
+ node.declaration = ast;
+ setParent(ast, node);
+ if (hasVisitors && !seen.has(node)) {
+ seen.add(node);
+ const handler = visitors[node.type];
+ if (handler) handler(node, path);
+ walkWithKeys(ast, path);
+ }
+ return;
+ }
+ } else if (parseResult) {
// Splice in place: write the GlimmerTemplate directly into the parent's
// slot instead of allocating new ancestor objects. This preserves node
// identity for every ancestor, which matters for WeakMap-keyed data
// held by custom parsers (scope manager, esTreeNodeToTSNodeMap).
- const ast = processPlaceholder(parseResult, node);
- const parent = parentPath?.node ?? null;
- if (parent) replaceInParent(parent, node, ast);
- setParent(ast, parent);
- // Recurse into the Glimmer subtree so visitors fire on its nodes too.
- // The Glimmer root's parentPath reflects its true JS parent — the
- // placeholder (UnaryExpression / StaticBlock) is an internal artifact.
- if (hasVisitors) walkWithKeys(ast, parentPath);
- return;
+ const ast = processPlaceholder(parseResult, node, path);
+ if (ast) {
+ const parent = parentPath?.node ?? null;
+ if (parent) replaceInParent(parent, node, ast);
+ setParent(ast, parent);
+ // Recurse into the Glimmer subtree so visitors fire on its nodes too.
+ // The Glimmer root's parentPath reflects its true JS parent — the
+ // placeholder (`void` expression / StaticBlock) is an internal artifact.
+ if (hasVisitors) walkWithKeys(ast, parentPath);
+ return;
+ }
}
+ // A template that failed to parse under `onTemplateError` falls through
+ // and is walked as the placeholder it still is.
}
- const path = { node, parent: parentPath?.node ?? null, parentPath };
-
if (hasVisitors && !seen.has(node)) {
seen.add(node);
const handler = visitors[node.type];
diff --git a/tests/parse.test.js b/tests/parse.test.js
index cc37a64..a78419b 100644
--- a/tests/parse.test.js
+++ b/tests/parse.test.js
@@ -184,6 +184,14 @@ describe("parse", () => {
]);
});
+ it("keeps the export default wrapper around a default-exported template", () => {
+ const source = `export default hi;\n`;
+ const ast = parse(source);
+
+ expect(ast.program.body.map((node) => node.type)).toEqual(["ExportDefaultDeclaration"]);
+ expect(ast.program.body[0].declaration.type).toBe("GlimmerTemplate");
+ });
+
it("handles multiple classes with templates", () => {
const source = `class A extends Component {
A
diff --git a/tests/print-file.test.js b/tests/print-file.test.js
index a301f47..00031b0 100644
--- a/tests/print-file.test.js
+++ b/tests/print-file.test.js
@@ -11,6 +11,11 @@ describe("print(File)", () => {
`);
});
+ it("round-trips a default-exported template", () => {
+ const source = `export default hi;`;
+ expect(print(toTree(source))).toBe("export default hi");
+ });
+
it("prints an empty File", () => {
const tree = toTree(``, { filePath: "m.js" });
diff --git a/tests/toTree-options.test.js b/tests/toTree-options.test.js
index cbaa24a..ff7618e 100644
--- a/tests/toTree-options.test.js
+++ b/tests/toTree-options.test.js
@@ -86,6 +86,48 @@ describe("toTree — error handling", () => {
});
});
+describe("toTree — onTemplateError", () => {
+ const source = [
+ "import { on } from '@ember/modifier';",
+ "const a = {{on}};",
+ "const b = {{#if}};",
+ "const c = {{a}};",
+ "",
+ ].join("\n");
+
+ it("continues past a template that fails to parse", () => {
+ const errors = [];
+ const visited = [];
+ const ast = toTree(source, {
+ onTemplateError: (error, template) => errors.push({ message: error.message, ...template }),
+ visitors: {
+ GlimmerTemplate: (node) => visited.push(node.start),
+ },
+ });
+
+ expect(errors).toHaveLength(1);
+ expect(errors[0].message).toMatch(/Parse error/);
+ expect(source.slice(...errors[0].range)).toBe("{{#if}}");
+ expect(source.slice(...errors[0].contentRange)).toBe("{{#if}}");
+ // The path is where the GlimmerTemplate would have been spliced.
+ expect(errors[0].path.parent.type).toBe("VariableDeclarator");
+ expect(errors[0].path.parentPath.parent.type).toBe("VariableDeclaration");
+
+ // The two good templates are spliced and visited; the bad one stays as
+ // its placeholder.
+ expect(findAllNodes(ast, "GlimmerTemplate")).toHaveLength(2);
+ expect(visited).toEqual([
+ source.indexOf("{{on}}"),
+ source.indexOf("{{a}}"),
+ ]);
+ expect(ast.program.body[2].declarations[0].init.type).not.toBe("GlimmerTemplate");
+ });
+
+ it("still throws without the option", () => {
+ expect(() => toTree(source)).toThrow(/Parse error/);
+ });
+});
+
describe("toTree — tokens", () => {
it("no tokens by default", () => {
const source = `const x = Hello {{name}};`;