Skip to content
Closed
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
00d1de6
feat: add Rust/WASM template parser using pest.rs
NullVoxPopuli Apr 12, 2026
c64ce61
fix: resolve type checking and lint errors in preprocess-rust.ts
NullVoxPopuli Apr 12, 2026
d18836f
style: fix Prettier formatting in preprocess-rust.ts
NullVoxPopuli Apr 12, 2026
fd0d205
refactor: delete @handlebars/parser and old multipass pipeline
NullVoxPopuli Apr 12, 2026
1b6cb1a
chore: commit pre-built WASM binaries for CI
NullVoxPopuli Apr 12, 2026
fe59085
fix: resolve WASM parser as a proper dependency via rollup
NullVoxPopuli Apr 12, 2026
6ed2d18
fix: support @Component tags and mustache comments in elements
NullVoxPopuli Apr 12, 2026
085c650
fix: support dotted component tags and lowercase @-tags
NullVoxPopuli Apr 12, 2026
f0bee43
refactor: move Rust parser into @glimmer/syntax root, remove simple-h…
NullVoxPopuli Apr 12, 2026
4681a82
fix: use universal WASM wrapper that works in Node and browsers
NullVoxPopuli Apr 12, 2026
47f93dc
fix: support namespaced and lowercase-dotted component tags
NullVoxPopuli Apr 12, 2026
52413e2
fix: don't consume {{else}} and {{/...}} as TopLevelStatement in blocks
NullVoxPopuli Apr 12, 2026
158de4f
fix: triple mustache {{{...}}} correctly sets trusting=true
NullVoxPopuli Apr 13, 2026
4852b90
fix: triple curlies in attrs, slashed identifiers in paths
NullVoxPopuli Apr 13, 2026
3d74e97
fix: consolidate tag grammar, add NullLiteral value, dash-prefixed id…
NullVoxPopuli Apr 13, 2026
c2127f4
fix: remove RawElement special-case so <script>/<style> parse mustaches
NullVoxPopuli Apr 13, 2026
0f9d5bf
fix: remove parts field from Rust output, add as non-enumerable getter
NullVoxPopuli Apr 13, 2026
2791f69
fix: remove escaped/chained/inverse divergence from reference AST
NullVoxPopuli Apr 13, 2026
6abd582
fix: decode HTML entities in text node content
NullVoxPopuli Apr 13, 2026
ef3468a
fix: VoidTagName is case-sensitive (PascalCase are components)
NullVoxPopuli Apr 13, 2026
d08b4b3
fix: transform {{else if}} chains into nested BlockStatement
NullVoxPopuli Apr 13, 2026
5916401
fix: self-closing void tags and mustache comments inside element tags
NullVoxPopuli Apr 13, 2026
de4f61a
feat: apply strip-flag whitespace stripping in JS wrapper
NullVoxPopuli Apr 13, 2026
f7273e2
fix: element tag paths use correct PathHead type (this/@/var)
NullVoxPopuli Apr 13, 2026
31df8ef
fix: reorder ElementContent so BlockParams wins over AttrNameOnly
NullVoxPopuli Apr 13, 2026
710f3cb
fix: drop empty text nodes left behind by whitespace stripping
NullVoxPopuli Apr 13, 2026
6e2754b
fix: literals shouldn't emit original, block inner strip, UndefinedLi…
NullVoxPopuli Apr 13, 2026
c2adf91
chore: fix lint errors in whitespace stripping logic
NullVoxPopuli Apr 13, 2026
6188b52
chore: remove old pipeline files accidentally re-added
NullVoxPopuli Apr 13, 2026
e54f2cd
feat: standalone block stripping for {{#block}} alone on a line
NullVoxPopuli Apr 13, 2026
af4dc49
feat: adjust loc.start/end when stripping text node whitespace
NullVoxPopuli Apr 13, 2026
118168d
fix: element.path loc uses tag name span, not whole element
NullVoxPopuli Apr 13, 2026
d8527fc
perf: shrink wasm, lazy init, named import to improve tree-shaking
NullVoxPopuli Apr 13, 2026
eeabc4d
perf: sideEffects false, revert to sync base64 init
NullVoxPopuli Apr 13, 2026
635f77f
Add semantic error detection for void close tags and weird unclosed e…
NullVoxPopuli Apr 13, 2026
edc604a
Fix lint: replace non-null assertion with explicit truthiness check
NullVoxPopuli Apr 13, 2026
e87b33c
Fix prettier format
NullVoxPopuli Apr 13, 2026
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
4 changes: 1 addition & 3 deletions .github/workflows/ci-jobs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ jobs:
run: pnpm build:types
- name: Check internal types
run: pnpm type-check:internals
- name: Check @handlebars/parser types
run: pnpm type-check:handlebars
- name: Check published types
run: pnpm type-check:types

Expand Down Expand Up @@ -222,7 +220,7 @@ jobs:
SHOULD_TRANSPILE_FOR_NODE: true
run: pnpm build
- name: test
run: pnpm test:node && pnpm --filter "@handlebars/parser" test
run: pnpm test:node

blueprint-test:
name: Blueprint Tests
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/glimmer-syntax-prettier-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ on:
- "packages/@glimmer/interfaces/**"
- "packages/@glimmer/util/**"
- "packages/@glimmer/wire-format/**"
- "packages/@handlebars/parser/**"
pull_request:
paths:
- ".github/workflows/glimmer-syntax-prettier-smoke-test.yml"
Expand All @@ -27,7 +26,6 @@ on:
- "packages/@glimmer/interfaces/**"
- "packages/@glimmer/util/**"
- "packages/@glimmer/wire-format/**"
- "packages/@handlebars/parser/**"
workflow_dispatch:

permissions:
Expand Down
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ dist-prod
lib/*/tests/all.js
lib/*/tests/qunit*
lib/bundler/man
pkg
/pkg
rdoc
spade-boot.js
spec/reports
Expand Down Expand Up @@ -55,3 +55,7 @@ npm-debug.log
# couple of the files. Once it is, we can switch this over to just ignoring
# `types/stable` entirely.
types/stable

# Rust build artifacts (WASM output in pkg/ is committed)
packages/@glimmer/syntax/target/
packages/@glimmer/syntax/Cargo.lock
6 changes: 6 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,9 @@ package.json
pnpm-lock.yaml
internal-docs/**/*.md
tracerbench-testing/
packages/@glimmer/syntax/target/
packages/@glimmer/syntax/pkg/
packages/@glimmer/syntax/Cargo.lock
packages/@glimmer/syntax/src/
*.pest
*.wasm
32 changes: 3 additions & 29 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@ export default [
'**/type-tests/',
'internal-docs/guides/**',
'packages/@glimmer-workspace/**',
'packages/@handlebars/parser/lib/parser.js',
'packages/@handlebars/parser/src/**',
'packages/@glimmer/syntax/pkg/**',
'packages/@glimmer/syntax/src/**',
'packages/@glimmer/syntax/target/**',
'tracerbench-testing/',
],
},
Expand Down Expand Up @@ -171,33 +172,6 @@ export default [
'ember-internal/no-const-outside-module-scope': 'error',
},
},
{
files: ['packages/@handlebars/**/*.js'],

languageOptions: {
ecmaVersion: 2017,
sourceType: 'module',
},

rules: {
'ember-internal/require-yuidoc-access': 'off',
'ember-internal/no-const-outside-module-scope': 'off',
'disable-features/disable-async-await': 'off',
'disable-features/disable-generator-functions': 'off',
'no-implicit-coercion': 'off',
'no-unused-vars': 'off',
'import/namespace': 'off',
},
},
{
files: ['packages/@handlebars/parser/spec/**/*.js'],

languageOptions: {
globals: {
...globals.mocha,
},
},
},
{
files: [
'packages/*/tests/**/*.[jt]s',
Expand Down
2 changes: 0 additions & 2 deletions internal-docs/guides/development/build-constraints.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,6 @@ The build system has specific rules for what gets inlined vs treated as external
- TypeScript helper library (`tslib`)

**Always External:**
- `@handlebars/parser`
- `simple-html-tokenizer`
- `babel-plugin-debug-macros`
- Other `@glimmer/*` packages (to avoid duplication)
- `@simple-dom/*` packages
Expand Down
4 changes: 1 addition & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
"test:browserstack": "node bin/run-browserstack-tests.js",
"test:wip": "vite build --mode development --minify false && testem ci",
"type-check:internals": "tsc --noEmit",
"type-check:handlebars": "tsc --noEmit --project packages/@handlebars/parser/tsconfig.json",
"type-check:types": "tsc --noEmit --project type-tests",
"type-check": "npm-run-all type-check:*"
},
Expand All @@ -76,8 +75,7 @@
"inflection": "^2.0.1",
"route-recognizer": "^0.3.4",
"semver": "^7.5.2",
"silent-error": "^1.1.1",
"simple-html-tokenizer": "^0.5.11"
"silent-error": "^1.1.1"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.731.0",
Expand Down
125 changes: 45 additions & 80 deletions packages/@glimmer-workspace/integration-tests/lib/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import type { Nullable, SimpleElement, SimpleNode } from '@glimmer/interfaces';
import type { EndTag, Token } from 'simple-html-tokenizer';
import { COMMENT_NODE, TEXT_NODE } from '@glimmer/constants';
import { castToSimple, unwrap } from '@glimmer/debug-util';
import { tokenize } from 'simple-html-tokenizer';
import { unwrap } from '@glimmer/debug-util';

import { replaceHTML, toInnerHTML } from './dom/simple-utils';
import { toInnerHTML } from './dom/simple-utils';

export type IndividualSnapshot = 'up' | 'down' | SimpleNode;
export type NodesSnapshot = IndividualSnapshot[];
Expand All @@ -13,6 +11,40 @@ export function snapshotIsNode(snapshot: IndividualSnapshot): snapshot is Simple
return snapshot !== 'up' && snapshot !== 'down';
}

// -- HTML equivalence (replaces simple-html-tokenizer) ----------------------
//
// Compares two HTML fragments for semantic equivalence by normalizing
// attribute order via the browser's DOM parser. Ember ID attributes
// are normalized to a stable counter so order-independent tests pass.

function normalizeHTML(html: string): string {
const container = document.createElement('div');
container.innerHTML = html;
sortAttributes(container);
return container.innerHTML;
}

function sortAttributes(element: Element): void {
for (const child of Array.from(element.children)) {
if (child.attributes.length > 1) {
const attrs = Array.from(child.attributes).map((a) => [a.name, a.value] as const);
attrs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
for (const [name] of attrs) {
child.removeAttribute(name);
}
for (const [name, value] of attrs) {
child.setAttribute(name, value);
}
}
sortAttributes(child);
}
}

function cleanEmberIds(html: string): string {
let id = 0;
return html.replace(/ember(\d+|\*)/gu, () => `ember${++id}`);
}

export function equalTokens(
testFragment: SimpleElement | string | null,
testHTML: SimpleElement | string,
Expand All @@ -22,41 +54,17 @@ export function equalTokens(
throw new Error(`Unexpectedly passed null to equalTokens`);
}

const fragTokens = generateTokens(testFragment);
const htmlTokens = generateTokens(testHTML);

cleanEmberIds(fragTokens.tokens);
cleanEmberIds(htmlTokens.tokens);

const equiv = QUnit.equiv(fragTokens.tokens, htmlTokens.tokens);

if (equiv && fragTokens.html !== htmlTokens.html) {
QUnit.assert.deepEqual(
fragTokens.tokens,
htmlTokens.tokens,
message || 'expected tokens to match'
);
} else {
QUnit.assert.pushResult({
result: QUnit.equiv(fragTokens.tokens, htmlTokens.tokens),
actual: fragTokens.html,
expected: htmlTokens.html,
message: message || 'expected tokens to match',
});
}

// QUnit.assert.deepEqual(fragTokens.tokens, htmlTokens.tokens, msg);
}

function cleanEmberIds(tokens: Token[]) {
let id = 0;
const fragHTML = typeof testFragment === 'string' ? testFragment : toInnerHTML(testFragment);
const expectedHTML = typeof testHTML === 'string' ? testHTML : toInnerHTML(testHTML);

tokens.forEach((token) => {
const idAttr = 'attributes' in token && token.attributes.filter((a) => a[0] === 'id')[0];
const normalizedFrag = cleanEmberIds(normalizeHTML(fragHTML));
const normalizedExpected = cleanEmberIds(normalizeHTML(expectedHTML));

if (idAttr) {
idAttr[1] = idAttr[1].replace(/ember(\d+|\*)/u, `ember${++id}`);
}
QUnit.assert.pushResult({
result: normalizedFrag === normalizedExpected,
actual: fragHTML,
expected: expectedHTML,
message: message || 'expected tokens to match',
});
}

Expand Down Expand Up @@ -86,49 +94,6 @@ export function generateSnapshot(element: SimpleElement): SimpleNode[] {
return snapshot;
}

function generateTokens(divOrHTML: SimpleElement | string): { tokens: Token[]; html: string } {
let div: SimpleElement;
if (typeof divOrHTML === 'string') {
div = castToSimple(document.createElement('div'));
replaceHTML(div, divOrHTML);
} else {
div = divOrHTML;
}

let tokens = tokenize(toInnerHTML(div), {});

tokens = tokens.reduce((tokens, token) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison
if (token.type === 'StartTag') {
if (token.attributes) {
token.attributes.sort((a, b) => {
if (a[0] > b[0]) {
return 1;
}
if (a[0] < b[0]) {
return -1;
}
return 0;
});
}

if (token.selfClosing) {
token.selfClosing = false;
tokens.push(token);
tokens.push({ type: 'EndTag', tagName: token.tagName } as EndTag);
} else {
tokens.push(token);
}
} else {
tokens.push(token);
}

return tokens;
}, new Array<Token>());

return { tokens, html: toInnerHTML(div) };
}

export function equalSnapshots(a: SimpleNode[], b: SimpleNode[]) {
QUnit.assert.strictEqual(a.length, b.length, 'Same number of nodes');
for (let i = 0; i < b.length; i++) {
Expand Down
3 changes: 1 addition & 2 deletions packages/@glimmer-workspace/integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,7 @@
"@simple-dom/serializer": "^1.4.0",
"@simple-dom/void-map": "^1.4.0",
"js-reporters": "^2.1.0",
"qunit": "^2.24.1",
"simple-html-tokenizer": "^0.5.11"
"qunit": "^2.24.1"
},
"devDependencies": {
"@ember/runloop": "workspace:*",
Expand Down
20 changes: 20 additions & 0 deletions packages/@glimmer/syntax/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "glimmer-template-parser"
version = "0.1.0"
edition = "2024"
description = "A Rust-based PEG parser for Glimmer/Handlebars templates"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
pest = "2"
pest_derive = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
wasm-bindgen = "0.2"
serde-wasm-bindgen = "0.6"

[profile.release]
opt-level = "z"
lto = true
34 changes: 34 additions & 0 deletions packages/@glimmer/syntax/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"

echo "🦀 Building Glimmer template parser (WASM)..."

# Clean previous builds
rm -rf pkg/standalone pkg/wasm-bytes.mjs

# Build only the web (standalone) target. We use a universal wrapper
# (pkg/universal.mjs) that inlines the WASM bytes as base64, so there's
# no need for separate Node/bundler targets.
echo " → Building web target..."
wasm-pack build --target web --out-dir pkg/standalone 2>&1 | grep -v "^warning:" || true

# Remove files that cause build:types issues
rm -f pkg/standalone/.gitignore
rm -f pkg/standalone/glimmer_template_parser_bg.wasm.d.ts

# Generate the base64-encoded WASM bytes module for the universal wrapper
echo " → Generating wasm-bytes.mjs..."
node -e "
const fs = require('fs');
const wasm = fs.readFileSync('pkg/standalone/glimmer_template_parser_bg.wasm');
const base64 = wasm.toString('base64');
const content = 'export const WASM_BYTES_BASE64 = ' + JSON.stringify(base64) + ';\n';
fs.writeFileSync('pkg/wasm-bytes.mjs', content);
console.log(' wasm-bytes.mjs: ' + (content.length / 1024).toFixed(1) + 'KB');
"

echo "✅ Build complete!"
ls -lh pkg/standalone/glimmer_template_parser_bg.wasm pkg/wasm-bytes.mjs pkg/universal.mjs
Loading
Loading