Skip to content

Commit 2a02c3a

Browse files
Peter Stengerclaude
andcommitted
Add format ignore directives, void element fixes, and formatter improvements, bump v0.0.37
- Add htmlmustache-ignore / htmlmustache-ignore-start / htmlmustache-ignore-end directives to skip formatting for specific nodes or regions - Fix void elements inside mustache sections being treated as boundary-crossing (scanner no longer forces closure; classifier skips void elements) - Bail out of formatting when tree has parse errors to avoid mangling content - Re-indent script/style content when nested inside mustache sections - Preserve space between interpolation and text in inline elements - Always keep spaces in mustache comments regardless of mustacheSpaces setting Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7da0a1c commit 2a02c3a

11 files changed

Lines changed: 407 additions & 40 deletions

File tree

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,47 @@ echo '<div><p>hi</p></div>' | htmlmustache format --stdin
135135
| `--print-width N` | Max line width (default: 80) |
136136
| `--mustache-spaces` | Add spaces inside mustache delimiters |
137137

138+
## Format Ignore
139+
140+
Skip formatting for specific regions using ignore directives. Both HTML and Mustache comment forms are supported.
141+
142+
### Ignore Next Node
143+
144+
Place a comment immediately before the element to preserve its original formatting:
145+
146+
```html
147+
<!-- htmlmustache-ignore -->
148+
<div class="a" id="b" >
149+
manually formatted
150+
</div>
151+
```
152+
153+
```html
154+
{{! htmlmustache-ignore }}
155+
<table><tr><td>compact</td><td>table</td></tr></table>
156+
```
157+
158+
Only the immediately following sibling node is ignored. Subsequent nodes are formatted normally.
159+
160+
### Ignore Region
161+
162+
Wrap a region in start/end comments to preserve everything between them:
163+
164+
```html
165+
<!-- htmlmustache-ignore-start -->
166+
<div class="a" >content</div>
167+
<p> kept as-is </p>
168+
<!-- htmlmustache-ignore-end -->
169+
```
170+
171+
```html
172+
{{! htmlmustache-ignore-start }}
173+
{{#items}}<li>{{name}}</li>{{/items}}
174+
{{! htmlmustache-ignore-end }}
175+
```
176+
177+
If `ignore-start` has no matching `ignore-end`, all remaining siblings in the current scope are preserved as raw text.
178+
138179
## Configuration
139180

140181
### `.htmlmustache.jsonc`

cli/src/format.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,54 @@ describe('formatSource', () => {
101101
});
102102
});
103103

104+
describe('script and style in mustache conditionals', () => {
105+
it('re-indents script content inside mustache section', () => {
106+
const input = [
107+
'{{#show}}',
108+
'<script>',
109+
'const x = 1;',
110+
'const y = 2;',
111+
'</script>',
112+
'{{/show}}',
113+
].join('\n');
114+
const result = formatSource(input, defaultOptions);
115+
expect(result).toBe(
116+
[
117+
'{{#show}}',
118+
' <script>',
119+
' const x = 1;',
120+
' const y = 2;',
121+
' </script>',
122+
'{{/show}}',
123+
'',
124+
].join('\n')
125+
);
126+
});
127+
128+
it('re-indents style content inside mustache section', () => {
129+
const input = [
130+
'{{#show}}',
131+
'<style>',
132+
'.foo { color: red; }',
133+
'.bar { color: blue; }',
134+
'</style>',
135+
'{{/show}}',
136+
].join('\n');
137+
const result = formatSource(input, defaultOptions);
138+
expect(result).toBe(
139+
[
140+
'{{#show}}',
141+
' <style>',
142+
' .foo { color: red; }',
143+
' .bar { color: blue; }',
144+
' </style>',
145+
'{{/show}}',
146+
'',
147+
].join('\n')
148+
);
149+
});
150+
});
151+
104152
describe('config file integration', () => {
105153
it('applies config file settings via configFile param', () => {
106154
const config: HtmlMustacheConfig = { indentSize: 4 };

lsp/package.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lsp/server/src/formatting/classifier.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -401,15 +401,17 @@ function hasImplicitEndTagsRecursive(node: SyntaxNode): boolean {
401401
if (node.type === 'html_element') {
402402
let hasStartTag = false;
403403
let hasEndTag = false;
404+
let hasContentChildren = false;
404405
for (let i = 0; i < node.childCount; i++) {
405406
const child = node.child(i);
406407
if (!child) continue;
407408
if (child.type === 'html_start_tag') hasStartTag = true;
408-
if (child.type === 'html_end_tag') hasEndTag = true;
409-
if (child.type === 'html_forced_end_tag') return true; // Explicit implicit end
409+
else if (child.type === 'html_end_tag') hasEndTag = true;
410+
else if (child.type === 'html_forced_end_tag') return true;
411+
else if (!child.type.startsWith('_')) hasContentChildren = true;
410412
}
411-
// If there's a start tag but no end tag at all, it's implicit
412-
if (hasStartTag && !hasEndTag) return true;
413+
// Void elements (start tag only, no content, no end tag) aren't boundary-crossing
414+
if (hasStartTag && !hasEndTag && hasContentChildren) return true;
413415
}
414416

415417
// Check children recursively

lsp/server/src/formatting/formatters.ts

Lines changed: 145 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
getCSSDisplay,
3333
isWhitespaceInsensitive,
3434
} from './classifier';
35-
import { normalizeText, getVisibleChildren, normalizeMustacheWhitespace, normalizeMustacheWhitespaceAll } from './utils';
35+
import { normalizeText, getVisibleChildren, normalizeMustacheWhitespace, normalizeMustacheWhitespaceAll, getIgnoreDirective } from './utils';
3636
import type { CustomCodeTagConfig } from '../customCodeTags';
3737
import { getAttributeValue } from '../customCodeTags';
3838

@@ -298,8 +298,18 @@ export function formatHtmlElement(node: SyntaxNode, context: FormatterContext):
298298
}
299299
} else if (!isBlock && !hasHtmlElementChildren) {
300300
// Inline element with only text/interpolation content - keep tight
301+
// Preserve whitespace gaps between sibling nodes (e.g. space between
302+
// mustache_interpolation and text that tree-sitter puts in the gap)
303+
let prevEnd = startTag ? startTag.endIndex : -1;
301304
for (const child of contentNodes) {
305+
if (prevEnd >= 0 && child.startIndex > prevEnd) {
306+
const gap = context.document.getText().slice(prevEnd, child.startIndex);
307+
if (/\s/.test(gap)) {
308+
parts.push(text(' '));
309+
}
310+
}
302311
parts.push(formatNode(child, context));
312+
prevEnd = child.endIndex;
303313
}
304314
} else {
305315
// Block element or inline-with-block-children: use hardline + indent
@@ -435,7 +445,32 @@ export function formatScriptStyleElement(
435445
parts.push(text(child.text));
436446
}
437447
} else {
438-
parts.push(text(child.text));
448+
// Script/style fallback: dedent and re-emit with hardlines so the
449+
// printer can apply proper indentation from parent context.
450+
const dedented = dedentContent(child.text);
451+
if (dedented.length > 0) {
452+
const contentLines = dedented.split('\n');
453+
if (contentLines.length === 1) {
454+
// Single-line content: keep inline
455+
parts.push(text(contentLines[0]));
456+
} else {
457+
const lineDocs: Doc[] = [];
458+
for (let j = 0; j < contentLines.length; j++) {
459+
if (j > 0) {
460+
if (contentLines[j] === '') {
461+
lineDocs.push('\n');
462+
} else {
463+
lineDocs.push(hardline);
464+
}
465+
}
466+
if (contentLines[j] !== '') {
467+
lineDocs.push(text(contentLines[j]));
468+
}
469+
}
470+
parts.push(indent(concat([hardline, ...lineDocs])));
471+
parts.push(hardline);
472+
}
473+
}
439474
}
440475
}
441476
}
@@ -716,25 +751,115 @@ export function formatBlockChildren(
716751
let lastNodeEnd = -1;
717752
let pendingBlankLine = false;
718753
let blankLineBeforeCurrentLine = false;
754+
let ignoreNext = false;
755+
let inIgnoreRegion = false;
756+
let ignoreRegionStartIndex = -1;
719757

720758
for (let i = 0; i < nodes.length; i++) {
721759
const node = nodes[i];
722760

723-
const treatAsBlock = shouldTreatAsBlock(node, i, nodes);
724-
725-
// Check for whitespace between nodes in original document
726-
if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd) {
761+
// Detect blank lines in gap between nodes (before directive handling)
762+
if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd && !inIgnoreRegion) {
727763
const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
728-
const prevNode = nodes[i - 1];
729-
const prevTreatAsBlock = shouldTreatAsBlock(prevNode, i - 1, nodes);
730-
731-
// Detect blank lines (≥2 newlines) in any gap between nodes
732764
const newlineCount = (gap.match(/\n/g) || []).length;
733765
if (newlineCount >= 2) {
734766
pendingBlankLine = true;
735767
}
768+
}
769+
770+
const directive = getIgnoreDirective(node);
771+
772+
// --- Ignore directive handling ---
773+
774+
// ignore-end: close a region
775+
if (directive === 'ignore-end' && inIgnoreRegion) {
776+
// Flush any pending inline content
777+
if (currentLine.length > 0) {
778+
const lineContent = trimDoc(inlineContentToFill(currentLine));
779+
if (hasDocContent(lineContent)) {
780+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
781+
}
782+
currentLine = [];
783+
blankLineBeforeCurrentLine = false;
784+
}
785+
// Emit raw text from region start to this comment, trimming boundary newlines
786+
const rawText = context.document.getText().slice(ignoreRegionStartIndex, node.startIndex)
787+
.replace(/^\n/, '').replace(/\n$/, '');
788+
if (rawText.length > 0) {
789+
lines.push({ doc: text(rawText), blankLineBefore: false });
790+
}
791+
// Emit the ignore-end comment itself
792+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
793+
lines.push({ doc: text(commentText), blankLineBefore: false });
794+
inIgnoreRegion = false;
795+
ignoreRegionStartIndex = -1;
796+
lastNodeEnd = node.endIndex;
797+
continue;
798+
}
799+
800+
// Inside ignore region: skip (content captured as raw text at ignore-end)
801+
if (inIgnoreRegion) {
802+
lastNodeEnd = node.endIndex;
803+
continue;
804+
}
805+
806+
// ignore-start: begin a region
807+
if (directive === 'ignore-start') {
808+
if (currentLine.length > 0) {
809+
const lineContent = trimDoc(inlineContentToFill(currentLine));
810+
if (hasDocContent(lineContent)) {
811+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
812+
}
813+
currentLine = [];
814+
blankLineBeforeCurrentLine = false;
815+
}
816+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
817+
lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
818+
pendingBlankLine = false;
819+
inIgnoreRegion = true;
820+
ignoreRegionStartIndex = node.endIndex;
821+
lastNodeEnd = node.endIndex;
822+
continue;
823+
}
824+
825+
// ignore (next-node): emit the comment, set flag
826+
if (directive === 'ignore') {
827+
if (currentLine.length > 0) {
828+
const lineContent = trimDoc(inlineContentToFill(currentLine));
829+
if (hasDocContent(lineContent)) {
830+
lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
831+
}
832+
currentLine = [];
833+
blankLineBeforeCurrentLine = false;
834+
}
835+
const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
836+
lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
837+
pendingBlankLine = false;
838+
ignoreNext = true;
839+
lastNodeEnd = node.endIndex;
840+
continue;
841+
}
842+
843+
// Ignored next-node: emit raw text, clear flag
844+
if (ignoreNext) {
845+
lines.push({ doc: text(node.text), blankLineBefore: pendingBlankLine });
846+
pendingBlankLine = false;
847+
ignoreNext = false;
848+
lastNodeEnd = node.endIndex;
849+
continue;
850+
}
851+
852+
// ignore-end without ignore-start: treat as normal comment (fall through)
853+
854+
const treatAsBlock = shouldTreatAsBlock(node, i, nodes);
855+
856+
// Check for whitespace between nodes in original document (inline gap handling)
857+
if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd) {
858+
const prevNode = nodes[i - 1];
859+
const prevTreatAsBlock = shouldTreatAsBlock(prevNode, i - 1, nodes);
736860

737861
if (!prevTreatAsBlock && !treatAsBlock) {
862+
const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
738863
if (/\s/.test(gap)) {
739864
currentLine.push(line);
740865
}
@@ -875,6 +1000,16 @@ export function formatBlockChildren(
8751000
lastNodeEnd = node.endIndex;
8761001
}
8771002

1003+
// Handle unterminated ignore region: emit remaining raw text
1004+
if (inIgnoreRegion && nodes.length > 0) {
1005+
const lastNode = nodes[nodes.length - 1];
1006+
const rawText = context.document.getText().slice(ignoreRegionStartIndex, lastNode.endIndex)
1007+
.replace(/^\n/, '');
1008+
if (rawText.length > 0) {
1009+
lines.push({ doc: text(rawText), blankLineBefore: false });
1010+
}
1011+
}
1012+
8781013
// Flush remaining inline content
8791014
if (currentLine.length > 0) {
8801015
const lineContent = trimDoc(inlineContentToFill(currentLine));

lsp/server/src/formatting/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ export function formatDocument(
7171
setCustomCodeTags(customCodeTags);
7272
}
7373

74+
// Bail out if the tree has parse errors to avoid mangling content
75+
if (tree.rootNode.hasError) {
76+
return [];
77+
}
78+
7479
const configMap = buildConfigMap(customCodeTagConfigs);
7580
const context: FormatterContext = {
7681
document,
@@ -109,6 +114,11 @@ export function formatDocumentRange(
109114
setCustomCodeTags(customCodeTags);
110115
}
111116

117+
// Bail out if the tree has parse errors to avoid mangling content
118+
if (tree.rootNode.hasError) {
119+
return [];
120+
}
121+
112122
// Find nodes that overlap with the range
113123
const startOffset = document.offsetAt(range.start);
114124
const endOffset = document.offsetAt(range.end);

0 commit comments

Comments
 (0)