Skip to content

Commit e54f2cd

Browse files
NullVoxPopuliclaude
andcommitted
feat: standalone block stripping for {{#block}} alone on a line
Implement the handlebars 'standalone' stripping rule: when a block statement or mustache comment is alone on its line (only whitespace around it from the previous newline to the next newline), strip the surrounding whitespace. My implementation is conservative: it trims trailing inline whitespace from the previous text node (leaving the preceding newline intact), and consumes the leading newline plus any inline whitespace from the next text node. This preserves text nodes at body boundaries so downstream code that expects a specific body indexing pattern still works. Applies to BlockStatement and MustacheCommentStatement only. MustacheStatement is not eligible for standalone stripping (matches legacy behavior). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 6188b52 commit e54f2cd

1 file changed

Lines changed: 76 additions & 0 deletions

File tree

packages/@glimmer/syntax/lib/parser/tokenizer-event-handlers.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ function cleanupStripFlags(node: unknown): void {
326326
}
327327

328328
function stripBodyWhitespace(body: Stripable[]): void {
329+
// Pass 1: apply explicit strip flags (~) and BlockStatement inner strips.
329330
for (let i = 0; i < body.length; i++) {
330331
const stmt = body[i];
331332
if (!stmt) continue;
@@ -373,6 +374,11 @@ function stripBodyWhitespace(body: Stripable[]): void {
373374
}
374375
}
375376

377+
// Pass 2: standalone stripping. If a block/comment is alone on its line
378+
// (only whitespace before it back to a newline and only whitespace after
379+
// it forward to a newline), strip that whitespace including the newlines.
380+
applyStandaloneStripping(body);
381+
376382
// Drop any text nodes that are now empty after stripping.
377383
for (let i = body.length - 1; i >= 0; i--) {
378384
const stmt = body[i];
@@ -382,6 +388,76 @@ function stripBodyWhitespace(body: Stripable[]): void {
382388
}
383389
}
384390

391+
function isStandaloneCandidate(stmt: Stripable | undefined): boolean {
392+
if (!stmt) return false;
393+
// BlockStatement gets standalone treatment on its open and close tags
394+
// independently; MustacheCommentStatement too. MustacheStatement does
395+
// NOT get standalone stripping in the legacy parser.
396+
return stmt.type === 'BlockStatement' || stmt.type === 'MustacheCommentStatement';
397+
}
398+
399+
function applyStandaloneStripping(body: Stripable[]): void {
400+
for (let i = 0; i < body.length; i++) {
401+
const stmt = body[i];
402+
if (!isStandaloneCandidate(stmt)) continue;
403+
404+
const prev = body[i - 1];
405+
const next = body[i + 1];
406+
407+
// For a block to be standalone:
408+
// 1. The text before it (back to the previous newline or start) must
409+
// be whitespace only.
410+
// 2. The text after it (forward to the next newline or end) must be
411+
// whitespace only.
412+
// 3. At least one side must contain a real newline (otherwise it's
413+
// just inline whitespace around the block).
414+
const prevOk = isEmptyOrWhitespaceToNewline(prev, 'backward');
415+
const nextOk = isEmptyOrWhitespaceToNewline(next, 'forward');
416+
const hasNewline = containsNewline(prev) || containsNewline(next);
417+
418+
if (prevOk && nextOk && hasNewline) {
419+
// Strip trailing inline whitespace on prev (up to but NOT including
420+
// the preceding newline — the newline itself marks where the content
421+
// on the standalone line ends, so we leave it in place). The next
422+
// text has its leading newline consumed instead.
423+
if (prev?.type === 'TextNode' && typeof prev.chars === 'string') {
424+
prev.chars = prev.chars.replace(/[ \t]+$/u, '');
425+
}
426+
// Strip leading whitespace + the trailing newline from next.
427+
if (next?.type === 'TextNode' && typeof next.chars === 'string') {
428+
next.chars = next.chars.replace(/^[ \t]*(?:\r\n|\r|\n)/u, '');
429+
}
430+
}
431+
}
432+
}
433+
434+
function containsNewline(node: Stripable | undefined): boolean {
435+
if (!node || node.type !== 'TextNode') return false;
436+
return /[\r\n]/u.test(node.chars ?? '');
437+
}
438+
439+
// Check that `node` exists and (going backward from its end or forward from
440+
// its start) has only whitespace until the next newline, or reaches the
441+
// start/end of the body.
442+
function isEmptyOrWhitespaceToNewline(
443+
node: Stripable | undefined,
444+
direction: 'backward' | 'forward'
445+
): boolean {
446+
if (!node) return true; // body boundary
447+
if (node.type !== 'TextNode') return false;
448+
const chars = node.chars ?? '';
449+
if (direction === 'backward') {
450+
// The tail (from last newline to end) must be only whitespace.
451+
const lastNewline = Math.max(chars.lastIndexOf('\n'), chars.lastIndexOf('\r'));
452+
const tail = lastNewline === -1 ? chars : chars.slice(lastNewline + 1);
453+
return /^[ \t]*$/u.test(tail);
454+
} else {
455+
// The head (up to first newline) must be only whitespace.
456+
const match = chars.match(/^[ \t]*(?:\r\n|\r|\n|$)/u);
457+
return match !== null;
458+
}
459+
}
460+
385461
function stripFirstTextLeading(body: Stripable[]): void {
386462
const first = body[0];
387463
if (first?.type === 'TextNode' && typeof first.chars === 'string') {

0 commit comments

Comments
 (0)