@reteps/tree-sitter-htmlmustache 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/browser/out/browser/index.d.ts +43 -0
  3. package/browser/out/browser/index.d.ts.map +1 -0
  4. package/browser/out/browser/index.mjs +3612 -0
  5. package/browser/out/browser/index.mjs.map +7 -0
  6. package/browser/out/core/collectErrors.d.ts +36 -0
  7. package/browser/out/core/collectErrors.d.ts.map +1 -0
  8. package/browser/out/core/configSchema.d.ts +63 -0
  9. package/browser/out/core/configSchema.d.ts.map +1 -0
  10. package/browser/out/core/customCodeTags.d.ts +34 -0
  11. package/browser/out/core/customCodeTags.d.ts.map +1 -0
  12. package/browser/out/core/diagnostic.d.ts +24 -0
  13. package/browser/out/core/diagnostic.d.ts.map +1 -0
  14. package/browser/out/core/embeddedRegions.d.ts +12 -0
  15. package/browser/out/core/embeddedRegions.d.ts.map +1 -0
  16. package/browser/out/core/formatting/classifier.d.ts +68 -0
  17. package/browser/out/core/formatting/classifier.d.ts.map +1 -0
  18. package/browser/out/core/formatting/embedded.d.ts +19 -0
  19. package/browser/out/core/formatting/embedded.d.ts.map +1 -0
  20. package/browser/out/core/formatting/formatters.d.ts +85 -0
  21. package/browser/out/core/formatting/formatters.d.ts.map +1 -0
  22. package/browser/out/core/formatting/index.d.ts +44 -0
  23. package/browser/out/core/formatting/index.d.ts.map +1 -0
  24. package/browser/out/core/formatting/ir.d.ts +100 -0
  25. package/browser/out/core/formatting/ir.d.ts.map +1 -0
  26. package/browser/out/core/formatting/mergeOptions.d.ts +18 -0
  27. package/browser/out/core/formatting/mergeOptions.d.ts.map +1 -0
  28. package/browser/out/core/formatting/printer.d.ts +18 -0
  29. package/browser/out/core/formatting/printer.d.ts.map +1 -0
  30. package/browser/out/core/formatting/utils.d.ts +39 -0
  31. package/browser/out/core/formatting/utils.d.ts.map +1 -0
  32. package/browser/out/core/grammar.d.ts +3 -0
  33. package/browser/out/core/grammar.d.ts.map +1 -0
  34. package/browser/out/core/htmlBalanceChecker.d.ts +23 -0
  35. package/browser/out/core/htmlBalanceChecker.d.ts.map +1 -0
  36. package/browser/out/core/mustacheChecks.d.ts +24 -0
  37. package/browser/out/core/mustacheChecks.d.ts.map +1 -0
  38. package/browser/out/core/nodeHelpers.d.ts +54 -0
  39. package/browser/out/core/nodeHelpers.d.ts.map +1 -0
  40. package/browser/out/core/ruleMetadata.d.ts +12 -0
  41. package/browser/out/core/ruleMetadata.d.ts.map +1 -0
  42. package/browser/out/core/selectorMatcher.d.ts +74 -0
  43. package/browser/out/core/selectorMatcher.d.ts.map +1 -0
  44. package/cli/out/main.js +133 -115
  45. package/package.json +21 -3
  46. package/src/browser/browser.test.ts +207 -0
  47. package/src/browser/index.ts +128 -0
  48. package/src/browser/tsconfig.json +18 -0
  49. package/src/core/collectErrors.ts +233 -0
  50. package/src/core/configSchema.ts +273 -0
  51. package/src/core/customCodeTags.ts +159 -0
  52. package/src/core/diagnostic.ts +45 -0
  53. package/src/core/embeddedRegions.ts +70 -0
  54. package/src/core/formatting/classifier.ts +549 -0
  55. package/src/core/formatting/embedded.ts +56 -0
  56. package/src/core/formatting/formatters.ts +1272 -0
  57. package/src/core/formatting/index.ts +185 -0
  58. package/src/core/formatting/ir.ts +202 -0
  59. package/src/core/formatting/mergeOptions.ts +34 -0
  60. package/src/core/formatting/printer.ts +242 -0
  61. package/src/core/formatting/utils.ts +193 -0
  62. package/src/core/grammar.ts +2 -0
  63. package/src/core/htmlBalanceChecker.ts +382 -0
  64. package/src/core/mustacheChecks.ts +504 -0
  65. package/src/core/nodeHelpers.ts +126 -0
  66. package/src/core/ruleMetadata.ts +63 -0
  67. package/src/core/selectorMatcher.ts +719 -0
@@ -0,0 +1,1272 @@
1
+ /**
2
+ * Formatters - convert AST nodes to Doc IR.
3
+ *
4
+ * This module converts tree-sitter AST nodes to the Doc intermediate
5
+ * representation. It uses CSS display-based classification to determine
6
+ * whitespace sensitivity and wraps elements in groups so the printer
7
+ * can decide flat vs break based on print width.
8
+ */
9
+
10
+ import type { Node as SyntaxNode } from 'web-tree-sitter';
11
+ import type { TextDocument } from 'vscode-languageserver-textdocument';
12
+ import {
13
+ Doc,
14
+ concat,
15
+ fill,
16
+ hardline,
17
+ softline,
18
+ line,
19
+ indent,
20
+ indentN,
21
+ group,
22
+ text,
23
+ empty,
24
+ ifBreak,
25
+ isLine,
26
+ } from './ir.js';
27
+ import {
28
+ isBlockLevel,
29
+ shouldPreserveContent,
30
+ hasImplicitEndTags,
31
+ isInTextFlow,
32
+ shouldTreatAsBlock,
33
+ getCSSDisplay,
34
+ isWhitespaceInsensitive,
35
+ } from './classifier.js';
36
+ import { normalizeText, getVisibleChildren, normalizeMustacheWhitespace, normalizeMustacheWhitespaceAll, getIgnoreDirective, getTagName } from './utils.js';
37
+ import type { CustomCodeTagConfig } from '../customCodeTags.js';
38
+ import { getAttributeValue } from '../customCodeTags.js';
39
+ import { isRawContentElement } from '../nodeHelpers.js';
40
+ import type { NoBreakDelimiter } from '../configSchema.js';
41
+
42
+ export interface FormatterContext {
43
+ document: TextDocument;
44
+ customTags?: Map<string, CustomCodeTagConfig>;
45
+ embeddedFormatted?: Map<number, string>;
46
+ mustacheSpaces?: boolean;
47
+ noBreakDelimiters?: NoBreakDelimiter[];
48
+ }
49
+
50
+ /**
51
+ * Check if an attribute value is truthy (not null, empty, "false", or "0").
52
+ */
53
+ export function isAttributeTruthy(value: string | null): boolean {
54
+ if (value === null || value === '' || value === 'false' || value === '0') {
55
+ return false;
56
+ }
57
+ return true;
58
+ }
59
+
60
+ /**
61
+ * Dedent content by stripping leading/trailing empty lines and removing the
62
+ * minimum common indentation from all non-empty lines.
63
+ */
64
+ export function dedentContent(rawContent: string): string {
65
+ const lines = rawContent.split('\n');
66
+
67
+ // Strip leading empty lines
68
+ while (lines.length > 0 && lines[0].trim() === '') {
69
+ lines.shift();
70
+ }
71
+ // Strip trailing empty lines
72
+ while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
73
+ lines.pop();
74
+ }
75
+
76
+ if (lines.length === 0) return '';
77
+
78
+ // Find minimum indentation across non-empty lines
79
+ let minIndent = Infinity;
80
+ for (const l of lines) {
81
+ if (l.trim() === '') continue;
82
+ const match = l.match(/^(\s*)/);
83
+ if (match && match[1].length < minIndent) {
84
+ minIndent = match[1].length;
85
+ }
86
+ }
87
+ if (minIndent === Infinity) minIndent = 0;
88
+
89
+ // Strip common indent
90
+ return lines.map(l => l.trim() === '' ? '' : l.slice(minIndent)).join('\n');
91
+ }
92
+
93
+ /**
94
+ * Resolve whether a custom code tag's content should be indented.
95
+ */
96
+ function resolveIndentMode(
97
+ node: SyntaxNode,
98
+ config: CustomCodeTagConfig
99
+ ): boolean {
100
+ const mode = config.indent ?? 'never';
101
+ if (mode === 'never') return false;
102
+ if (mode === 'always') return true;
103
+ // mode === 'attribute'
104
+ if (!config.indentAttribute) return false;
105
+ const value = getAttributeValue(node, config.indentAttribute);
106
+ return isAttributeTruthy(value);
107
+ }
108
+
109
+ function getTagNameFromStartTag(startTag: SyntaxNode): string | null {
110
+ for (let i = 0; i < startTag.childCount; i++) {
111
+ const child = startTag.child(i);
112
+ if (child?.type === 'html_tag_name') return child.text.toLowerCase();
113
+ }
114
+ return null;
115
+ }
116
+
117
+ function mustacheText(raw: string, context: FormatterContext): string {
118
+ if (context.mustacheSpaces !== undefined) {
119
+ return normalizeMustacheWhitespace(raw, context.mustacheSpaces);
120
+ }
121
+ return raw;
122
+ }
123
+
124
+ /**
125
+ * Format the document root node.
126
+ */
127
+ export function formatDocument(node: SyntaxNode, context: FormatterContext): Doc {
128
+ const children = getVisibleChildren(node);
129
+ const content = formatBlockChildren(children, context);
130
+ return concat([content, hardline]);
131
+ }
132
+
133
+ /**
134
+ * Format a node based on its type.
135
+ * @param forceInline - If true, format as inline even if content would normally be block-level
136
+ */
137
+ export function formatNode(
138
+ node: SyntaxNode,
139
+ context: FormatterContext,
140
+ forceInline = false
141
+ ): Doc {
142
+ const type = node.type;
143
+
144
+ switch (type) {
145
+ case 'document':
146
+ return formatDocument(node, context);
147
+
148
+ case 'html_element':
149
+ return formatHtmlElement(node, context, forceInline);
150
+
151
+ case 'html_script_element':
152
+ case 'html_style_element':
153
+ case 'html_raw_element':
154
+ return formatScriptStyleElement(node, context);
155
+
156
+ case 'mustache_section':
157
+ case 'mustache_inverted_section':
158
+ if (forceInline) {
159
+ if (context.mustacheSpaces !== undefined) {
160
+ return text(normalizeMustacheWhitespaceAll(node.text, context.mustacheSpaces));
161
+ }
162
+ return text(node.text);
163
+ }
164
+ return formatMustacheSection(node, context);
165
+
166
+ case 'mustache_interpolation':
167
+ case 'mustache_triple':
168
+ case 'mustache_partial':
169
+ case 'mustache_comment':
170
+ return text(mustacheText(node.text, context));
171
+
172
+ case 'html_comment':
173
+ case 'html_doctype':
174
+ case 'html_entity':
175
+ case 'html_erroneous_end_tag':
176
+ return text(node.text);
177
+
178
+ case 'text':
179
+ return formatText(node);
180
+
181
+ default:
182
+ return text(node.text);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Format a text node.
188
+ */
189
+ export function formatText(node: SyntaxNode): Doc {
190
+ return text(normalizeText(node.text));
191
+ }
192
+
193
+ /**
194
+ * Format an HTML element.
195
+ */
196
+ export function formatHtmlElement(node: SyntaxNode, context: FormatterContext, forceInline = false): Doc {
197
+ const tags = context.customTags;
198
+ const display = getCSSDisplay(node, tags);
199
+ const isBlock = isWhitespaceInsensitive(display);
200
+ const preserveContent = shouldPreserveContent(node, tags);
201
+
202
+ // Self-closing tag
203
+ const selfClosing =
204
+ node.childCount === 1 && node.child(0)?.type === 'html_self_closing_tag';
205
+
206
+ if (selfClosing) {
207
+ const tag = node.child(0)!;
208
+ return formatStartTag(tag, context);
209
+ }
210
+
211
+ // Get start tag, children, and end tag
212
+ let startTag: SyntaxNode | null = null;
213
+ let endTag: SyntaxNode | null = null;
214
+ let hasRealEndTag = false;
215
+ const contentNodes: SyntaxNode[] = [];
216
+
217
+ for (let i = 0; i < node.childCount; i++) {
218
+ const child = node.child(i);
219
+ if (!child) continue;
220
+
221
+ if (child.type === 'html_start_tag') {
222
+ startTag = child;
223
+ } else if (child.type === 'html_end_tag') {
224
+ endTag = child;
225
+ hasRealEndTag = true;
226
+ } else if (child.type === 'html_forced_end_tag') {
227
+ endTag = child;
228
+ } else if (!child.type.startsWith('_')) {
229
+ contentNodes.push(child);
230
+ }
231
+ }
232
+
233
+ const parts: Doc[] = [];
234
+
235
+ // Format start tag
236
+ if (startTag) {
237
+ parts.push(formatStartTag(startTag, context));
238
+ }
239
+
240
+ // Check if content contains any HTML element children
241
+ const hasHtmlElementChildren = contentNodes.some(
242
+ (child) =>
243
+ child.type === 'html_element' ||
244
+ isRawContentElement(child) ||
245
+ isBlockLevel(child, tags)
246
+ );
247
+
248
+ // Handle content
249
+ if (preserveContent) {
250
+ // Check if this custom code tag should be indented
251
+ const tagNameLower = startTag ? getTagNameFromStartTag(startTag) : null;
252
+ const tagConfig = tagNameLower ? context.customTags?.get(tagNameLower) : undefined;
253
+ const shouldIndent = tagConfig ? resolveIndentMode(node, tagConfig) : false;
254
+
255
+ if (shouldIndent && startTag && endTag) {
256
+ const rawContent = context.document.getText().slice(
257
+ startTag.endIndex,
258
+ endTag.startIndex
259
+ );
260
+ const dedented = dedentContent(rawContent);
261
+ if (dedented.length > 0) {
262
+ const contentLines = dedented.split('\n');
263
+ const lineDocs: Doc[] = [];
264
+ for (let j = 0; j < contentLines.length; j++) {
265
+ if (j > 0) {
266
+ if (contentLines[j] === '') {
267
+ // Empty line: literal \n avoids indentation from the printer
268
+ lineDocs.push('\n');
269
+ } else {
270
+ lineDocs.push(hardline);
271
+ }
272
+ }
273
+ if (contentLines[j] !== '') {
274
+ lineDocs.push(text(contentLines[j]));
275
+ }
276
+ }
277
+ parts.push(indent(concat([hardline, ...lineDocs])));
278
+ parts.push(hardline);
279
+ }
280
+ } else if (startTag && endTag) {
281
+ // Use raw document text to preserve all whitespace, since tree-sitter
282
+ // text nodes strip boundary whitespace from regular html_element children
283
+ const rawContent = context.document.getText().slice(
284
+ startTag.endIndex,
285
+ endTag.startIndex
286
+ );
287
+ // For block-level elements, replace trailing newline+whitespace with
288
+ // a hardline so the closing tag gets proper indentation from the printer
289
+ const trailingMatch = isBlock ? rawContent.match(/\n[\t ]*$/) : null;
290
+ if (trailingMatch) {
291
+ parts.push(text(rawContent.slice(0, -trailingMatch[0].length)));
292
+ parts.push(hardline);
293
+ } else {
294
+ parts.push(text(rawContent));
295
+ }
296
+ } else {
297
+ for (const child of contentNodes) {
298
+ parts.push(text(child.text));
299
+ }
300
+ }
301
+ } else if (!isBlock && (!hasHtmlElementChildren || (forceInline && display !== 'inline-block' && !contentNodes.some(
302
+ (child) => isRawContentElement(child) || isBlockLevel(child, tags)
303
+ )))) {
304
+ // Standalone element with attributes: use outer group wrapping so content
305
+ // goes on its own line when attributes wrap (matches Prettier's printTag)
306
+ if (!forceInline && startTag && startTagHasAttributes(startTag)) {
307
+ const formattedContent = formatBlockChildren(contentNodes, context);
308
+ if (hasDocContent(formattedContent)) {
309
+ const bareStartTag = formatStartTag(startTag, context, true);
310
+ const outerParts: Doc[] = [
311
+ group(bareStartTag),
312
+ indent(concat([softline, formattedContent])),
313
+ ];
314
+ if (hasRealEndTag) {
315
+ outerParts.push(softline);
316
+ }
317
+ if (endTag) {
318
+ outerParts.push(formatEndTag(endTag));
319
+ }
320
+ return group(concat(outerParts));
321
+ }
322
+ }
323
+
324
+ // Inline element with only text/interpolation content - keep tight
325
+ // Preserve whitespace gaps between sibling nodes (e.g. space between
326
+ // mustache_interpolation and text that tree-sitter puts in the gap)
327
+ let prevEnd = startTag ? startTag.endIndex : -1;
328
+ for (const child of contentNodes) {
329
+ if (prevEnd >= 0 && child.startIndex > prevEnd) {
330
+ const gap = context.document.getText().slice(prevEnd, child.startIndex);
331
+ if (/\s/.test(gap)) {
332
+ parts.push(text(' '));
333
+ }
334
+ }
335
+ parts.push(formatNode(child, context, forceInline));
336
+ prevEnd = child.endIndex;
337
+ }
338
+ } else {
339
+ // Block element or inline-with-block-children: use hardline + indent
340
+ const formattedContent = formatBlockChildren(contentNodes, context);
341
+ const hasContent = hasDocContent(formattedContent);
342
+
343
+ if (hasContent) {
344
+ // Check if content has CSS-block children that would be treated as block
345
+ // by formatBlockChildren. Nodes in text flow (e.g. mustache sections
346
+ // adjacent to text) are inline regardless of their content.
347
+ const hasBlockChildren = contentNodes.some((child, i) => {
348
+ if (!shouldTreatAsBlock(child, i, contentNodes, tags)) {
349
+ return false;
350
+ }
351
+ const childDisplay = getCSSDisplay(child, tags);
352
+ return isWhitespaceInsensitive(childDisplay) || isRawContentElement(child);
353
+ });
354
+
355
+ if (isBlock && !hasBlockChildren) {
356
+ // Block element with only inline content: wrap in group so short ones stay flat
357
+ // e.g. <div>x</div> stays on one line, <div>long content...</div> breaks
358
+ const hasAttrs = startTag && startTagHasAttributes(startTag);
359
+
360
+ if (hasAttrs && startTag) {
361
+ // Outer group wrapping: match Prettier's printTag pattern
362
+ // group([group(openTag), indent([softline, content]), softline, closingTag])
363
+ const bareStartTag = formatStartTag(startTag, context, true);
364
+ const outerParts: Doc[] = [
365
+ group(bareStartTag),
366
+ indent(concat([softline, formattedContent])),
367
+ ];
368
+ if (hasRealEndTag) {
369
+ outerParts.push(softline);
370
+ }
371
+ if (endTag) {
372
+ outerParts.push(formatEndTag(endTag));
373
+ }
374
+ return group(concat(outerParts));
375
+ }
376
+
377
+ // No attributes — existing logic
378
+ const doc = group(
379
+ concat([
380
+ indent(concat([softline, formattedContent])),
381
+ softline,
382
+ ])
383
+ );
384
+ parts.push(doc);
385
+ // If no real end tag, don't add closing softline
386
+ if (!hasRealEndTag && endTag) {
387
+ // Remove the trailing softline we just added — content goes
388
+ // right up to forced end
389
+ parts.pop();
390
+ parts.push(
391
+ group(
392
+ concat([
393
+ indent(concat([softline, formattedContent])),
394
+ ])
395
+ )
396
+ );
397
+ }
398
+ } else {
399
+ // Has block children: always break
400
+ parts.push(indent(concat([hardline, formattedContent])));
401
+ if (hasRealEndTag) {
402
+ parts.push(hardline);
403
+ }
404
+ }
405
+ } else if (contentNodes.length === 0 && hasRealEndTag) {
406
+ // Empty block element: <div>\n</div>
407
+ parts.push(hardline);
408
+ }
409
+ }
410
+
411
+ // Format end tag
412
+ if (endTag) {
413
+ parts.push(formatEndTag(endTag));
414
+ }
415
+
416
+ return concat(parts);
417
+ }
418
+
419
+ /**
420
+ * Format script or style element.
421
+ * Uses pre-formatted content from embeddedFormatted map when available,
422
+ * otherwise preserves raw content as-is.
423
+ */
424
+ export function formatScriptStyleElement(
425
+ node: SyntaxNode,
426
+ context: FormatterContext
427
+ ): Doc {
428
+ const parts: Doc[] = [];
429
+
430
+ for (let i = 0; i < node.childCount; i++) {
431
+ const child = node.child(i);
432
+ if (!child) continue;
433
+
434
+ if (child.type === 'html_start_tag') {
435
+ parts.push(formatStartTag(child, context));
436
+ } else if (child.type === 'html_end_tag') {
437
+ parts.push(formatEndTag(child));
438
+ } else if (child.type === 'html_raw_text') {
439
+ const formatted = context.embeddedFormatted?.get(child.startIndex);
440
+ if (formatted !== undefined) {
441
+ const trimmed = formatted.replace(/^\n+/, '').replace(/\n+$/, '');
442
+ if (trimmed.length === 0) {
443
+ // Empty content — no lines between tags
444
+ } else {
445
+ const lines = trimmed.split('\n');
446
+ const lineDocs: Doc[] = [];
447
+ for (let j = 0; j < lines.length; j++) {
448
+ if (j > 0) {
449
+ lineDocs.push(hardline);
450
+ }
451
+ lineDocs.push(text(lines[j]));
452
+ }
453
+ parts.push(indent(concat([hardline, ...lineDocs])));
454
+ parts.push(hardline);
455
+ }
456
+ } else {
457
+ // Fallback: preserve raw content as-is (also used for html_raw_element)
458
+ // Check if this is a custom code tag that should be indented
459
+ if (node.type === 'html_raw_element') {
460
+ const startTagNode = node.child(0);
461
+ const tagNameLower = startTagNode?.type === 'html_start_tag' ? getTagNameFromStartTag(startTagNode) : null;
462
+ const tagConfig = tagNameLower ? context.customTags?.get(tagNameLower) : undefined;
463
+ if (tagConfig && resolveIndentMode(node, tagConfig)) {
464
+ const dedented = dedentContent(child.text);
465
+ if (dedented.length > 0) {
466
+ const contentLines = dedented.split('\n');
467
+ const lineDocs: Doc[] = [];
468
+ for (let j = 0; j < contentLines.length; j++) {
469
+ if (j > 0) {
470
+ if (contentLines[j] === '') {
471
+ lineDocs.push('\n');
472
+ } else {
473
+ lineDocs.push(hardline);
474
+ }
475
+ }
476
+ if (contentLines[j] !== '') {
477
+ lineDocs.push(text(contentLines[j]));
478
+ }
479
+ }
480
+ parts.push(indent(concat([hardline, ...lineDocs])));
481
+ parts.push(hardline);
482
+ }
483
+ } else {
484
+ parts.push(text(child.text));
485
+ }
486
+ } else {
487
+ // Script/style fallback: dedent and re-emit with hardlines so the
488
+ // printer can apply proper indentation from parent context.
489
+ const dedented = dedentContent(child.text);
490
+ if (dedented.length > 0) {
491
+ const contentLines = dedented.split('\n');
492
+ const lineDocs: Doc[] = [];
493
+ for (let j = 0; j < contentLines.length; j++) {
494
+ if (j > 0) {
495
+ if (contentLines[j] === '') {
496
+ lineDocs.push('\n');
497
+ } else {
498
+ lineDocs.push(hardline);
499
+ }
500
+ }
501
+ if (contentLines[j] !== '') {
502
+ lineDocs.push(text(contentLines[j]));
503
+ }
504
+ }
505
+ parts.push(indent(concat([hardline, ...lineDocs])));
506
+ parts.push(hardline);
507
+ }
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ return concat(parts);
514
+ }
515
+
516
+ /**
517
+ * Format a mustache section ({{#...}} or {{^...}}).
518
+ */
519
+ export function formatMustacheSection(
520
+ node: SyntaxNode,
521
+ context: FormatterContext
522
+ ): Doc {
523
+ const isInverted = node.type === 'mustache_inverted_section';
524
+ const beginType = isInverted
525
+ ? 'mustache_inverted_section_begin'
526
+ : 'mustache_section_begin';
527
+ const endType = isInverted
528
+ ? 'mustache_inverted_section_end'
529
+ : 'mustache_section_end';
530
+
531
+ let beginNode: SyntaxNode | null = null;
532
+ let endNode: SyntaxNode | null = null;
533
+ const contentNodes: SyntaxNode[] = [];
534
+
535
+ for (let i = 0; i < node.childCount; i++) {
536
+ const child = node.child(i);
537
+ if (!child) continue;
538
+
539
+ if (child.type === beginType) {
540
+ beginNode = child;
541
+ } else if (
542
+ child.type === endType ||
543
+ child.type === 'mustache_erroneous_section_end' ||
544
+ child.type === 'mustache_erroneous_inverted_section_end'
545
+ ) {
546
+ endNode = child;
547
+ } else if (!child.type.startsWith('_')) {
548
+ contentNodes.push(child);
549
+ }
550
+ }
551
+
552
+ const parts: Doc[] = [];
553
+
554
+ // Opening tag
555
+ if (beginNode) {
556
+ parts.push(text(mustacheText(beginNode.text, context)));
557
+ }
558
+
559
+ // Determine indentation: if content has implicit end tags (HTML crossing mustache
560
+ // boundaries), don't indent. Otherwise, indent normally.
561
+ const hasImplicit = hasImplicitEndTags(contentNodes);
562
+
563
+ // Staircase indentation: when content includes erroneous end tags (closing tags
564
+ // from a cross-section split). Each erroneous end tag gets a descending indent
565
+ // level so the outermost closing tag aligns at indent 0 (matching its opening).
566
+ // Non-erroneous content between erroneous end tags is indented one level deeper
567
+ // than the surrounding erroneous tags (it's a child of that scope).
568
+ const erroneousCount = contentNodes.filter(n => n.type === 'html_erroneous_end_tag').length;
569
+ const hasStaircase = !hasImplicit && erroneousCount > 0;
570
+
571
+ if (hasStaircase) {
572
+ let virtualDepth = erroneousCount - 1;
573
+ const groupNodes: SyntaxNode[] = [];
574
+ let lastNodeEnd = -1;
575
+ let pendingBlankLine = false;
576
+ let groupBlankLine = false;
577
+
578
+ const emitGroup = () => {
579
+ if (groupNodes.length === 0) return;
580
+ const formatted = formatBlockChildren(groupNodes, context);
581
+ if (hasDocContent(formatted)) {
582
+ if (groupBlankLine) parts.push('\n');
583
+ const depth = Math.max(0, virtualDepth + 1);
584
+ parts.push(depth > 0
585
+ ? indentN(concat([hardline, formatted]), depth)
586
+ : concat([hardline, formatted]));
587
+ }
588
+ groupNodes.length = 0;
589
+ groupBlankLine = false;
590
+ };
591
+
592
+ for (const node of contentNodes) {
593
+ if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd) {
594
+ const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
595
+ if ((gap.match(/\n/g) || []).length >= 2) {
596
+ pendingBlankLine = true;
597
+ }
598
+ }
599
+
600
+ if (node.type === 'html_erroneous_end_tag') {
601
+ emitGroup();
602
+ if (pendingBlankLine) parts.push('\n');
603
+ pendingBlankLine = false;
604
+ const formatted = formatNode(node, context);
605
+ const depth = Math.max(0, virtualDepth);
606
+ parts.push(depth > 0
607
+ ? indentN(concat([hardline, formatted]), depth)
608
+ : concat([hardline, formatted]));
609
+ virtualDepth--;
610
+ } else {
611
+ if (groupNodes.length === 0) {
612
+ groupBlankLine = pendingBlankLine;
613
+ pendingBlankLine = false;
614
+ }
615
+ groupNodes.push(node);
616
+ }
617
+ lastNodeEnd = node.endIndex;
618
+ }
619
+ emitGroup();
620
+ parts.push(hardline);
621
+ } else {
622
+ const formattedContent = formatBlockChildren(contentNodes, context);
623
+ const hasContent = hasDocContent(formattedContent);
624
+
625
+ if (hasContent) {
626
+ if (hasImplicit) {
627
+ // No indent for content with implicit end tags
628
+ parts.push(hardline);
629
+ parts.push(formattedContent);
630
+ parts.push(hardline);
631
+ } else {
632
+ // Check if content has CSS-block children (accounting for text flow)
633
+ const hasBlockChildren = contentNodes.some((child, i) => {
634
+ if (!shouldTreatAsBlock(child, i, contentNodes, context.customTags)) {
635
+ return false;
636
+ }
637
+ const childDisplay = getCSSDisplay(child, context.customTags);
638
+ return isWhitespaceInsensitive(childDisplay) || isRawContentElement(child);
639
+ });
640
+
641
+ if (!hasBlockChildren) {
642
+ // Inline content only: use group so short sections stay flat
643
+ parts.push(indent(concat([softline, formattedContent])));
644
+ parts.push(softline);
645
+ } else {
646
+ // Block content: always break
647
+ parts.push(indent(concat([hardline, formattedContent])));
648
+ parts.push(hardline);
649
+ }
650
+ }
651
+ }
652
+ }
653
+
654
+ // Closing tag
655
+ if (endNode) {
656
+ parts.push(text(mustacheText(endNode.text, context)));
657
+ }
658
+
659
+ // Wrap in group so inline-only content can stay flat
660
+ return group(concat(parts));
661
+ }
662
+
663
+ /**
664
+ * Check if a start tag has any attributes.
665
+ */
666
+ function startTagHasAttributes(startTag: SyntaxNode): boolean {
667
+ for (let i = 0; i < startTag.childCount; i++) {
668
+ const child = startTag.child(i);
669
+ if (!child) continue;
670
+ if (
671
+ child.type === 'html_attribute' ||
672
+ child.type === 'mustache_attribute' ||
673
+ child.type === 'mustache_interpolation' ||
674
+ child.type === 'mustache_triple'
675
+ ) {
676
+ return true;
677
+ }
678
+ }
679
+ return false;
680
+ }
681
+
682
+ /**
683
+ * Format a start tag with attributes.
684
+ * Wraps in a group so attributes break onto separate lines when
685
+ * the tag exceeds print width.
686
+ * When `bare` is true, returns the tag IR without the outer group wrapper.
687
+ */
688
+ export function formatStartTag(node: SyntaxNode, context?: FormatterContext, bare = false): Doc {
689
+ let tagNameText = '';
690
+ const attrs: Doc[] = [];
691
+
692
+ for (let i = 0; i < node.childCount; i++) {
693
+ const child = node.child(i);
694
+ if (!child) continue;
695
+
696
+ if (child.type === 'html_tag_name') {
697
+ tagNameText = child.text;
698
+ } else if (child.type === 'html_attribute') {
699
+ attrs.push(formatAttribute(child, context));
700
+ } else if (child.type === 'mustache_attribute') {
701
+ if (context?.mustacheSpaces !== undefined) {
702
+ attrs.push(text(normalizeMustacheWhitespaceAll(child.text, context.mustacheSpaces)));
703
+ } else {
704
+ attrs.push(text(child.text));
705
+ }
706
+ } else if (child.type === 'mustache_interpolation' || child.type === 'mustache_triple') {
707
+ attrs.push(text(context ? mustacheText(child.text, context) : child.text));
708
+ }
709
+ }
710
+
711
+ const isSelfClosing = node.type === 'html_self_closing_tag';
712
+ const closingBracket = isSelfClosing ? ' />' : '>';
713
+
714
+ if (attrs.length === 0) {
715
+ return text('<' + tagNameText + closingBracket);
716
+ }
717
+
718
+ // Build attribute list with line separators
719
+ const attrParts: Doc[] = [];
720
+ for (let i = 0; i < attrs.length; i++) {
721
+ if (i > 0) {
722
+ attrParts.push(line);
723
+ }
724
+ attrParts.push(attrs[i]);
725
+ }
726
+
727
+ // In break mode, self-closing /> has no leading space (aligns with <tagName)
728
+ const breakClosingBracket = isSelfClosing ? '/>' : '>';
729
+
730
+ // Wrap tag in group: flat puts attrs on one line, break wraps them
731
+ const inner = concat([
732
+ text('<'),
733
+ text(tagNameText),
734
+ indent(concat([line, concat(attrParts)])),
735
+ ifBreak(concat([hardline, text(breakClosingBracket)]), text(closingBracket)),
736
+ ]);
737
+ return bare ? inner : group(inner);
738
+ }
739
+
740
+ /**
741
+ * Format an end tag.
742
+ */
743
+ export function formatEndTag(node: SyntaxNode): Doc {
744
+ for (let i = 0; i < node.childCount; i++) {
745
+ const child = node.child(i);
746
+ if (child && child.type === 'html_tag_name') {
747
+ return text('</' + child.text + '>');
748
+ }
749
+ }
750
+ return text(node.text);
751
+ }
752
+
753
+ /**
754
+ * Format an HTML attribute.
755
+ */
756
+ export function formatAttribute(node: SyntaxNode, context?: FormatterContext): Doc {
757
+ const parts: Doc[] = [];
758
+
759
+ for (let i = 0; i < node.childCount; i++) {
760
+ const child = node.child(i);
761
+ if (!child) continue;
762
+
763
+ if (child.type === 'html_attribute_name') {
764
+ parts.push(text(child.text));
765
+ } else if (child.type === 'html_attribute_value') {
766
+ parts.push(text('='));
767
+ parts.push(text(child.text));
768
+ } else if (child.type === 'html_quoted_attribute_value') {
769
+ parts.push(text('='));
770
+ if (context?.mustacheSpaces !== undefined) {
771
+ parts.push(text(normalizeMustacheWhitespaceAll(child.text, context.mustacheSpaces)));
772
+ } else {
773
+ parts.push(text(child.text));
774
+ }
775
+ } else if (child.type === 'mustache_interpolation') {
776
+ parts.push(text('='));
777
+ parts.push(text(context ? mustacheText(child.text, context) : child.text));
778
+ }
779
+ }
780
+
781
+ return concat(parts);
782
+ }
783
+
784
+ /**
785
+ * Split a single-line text string into alternating words and `line` separators.
786
+ * Returns an array of fill-ready parts to spread into `currentLine`.
787
+ */
788
+ function textWords(str: string): Doc[] {
789
+ const words = str.split(/\s+/).filter((w) => w.length > 0);
790
+ if (words.length === 0) return [];
791
+ const parts: Doc[] = [words[0]];
792
+ for (let i = 1; i < words.length; i++) {
793
+ parts.push(line);
794
+ parts.push(words[i]);
795
+ }
796
+ return parts;
797
+ }
798
+
799
+ /**
800
+ * Replace `line` separators with `" "` inside delimited regions so the
801
+ * fill algorithm treats delimited content as unbreakable.
802
+ *
803
+ * Scans string parts for delimiter boundaries. Between an opening and closing
804
+ * delimiter, any `line` separator is replaced with a literal space string.
805
+ * Delimiters are matched longest-first to handle e.g. `$$` before `$`.
806
+ */
807
+ export function collapseDelimitedRegions(parts: Doc[], delimiters: NoBreakDelimiter[]): Doc[] {
808
+ if (delimiters.length === 0) return parts;
809
+
810
+ // Sort longest-first by max(start.length, end.length) so $$ is checked before $
811
+ const sorted = [...delimiters].sort(
812
+ (a, b) => Math.max(b.start.length, b.end.length) - Math.max(a.start.length, a.end.length)
813
+ );
814
+
815
+ const result = [...parts];
816
+ let activeDelimiter: NoBreakDelimiter | null = null;
817
+
818
+ for (let i = 0; i < result.length; i++) {
819
+ const part = result[i];
820
+
821
+ if (typeof part === 'string') {
822
+ if (activeDelimiter === null) {
823
+ // Look for an opening delimiter
824
+ for (const delim of sorted) {
825
+ const startIdx = part.indexOf(delim.start);
826
+ if (startIdx >= 0) {
827
+ // Check if it also closes in the same string
828
+ const afterOpen = startIdx + delim.start.length;
829
+ const closeIdx = part.indexOf(delim.end, afterOpen);
830
+ if (closeIdx >= 0) {
831
+ // Self-contained (e.g. "$x$") — no state change, already atomic
832
+ continue;
833
+ }
834
+ activeDelimiter = delim;
835
+ break;
836
+ }
837
+ }
838
+ } else {
839
+ // Look for the closing delimiter
840
+ if (part.includes(activeDelimiter.end)) {
841
+ activeDelimiter = null;
842
+ }
843
+ }
844
+ } else if (activeDelimiter !== null && isLine(part)) {
845
+ // Inside a delimited region: replace line with non-breaking space
846
+ result[i] = ' ';
847
+ }
848
+ }
849
+
850
+ return result;
851
+ }
852
+
853
+ /**
854
+ * Convert inline content parts into a fill Doc that wraps at word boundaries.
855
+ *
856
+ * `currentLine` is already fill-ready: text nodes are pre-split into
857
+ * alternating word/`line` parts by `textWords`, and inter-node gaps are
858
+ * `line` separators. This function enforces proper alternating
859
+ * content/separator structure, concatenates adjacent content, and attaches
860
+ * leading punctuation to the preceding content.
861
+ */
862
+ function inlineContentToFill(parts: Doc[]): Doc {
863
+ if (parts.length === 0) return empty;
864
+ if (parts.length === 1) return parts[0];
865
+
866
+ const fillParts: Doc[] = [];
867
+ for (const item of parts) {
868
+ if (isLine(item)) {
869
+ // Only push separator after content (skip leading/duplicate separators)
870
+ if (fillParts.length > 0 && !isLine(fillParts[fillParts.length - 1])) {
871
+ fillParts.push(item);
872
+ }
873
+ } else {
874
+ const lastIdx = fillParts.length - 1;
875
+ if (lastIdx >= 0 && !isLine(fillParts[lastIdx])) {
876
+ // Adjacent content (no separator) — concat with previous
877
+ fillParts[lastIdx] = concat([fillParts[lastIdx], item]);
878
+ } else if (
879
+ typeof item === 'string' &&
880
+ /^[,.:;!?)\]]/.test(item) &&
881
+ lastIdx >= 0 &&
882
+ isLine(fillParts[lastIdx])
883
+ ) {
884
+ // Punctuation after separator — attach to preceding content
885
+ fillParts.pop();
886
+ if (fillParts.length > 0) {
887
+ fillParts[fillParts.length - 1] = concat([
888
+ fillParts[fillParts.length - 1],
889
+ item,
890
+ ]);
891
+ } else {
892
+ fillParts.push(item);
893
+ }
894
+ } else {
895
+ fillParts.push(item);
896
+ }
897
+ }
898
+ }
899
+
900
+ // Remove trailing separator
901
+ if (fillParts.length > 0 && isLine(fillParts[fillParts.length - 1])) {
902
+ fillParts.pop();
903
+ }
904
+
905
+ return fill(fillParts);
906
+ }
907
+
908
+ /**
909
+ * Format block-level children with display-aware separators.
910
+ */
911
+ export function formatBlockChildren(
912
+ nodes: SyntaxNode[],
913
+ context: FormatterContext
914
+ ): Doc {
915
+ const lines: { doc: Doc; blankLineBefore: boolean; rawLine?: boolean }[] = [];
916
+ let currentLine: Doc[] = [];
917
+ let lastNodeEnd = -1;
918
+ let pendingBlankLine = false;
919
+ let blankLineBeforeCurrentLine = false;
920
+ let ignoreNext = false;
921
+ let inIgnoreRegion = false;
922
+ let ignoreRegionStartIndex = -1;
923
+
924
+ const noBreakDelims = context.noBreakDelimiters;
925
+ function flushCurrentLine(): Doc {
926
+ const parts = noBreakDelims ? collapseDelimitedRegions(currentLine, noBreakDelims) : currentLine;
927
+ return inlineContentToFill(parts);
928
+ }
929
+
930
+ for (let i = 0; i < nodes.length; i++) {
931
+ const node = nodes[i];
932
+
933
+ // Detect blank lines in gap between nodes (before directive handling)
934
+ if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd && !inIgnoreRegion) {
935
+ const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
936
+ const newlineCount = (gap.match(/\n/g) || []).length;
937
+ if (newlineCount >= 2) {
938
+ pendingBlankLine = true;
939
+ }
940
+ }
941
+
942
+ const directive = getIgnoreDirective(node);
943
+
944
+ // --- Ignore directive handling ---
945
+
946
+ // ignore-end: close a region
947
+ if (directive === 'ignore-end' && inIgnoreRegion) {
948
+ // Flush any pending inline content
949
+ if (currentLine.length > 0) {
950
+ const lineContent = trimDoc(flushCurrentLine());
951
+ if (hasDocContent(lineContent)) {
952
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
953
+ }
954
+ currentLine = [];
955
+ blankLineBeforeCurrentLine = false;
956
+ }
957
+ // Emit raw text from region start to this comment, trimming boundary newlines
958
+ const rawText = context.document.getText().slice(ignoreRegionStartIndex, node.startIndex)
959
+ .replace(/^\n/, '').replace(/\n$/, '');
960
+ if (rawText.length > 0) {
961
+ lines.push({ doc: text(rawText), blankLineBefore: false, rawLine: true });
962
+ }
963
+ // Emit the ignore-end comment itself (rawLine to avoid adding indent after raw text)
964
+ const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
965
+ lines.push({ doc: text(commentText), blankLineBefore: false, rawLine: true });
966
+ inIgnoreRegion = false;
967
+ ignoreRegionStartIndex = -1;
968
+ lastNodeEnd = node.endIndex;
969
+ continue;
970
+ }
971
+
972
+ // Inside ignore region: skip (content captured as raw text at ignore-end)
973
+ if (inIgnoreRegion) {
974
+ lastNodeEnd = node.endIndex;
975
+ continue;
976
+ }
977
+
978
+ // ignore-start: begin a region
979
+ if (directive === 'ignore-start') {
980
+ if (currentLine.length > 0) {
981
+ const lineContent = trimDoc(flushCurrentLine());
982
+ if (hasDocContent(lineContent)) {
983
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
984
+ }
985
+ currentLine = [];
986
+ blankLineBeforeCurrentLine = false;
987
+ }
988
+ const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
989
+ lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
990
+ pendingBlankLine = false;
991
+ inIgnoreRegion = true;
992
+ ignoreRegionStartIndex = node.endIndex;
993
+ lastNodeEnd = node.endIndex;
994
+ continue;
995
+ }
996
+
997
+ // ignore (next-node): emit the comment, set flag
998
+ if (directive === 'ignore') {
999
+ if (currentLine.length > 0) {
1000
+ const lineContent = trimDoc(flushCurrentLine());
1001
+ if (hasDocContent(lineContent)) {
1002
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
1003
+ }
1004
+ currentLine = [];
1005
+ blankLineBeforeCurrentLine = false;
1006
+ }
1007
+ const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
1008
+ lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
1009
+ pendingBlankLine = false;
1010
+ ignoreNext = true;
1011
+ lastNodeEnd = node.endIndex;
1012
+ continue;
1013
+ }
1014
+
1015
+ // Ignored next-node: emit raw text, clear flag
1016
+ if (ignoreNext) {
1017
+ lines.push({ doc: text(node.text), blankLineBefore: pendingBlankLine });
1018
+ pendingBlankLine = false;
1019
+ ignoreNext = false;
1020
+ lastNodeEnd = node.endIndex;
1021
+ continue;
1022
+ }
1023
+
1024
+ // ignore-end without ignore-start: treat as normal comment (fall through)
1025
+
1026
+ const treatAsBlock = shouldTreatAsBlock(node, i, nodes, context.customTags);
1027
+
1028
+ // Check for whitespace between nodes in original document (inline gap handling)
1029
+ if (lastNodeEnd >= 0 && node.startIndex > lastNodeEnd) {
1030
+ const prevNode = nodes[i - 1];
1031
+ const prevTreatAsBlock = shouldTreatAsBlock(prevNode, i - 1, nodes, context.customTags);
1032
+
1033
+ if (!prevTreatAsBlock && !treatAsBlock) {
1034
+ const gap = context.document.getText().slice(lastNodeEnd, node.startIndex);
1035
+ if (/\s/.test(gap)) {
1036
+ currentLine.push(line);
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ if (treatAsBlock) {
1042
+ // Flush current inline content
1043
+ if (currentLine.length > 0) {
1044
+ const lineContent = trimDoc(flushCurrentLine());
1045
+ if (hasDocContent(lineContent)) {
1046
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
1047
+ }
1048
+ currentLine = [];
1049
+ blankLineBeforeCurrentLine = false;
1050
+ }
1051
+ // Add block element
1052
+ lines.push({ doc: formatNode(node, context), blankLineBefore: pendingBlankLine });
1053
+ pendingBlankLine = false;
1054
+ } else if (node.type === 'html_comment' || node.type === 'mustache_comment') {
1055
+ // Comments on their own line if multi-line or on their own line in source
1056
+ const isMultiline = node.startPosition.row !== node.endPosition.row;
1057
+ const isOnOwnLine = i > 0 && node.startPosition.row > nodes[i - 1].endPosition.row;
1058
+ if (isMultiline || isOnOwnLine) {
1059
+ if (currentLine.length > 0) {
1060
+ const lineContent = trimDoc(flushCurrentLine());
1061
+ if (hasDocContent(lineContent)) {
1062
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
1063
+ }
1064
+ currentLine = [];
1065
+ blankLineBeforeCurrentLine = false;
1066
+ }
1067
+ const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
1068
+ lines.push({ doc: text(commentText), blankLineBefore: pendingBlankLine });
1069
+ pendingBlankLine = false;
1070
+ } else {
1071
+ if (currentLine.length === 0) {
1072
+ blankLineBeforeCurrentLine = pendingBlankLine;
1073
+ pendingBlankLine = false;
1074
+ }
1075
+ const commentText = node.type === 'mustache_comment' ? mustacheText(node.text, context) : node.text;
1076
+ currentLine.push(text(commentText));
1077
+ }
1078
+ } else {
1079
+ // Inline content
1080
+ if (currentLine.length === 0) {
1081
+ blankLineBeforeCurrentLine = pendingBlankLine;
1082
+ pendingBlankLine = false;
1083
+ }
1084
+ const forceInline = isInTextFlow(node, i, nodes);
1085
+ const formatted = formatNode(node, context, forceInline);
1086
+
1087
+ // Check if formatted content contains newlines (multi-line text)
1088
+ if (typeof formatted === 'string' && formatted.includes('\n')) {
1089
+ const contentLines = formatted.split('\n');
1090
+ const isTextNode = node.type === 'text';
1091
+
1092
+ if (isTextNode) {
1093
+ // Re-flow: treat source newlines as word boundaries, only flush at
1094
+ // blank lines. This lets the fill algorithm handle all wrapping.
1095
+ for (let j = 0; j < contentLines.length; j++) {
1096
+ const trimmed = contentLines[j].trim();
1097
+ if (!trimmed) {
1098
+ // Empty line = paragraph break — flush current inline flow
1099
+ if (currentLine.length > 0) {
1100
+ const lineContent = trimDoc(flushCurrentLine());
1101
+ if (hasDocContent(lineContent)) {
1102
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
1103
+ blankLineBeforeCurrentLine = false;
1104
+ }
1105
+ currentLine = [];
1106
+ }
1107
+ pendingBlankLine = true;
1108
+ } else {
1109
+ if (currentLine.length === 0) {
1110
+ blankLineBeforeCurrentLine = pendingBlankLine;
1111
+ pendingBlankLine = false;
1112
+ }
1113
+ // Add a line separator between joined source lines (j > 0),
1114
+ // but not before the first line — it continues the existing flow
1115
+ if (j > 0 && currentLine.length > 0) {
1116
+ currentLine.push(line);
1117
+ }
1118
+ currentLine.push(...textWords(trimmed));
1119
+ }
1120
+ }
1121
+ } else {
1122
+ // Non-text nodes (force-inline mustache sections, etc.):
1123
+ // preserve source newlines as hard line breaks.
1124
+ const firstTrimmed = contentLines[0].trim();
1125
+ if (firstTrimmed) {
1126
+ currentLine.push(firstTrimmed);
1127
+ }
1128
+
1129
+ if (currentLine.length > 0) {
1130
+ const lineContent = trimDoc(flushCurrentLine());
1131
+ if (hasDocContent(lineContent)) {
1132
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
1133
+ blankLineBeforeCurrentLine = pendingBlankLine;
1134
+ pendingBlankLine = false;
1135
+ }
1136
+ currentLine = [];
1137
+ }
1138
+
1139
+ let sawBlankLine = false;
1140
+ for (let j = 1; j < contentLines.length - 1; j++) {
1141
+ const trimmed = contentLines[j].trim();
1142
+ if (trimmed) {
1143
+ lines.push({ doc: text(trimmed), blankLineBefore: blankLineBeforeCurrentLine || sawBlankLine });
1144
+ blankLineBeforeCurrentLine = false;
1145
+ sawBlankLine = false;
1146
+ } else {
1147
+ sawBlankLine = true;
1148
+ }
1149
+ }
1150
+
1151
+ if (contentLines.length > 1) {
1152
+ const lastTrimmed = contentLines[contentLines.length - 1].trim();
1153
+ if (lastTrimmed) {
1154
+ blankLineBeforeCurrentLine = sawBlankLine;
1155
+ sawBlankLine = false;
1156
+ currentLine = [lastTrimmed];
1157
+ }
1158
+ if (sawBlankLine) {
1159
+ pendingBlankLine = true;
1160
+ }
1161
+ }
1162
+ }
1163
+ } else {
1164
+ // For text nodes, spread word/line parts directly into currentLine
1165
+ if (node.type === 'text' && typeof formatted === 'string') {
1166
+ const words = textWords(formatted);
1167
+ if (words.length > 0) {
1168
+ currentLine.push(...words);
1169
+ } else if (node.text.trim() === '' && currentLine.length > 0) {
1170
+ // Whitespace-only text between inline content: preserve as line separator
1171
+ currentLine.push(line);
1172
+ }
1173
+ } else {
1174
+ currentLine.push(formatted);
1175
+ }
1176
+ }
1177
+ }
1178
+
1179
+ // Force line break after <br> tags
1180
+ if (node.type === 'html_element' && currentLine.length > 0) {
1181
+ const tagName = getTagName(node);
1182
+ if (tagName?.toLowerCase() === 'br') {
1183
+ const lineContent = trimDoc(flushCurrentLine());
1184
+ if (hasDocContent(lineContent)) {
1185
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
1186
+ blankLineBeforeCurrentLine = false;
1187
+ }
1188
+ currentLine = [];
1189
+ }
1190
+ }
1191
+
1192
+ lastNodeEnd = node.endIndex;
1193
+ }
1194
+
1195
+ // Handle unterminated ignore region: emit remaining raw text
1196
+ if (inIgnoreRegion && nodes.length > 0) {
1197
+ const lastNode = nodes[nodes.length - 1];
1198
+ const rawText = context.document.getText().slice(ignoreRegionStartIndex, lastNode.endIndex)
1199
+ .replace(/^\n/, '');
1200
+ if (rawText.length > 0) {
1201
+ lines.push({ doc: text(rawText), blankLineBefore: false, rawLine: true });
1202
+ }
1203
+ }
1204
+
1205
+ // Flush remaining inline content
1206
+ if (currentLine.length > 0) {
1207
+ const lineContent = trimDoc(flushCurrentLine());
1208
+ if (hasDocContent(lineContent)) {
1209
+ lines.push({ doc: lineContent, blankLineBefore: blankLineBeforeCurrentLine });
1210
+ }
1211
+ }
1212
+
1213
+ // Join lines with hardlines
1214
+ if (lines.length === 0) {
1215
+ return empty;
1216
+ }
1217
+
1218
+ const parts: Doc[] = [];
1219
+ for (let i = 0; i < lines.length; i++) {
1220
+ if (i > 0) {
1221
+ if (lines[i].blankLineBefore) {
1222
+ // Emit a blank line: literal \n (no indent) + hardline (with indent)
1223
+ parts.push('\n');
1224
+ }
1225
+ if (lines[i].rawLine) {
1226
+ // Raw lines (ignored regions): literal \n to avoid adding indentation
1227
+ parts.push('\n');
1228
+ } else {
1229
+ parts.push(hardline);
1230
+ }
1231
+ }
1232
+ parts.push(lines[i].doc);
1233
+ }
1234
+
1235
+ return concat(parts);
1236
+ }
1237
+
1238
+ /**
1239
+ * Check if a Doc has any meaningful content.
1240
+ */
1241
+ function hasDocContent(doc: Doc): boolean {
1242
+ if (typeof doc === 'string') {
1243
+ return doc.trim().length > 0;
1244
+ }
1245
+ if (doc.type === 'concat') {
1246
+ return doc.parts.some(hasDocContent);
1247
+ }
1248
+ if (doc.type === 'indent') {
1249
+ return hasDocContent(doc.contents);
1250
+ }
1251
+ if (doc.type === 'group') {
1252
+ return hasDocContent(doc.contents);
1253
+ }
1254
+ if (doc.type === 'fill') {
1255
+ return doc.parts.some(hasDocContent);
1256
+ }
1257
+ if (doc.type === 'ifBreak') {
1258
+ return hasDocContent(doc.breakContents) || hasDocContent(doc.flatContents);
1259
+ }
1260
+ // hardline, softline, line, breakParent are structural
1261
+ return false;
1262
+ }
1263
+
1264
+ /**
1265
+ * Trim whitespace from the beginning and end of a Doc string.
1266
+ */
1267
+ function trimDoc(doc: Doc): Doc {
1268
+ if (typeof doc === 'string') {
1269
+ return doc.trim();
1270
+ }
1271
+ return doc;
1272
+ }