Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` <br /> 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` <br /> 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 `<template>` 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.

Expand Down
10 changes: 10 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export interface ParseOptions {
* Must return at least `{ ast }`.
*/
parser?: (placeholderJS: string) => { ast: ASTNode; [key: string]: unknown };
/**
* Called when a `<template>` 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
Expand Down
75 changes: 58 additions & 17 deletions src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<template>` 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
Expand Down Expand Up @@ -157,18 +161,31 @@ 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,
parseResult.contentRange.endUtf16Codepoint,
];
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 <template>...</template> range
ast.range = fullRange;
Expand Down Expand Up @@ -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 <template>...</template>`: 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];
Expand Down
8 changes: 8 additions & 0 deletions tests/parse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ describe("parse", () => {
]);
});

it("keeps the export default wrapper around a default-exported template", () => {
const source = `export default <template>hi</template>;\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 {
<template><div>A</div></template>
Expand Down
5 changes: 5 additions & 0 deletions tests/print-file.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ describe("print(File)", () => {
`);
});

it("round-trips a default-exported template", () => {
const source = `export default <template>hi</template>;`;
expect(print(toTree(source))).toBe("export default <template>hi</template>");
});

it("prints an empty File", () => {
const tree = toTree(``, { filePath: "m.js" });

Expand Down
42 changes: 42 additions & 0 deletions tests/toTree-options.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,48 @@ describe("toTree — error handling", () => {
});
});

describe("toTree — onTemplateError", () => {
const source = [
"import { on } from '@ember/modifier';",
"const a = <template>{{on}}</template>;",
"const b = <template>{{#if}}</template>;",
"const c = <template>{{a}}</template>;",
"",
].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("<template>{{#if}}</template>");
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("<template>{{on}}"),
source.indexOf("<template>{{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 = <template>Hello {{name}}</template>;`;
Expand Down
Loading