@herb-tools/formatter 0.4.3 → 0.5.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.
package/dist/index.cjs CHANGED
@@ -131,7 +131,7 @@ class Token {
131
131
  }
132
132
 
133
133
  // NOTE: This file is generated by the templates/template.rb script and should not
134
- // be modified manually. See /Users/marcoroth/Development/herb-release-7/templates/javascript/packages/core/src/errors.ts.erb
134
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.5.0/templates/javascript/packages/core/src/errors.ts.erb
135
135
  class HerbError {
136
136
  type;
137
137
  message;
@@ -581,7 +581,7 @@ function convertToUTF8(string) {
581
581
  }
582
582
 
583
583
  // NOTE: This file is generated by the templates/template.rb script and should not
584
- // be modified manually. See /Users/marcoroth/Development/herb-release-7/templates/javascript/packages/core/src/nodes.ts.erb
584
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.5.0/templates/javascript/packages/core/src/nodes.ts.erb
585
585
  class Node {
586
586
  type;
587
587
  location;
@@ -2576,7 +2576,7 @@ function fromSerializedNode(node) {
2576
2576
  }
2577
2577
 
2578
2578
  // NOTE: This file is generated by the templates/template.rb script and should not
2579
- // be modified manually. See /Users/marcoroth/Development/herb-release-7/templates/javascript/packages/core/src/visitor.ts.erb
2579
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.5.0/templates/javascript/packages/core/src/visitor.ts.erb
2580
2580
  class Visitor {
2581
2581
  visit(node) {
2582
2582
  if (!node)
@@ -2681,6 +2681,11 @@ class Visitor {
2681
2681
  }
2682
2682
  }
2683
2683
 
2684
+ // TODO: we can probably expand this list with more tags/attributes
2685
+ const FORMATTABLE_ATTRIBUTES = {
2686
+ '*': ['class'],
2687
+ 'img': ['srcset', 'sizes']
2688
+ };
2684
2689
  /**
2685
2690
  * Printer traverses the Herb AST using the Visitor pattern
2686
2691
  * and emits a formatted string with proper indentation, line breaks, and attribute wrapping.
@@ -2693,6 +2698,7 @@ class Printer extends Visitor {
2693
2698
  indentLevel = 0;
2694
2699
  inlineMode = false;
2695
2700
  isInComplexNesting = false;
2701
+ currentTagName = "";
2696
2702
  static INLINE_ELEMENTS = new Set([
2697
2703
  'a', 'abbr', 'acronym', 'b', 'bdo', 'big', 'br', 'cite', 'code',
2698
2704
  'dfn', 'em', 'i', 'img', 'kbd', 'label', 'map', 'object', 'q',
@@ -2791,26 +2797,117 @@ class Printer extends Visitor {
2791
2797
  /**
2792
2798
  * Determine if a tag should be rendered inline based on attribute count and other factors
2793
2799
  */
2794
- shouldRenderInline(totalAttributeCount, inlineLength, indentLength, maxLineLength = this.maxLineLength, hasComplexERB = false, nestingDepth = 0, inlineNodesLength = 0) {
2795
- if (hasComplexERB)
2800
+ shouldRenderInline(totalAttributeCount, inlineLength, indentLength, maxLineLength = this.maxLineLength, hasComplexERB = false, _nestingDepth = 0, _inlineNodesLength = 0, hasMultilineAttributes = false) {
2801
+ if (hasComplexERB || hasMultilineAttributes)
2796
2802
  return false;
2797
- // Special case: no attributes at all, always inline if it fits
2798
2803
  if (totalAttributeCount === 0) {
2799
2804
  return inlineLength + indentLength <= maxLineLength;
2800
2805
  }
2801
- const basicInlineCondition = totalAttributeCount <= 3 &&
2802
- inlineLength + indentLength <= maxLineLength;
2803
- const erbInlineCondition = inlineNodesLength > 0 && totalAttributeCount <= 3;
2804
- return basicInlineCondition || erbInlineCondition;
2806
+ if (totalAttributeCount > 3 || inlineLength + indentLength > maxLineLength) {
2807
+ return false;
2808
+ }
2809
+ return true;
2810
+ }
2811
+ hasMultilineAttributes(attributes) {
2812
+ return attributes.some(attribute => {
2813
+ if (attribute.value && (attribute.value instanceof HTMLAttributeValueNode || attribute.value?.type === 'AST_HTML_ATTRIBUTE_VALUE_NODE')) {
2814
+ const attributeValue = attribute.value;
2815
+ const content = attributeValue.children.map((child) => {
2816
+ if (child instanceof HTMLTextNode || child.type === 'AST_HTML_TEXT_NODE' || child instanceof LiteralNode || child.type === 'AST_LITERAL_NODE') {
2817
+ return child.content;
2818
+ }
2819
+ else if (child instanceof ERBContentNode || child.type === 'AST_ERB_CONTENT_NODE') {
2820
+ const erbAttribute = child;
2821
+ return erbAttribute.tag_opening.value + erbAttribute.content.value + erbAttribute.tag_closing.value;
2822
+ }
2823
+ return "";
2824
+ }).join("");
2825
+ if (/\r?\n/.test(content)) {
2826
+ const name = attribute.name.name.value ?? "";
2827
+ if (name === 'class') {
2828
+ const normalizedContent = content.replace(/\s+/g, ' ').trim();
2829
+ return normalizedContent.length > 80;
2830
+ }
2831
+ const lines = content.split(/\r?\n/);
2832
+ if (lines.length > 1) {
2833
+ return lines.slice(1).some(line => /^\s+/.test(line));
2834
+ }
2835
+ }
2836
+ }
2837
+ return false;
2838
+ });
2839
+ }
2840
+ formatClassAttribute(content, name, equals, open_quote, close_quote) {
2841
+ const normalizedContent = content.replace(/\s+/g, ' ').trim();
2842
+ const hasActualNewlines = /\r?\n/.test(content);
2843
+ if (hasActualNewlines && normalizedContent.length > 80) {
2844
+ const lines = content.split(/\r?\n/).map(line => line.trim()).filter(line => line);
2845
+ if (lines.length > 1) {
2846
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote;
2847
+ }
2848
+ }
2849
+ const currentIndent = this.indentLevel * this.indentWidth;
2850
+ const attributeLine = `${name}${equals}${open_quote}${normalizedContent}${close_quote}`;
2851
+ if (currentIndent + attributeLine.length > this.maxLineLength && normalizedContent.length > 60) {
2852
+ const classes = normalizedContent.split(' ');
2853
+ const lines = this.breakTokensIntoLines(classes, currentIndent);
2854
+ if (lines.length > 1) {
2855
+ return open_quote + this.formatMultilineAttributeValue(lines) + close_quote;
2856
+ }
2857
+ }
2858
+ return open_quote + normalizedContent + close_quote;
2859
+ }
2860
+ isFormattableAttribute(attributeName, tagName) {
2861
+ const globalFormattable = FORMATTABLE_ATTRIBUTES['*'] || [];
2862
+ const tagSpecificFormattable = FORMATTABLE_ATTRIBUTES[tagName.toLowerCase()] || [];
2863
+ return globalFormattable.includes(attributeName) || tagSpecificFormattable.includes(attributeName);
2864
+ }
2865
+ formatMultilineAttribute(content, name, equals, open_quote, close_quote) {
2866
+ if (name === 'srcset' || name === 'sizes') {
2867
+ const normalizedContent = content.replace(/\s+/g, ' ').trim();
2868
+ return open_quote + normalizedContent + close_quote;
2869
+ }
2870
+ const lines = content.split('\n');
2871
+ if (lines.length <= 1) {
2872
+ return open_quote + content + close_quote;
2873
+ }
2874
+ const formattedContent = this.formatMultilineAttributeValue(lines);
2875
+ return open_quote + formattedContent + close_quote;
2876
+ }
2877
+ formatMultilineAttributeValue(lines) {
2878
+ const indent = " ".repeat((this.indentLevel + 1) * this.indentWidth);
2879
+ const closeIndent = " ".repeat(this.indentLevel * this.indentWidth);
2880
+ return "\n" + lines.map(line => indent + line).join("\n") + "\n" + closeIndent;
2881
+ }
2882
+ breakTokensIntoLines(tokens, currentIndent, separator = ' ') {
2883
+ const lines = [];
2884
+ let currentLine = '';
2885
+ for (const token of tokens) {
2886
+ const testLine = currentLine ? currentLine + separator + token : token;
2887
+ if (testLine.length > (this.maxLineLength - currentIndent - 6)) {
2888
+ if (currentLine) {
2889
+ lines.push(currentLine);
2890
+ currentLine = token;
2891
+ }
2892
+ else {
2893
+ lines.push(token);
2894
+ }
2895
+ }
2896
+ else {
2897
+ currentLine = testLine;
2898
+ }
2899
+ }
2900
+ if (currentLine)
2901
+ lines.push(currentLine);
2902
+ return lines;
2805
2903
  }
2806
2904
  /**
2807
2905
  * Render multiline attributes for a tag
2808
2906
  */
2809
- renderMultilineAttributes(tagName, attributes, inlineNodes = [], allChildren = [], isSelfClosing = false, isVoid = false, hasBodyContent = false) {
2907
+ renderMultilineAttributes(tagName, _attributes, _inlineNodes = [], allChildren = [], isSelfClosing = false, isVoid = false, hasBodyContent = false) {
2810
2908
  const indent = this.indent();
2811
2909
  this.push(indent + `<${tagName}`);
2812
2910
  this.withIndent(() => {
2813
- // Render children in order, handling both attributes and ERB nodes
2814
2911
  allChildren.forEach(child => {
2815
2912
  if (child instanceof HTMLAttributeNode || child.type === 'AST_HTML_ATTRIBUTE_NODE') {
2816
2913
  this.push(this.indent() + this.renderAttribute(child));
@@ -2878,6 +2975,7 @@ class Printer extends Visitor {
2878
2975
  const open = node.open_tag;
2879
2976
  const tagName = open.tag_name?.value ?? "";
2880
2977
  const indent = this.indent();
2978
+ this.currentTagName = tagName;
2881
2979
  const attributes = this.extractAttributes(open.children);
2882
2980
  const inlineNodes = this.extractInlineNodes(open.children);
2883
2981
  const hasTextFlow = this.isInTextFlowContext(null, node.body);
@@ -3019,7 +3117,7 @@ class Printer extends Visitor {
3019
3117
  const inline = hasComplexERB ? "" : this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children);
3020
3118
  const nestingDepth = this.getMaxNestingDepth(children, 0);
3021
3119
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
3022
- const shouldKeepInline = this.shouldRenderInline(totalAttributeCount, inline.length, indent.length, this.maxLineLength, hasComplexERB, nestingDepth, inlineNodes.length);
3120
+ const shouldKeepInline = this.shouldRenderInline(totalAttributeCount, inline.length, indent.length, this.maxLineLength, hasComplexERB, nestingDepth, inlineNodes.length, this.hasMultilineAttributes(attributes));
3023
3121
  if (shouldKeepInline) {
3024
3122
  if (children.length === 0) {
3025
3123
  if (isSelfClosing) {
@@ -3128,19 +3226,24 @@ class Printer extends Visitor {
3128
3226
  }
3129
3227
  }
3130
3228
  if (isInlineElement && children.length === 0) {
3131
- let result = `<${tagName}`;
3132
- result += this.renderAttributesString(attributes);
3133
- if (isSelfClosing) {
3134
- result += " />";
3135
- }
3136
- else if (node.is_void) {
3137
- result += ">";
3138
- }
3139
- else {
3140
- result += `></${tagName}>`;
3229
+ const inline = this.renderInlineOpen(tagName, attributes, isSelfClosing, inlineNodes, open.children);
3230
+ const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
3231
+ const shouldKeepInline = this.shouldRenderInline(totalAttributeCount, inline.length, indent.length, this.maxLineLength, false, 0, inlineNodes.length, this.hasMultilineAttributes(attributes));
3232
+ if (shouldKeepInline) {
3233
+ let result = `<${tagName}`;
3234
+ result += this.renderAttributesString(attributes);
3235
+ if (isSelfClosing) {
3236
+ result += " />";
3237
+ }
3238
+ else if (node.is_void) {
3239
+ result += ">";
3240
+ }
3241
+ else {
3242
+ result += `></${tagName}>`;
3243
+ }
3244
+ this.push(indent + result);
3245
+ return;
3141
3246
  }
3142
- this.push(indent + result);
3143
- return;
3144
3247
  }
3145
3248
  this.renderMultilineAttributes(tagName, attributes, inlineNodes, open.children, isSelfClosing, node.is_void, children.length > 0);
3146
3249
  if (!isSelfClosing && !node.is_void && children.length > 0) {
@@ -3168,7 +3271,7 @@ class Printer extends Visitor {
3168
3271
  }
3169
3272
  const inline = this.renderInlineOpen(tagName, attributes, node.is_void, inlineNodes, node.children);
3170
3273
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
3171
- const shouldKeepInline = this.shouldRenderInline(totalAttributeCount, inline.length, indent.length, this.maxLineLength, false, 0, inlineNodes.length);
3274
+ const shouldKeepInline = this.shouldRenderInline(totalAttributeCount, inline.length, indent.length, this.maxLineLength, false, 0, inlineNodes.length, this.hasMultilineAttributes(attributes));
3172
3275
  if (shouldKeepInline) {
3173
3276
  this.push(indent + inline);
3174
3277
  return;
@@ -3182,7 +3285,7 @@ class Printer extends Visitor {
3182
3285
  const inlineNodes = this.extractInlineNodes(node.attributes);
3183
3286
  const inline = this.renderInlineOpen(tagName, attributes, true, inlineNodes, node.attributes);
3184
3287
  const totalAttributeCount = this.getTotalAttributeCount(attributes, inlineNodes);
3185
- const shouldKeepInline = this.shouldRenderInline(totalAttributeCount, inline.length, indent.length, this.maxLineLength, false, 0, inlineNodes.length);
3288
+ const shouldKeepInline = this.shouldRenderInline(totalAttributeCount, inline.length, indent.length, this.maxLineLength, false, 0, inlineNodes.length, this.hasMultilineAttributes(attributes));
3186
3289
  if (shouldKeepInline) {
3187
3290
  this.push(indent + inline);
3188
3291
  return;
@@ -3256,19 +3359,24 @@ class Printer extends Visitor {
3256
3359
  const open = node.comment_start?.value ?? "";
3257
3360
  const close = node.comment_end?.value ?? "";
3258
3361
  let inner;
3259
- if (node.comment_start && node.comment_end) {
3260
- // TODO: use .value
3261
- const [_, startIndex] = node.comment_start.range.toArray();
3262
- const [endIndex] = node.comment_end.range.toArray();
3263
- const rawInner = this.source.slice(startIndex, endIndex);
3264
- inner = ` ${rawInner.trim()} `;
3265
- }
3266
- else {
3362
+ if (node.children && node.children.length > 0) {
3267
3363
  inner = node.children.map(child => {
3268
- const prevLines = this.lines.length;
3269
- this.visit(child);
3270
- return this.lines.slice(prevLines).join("");
3364
+ if (child instanceof HTMLTextNode || child.type === 'AST_HTML_TEXT_NODE') {
3365
+ return child.content;
3366
+ }
3367
+ else if (child instanceof LiteralNode || child.type === 'AST_LITERAL_NODE') {
3368
+ return child.content;
3369
+ }
3370
+ else {
3371
+ const prevLines = this.lines.length;
3372
+ this.visit(child);
3373
+ return this.lines.slice(prevLines).join("");
3374
+ }
3271
3375
  }).join("");
3376
+ inner = ` ${inner.trim()} `;
3377
+ }
3378
+ else {
3379
+ inner = "";
3272
3380
  }
3273
3381
  this.push(indent + open + inner + close);
3274
3382
  }
@@ -3277,10 +3385,8 @@ class Printer extends Visitor {
3277
3385
  const open = node.tag_opening?.value ?? "";
3278
3386
  const close = node.tag_closing?.value ?? "";
3279
3387
  let inner;
3280
- if (node.tag_opening && node.tag_closing) {
3281
- const [, openingEnd] = node.tag_opening.range.toArray();
3282
- const [closingStart] = node.tag_closing.range.toArray();
3283
- const rawInner = this.source.slice(openingEnd, closingStart);
3388
+ if (node.content && node.content.value) {
3389
+ const rawInner = node.content.value;
3284
3390
  const lines = rawInner.split("\n");
3285
3391
  if (lines.length > 2) {
3286
3392
  const childIndent = indent + " ".repeat(this.indentWidth);
@@ -3291,32 +3397,41 @@ class Printer extends Visitor {
3291
3397
  inner = ` ${rawInner.trim()} `;
3292
3398
  }
3293
3399
  }
3294
- else {
3295
- inner = node.children
3296
- .map((child) => {
3400
+ else if (node.children) {
3401
+ inner = node.children.map((child) => {
3297
3402
  const prevLines = this.lines.length;
3298
3403
  this.visit(child);
3299
3404
  return this.lines.slice(prevLines).join("");
3300
- })
3301
- .join("");
3405
+ }).join("");
3406
+ }
3407
+ else {
3408
+ inner = "";
3302
3409
  }
3303
3410
  this.push(indent + open + inner + close);
3304
3411
  }
3305
3412
  visitHTMLDoctypeNode(node) {
3306
3413
  const indent = this.indent();
3307
3414
  const open = node.tag_opening?.value ?? "";
3308
- let innerDoctype;
3309
- if (node.tag_opening && node.tag_closing) {
3310
- // TODO: use .value
3311
- const [, openingEnd] = node.tag_opening.range.toArray();
3312
- const [closingStart] = node.tag_closing.range.toArray();
3313
- innerDoctype = this.source.slice(openingEnd, closingStart);
3314
- }
3315
- else {
3316
- innerDoctype = node.children
3317
- .map(child => child instanceof HTMLTextNode ? child.content : (() => { const prevLines = this.lines.length; this.visit(child); return this.lines.slice(prevLines).join(""); })())
3318
- .join("");
3319
- }
3415
+ let innerDoctype = node.children.map(child => {
3416
+ if (child instanceof HTMLTextNode || child.type === 'AST_HTML_TEXT_NODE') {
3417
+ return child.content;
3418
+ }
3419
+ else if (child instanceof LiteralNode || child.type === 'AST_LITERAL_NODE') {
3420
+ return child.content;
3421
+ }
3422
+ else if (child instanceof ERBContentNode || child.type === 'AST_ERB_CONTENT_NODE') {
3423
+ const erbNode = child;
3424
+ const erbOpen = erbNode.tag_opening?.value ?? "";
3425
+ const erbContent = erbNode.content?.value ?? "";
3426
+ const erbClose = erbNode.tag_closing?.value ?? "";
3427
+ return erbOpen + (erbContent ? ` ${erbContent.trim()} ` : "") + erbClose;
3428
+ }
3429
+ else {
3430
+ const prevLines = this.lines.length;
3431
+ this.visit(child);
3432
+ return this.lines.slice(prevLines).join("");
3433
+ }
3434
+ }).join("");
3320
3435
  const close = node.tag_closing?.value ?? "";
3321
3436
  this.push(indent + open + innerDoctype + close);
3322
3437
  }
@@ -3563,6 +3678,8 @@ class Printer extends Visitor {
3563
3678
  const erbContent = this.lines.join("");
3564
3679
  currentLineContent += erbContent;
3565
3680
  if ((indent.length + currentLineContent.length) > Math.max(this.maxLineLength, 120)) {
3681
+ this.lines = oldLines;
3682
+ this.inlineMode = oldInlineMode;
3566
3683
  this.visitTextFlowChildrenMultiline(children);
3567
3684
  return;
3568
3685
  }
@@ -3704,14 +3821,24 @@ class Printer extends Visitor {
3704
3821
  open_quote = '"';
3705
3822
  close_quote = '"';
3706
3823
  }
3707
- value = open_quote + content + close_quote;
3824
+ if (this.isFormattableAttribute(name, this.currentTagName)) {
3825
+ if (name === 'class') {
3826
+ value = this.formatClassAttribute(content, name, equals, open_quote, close_quote);
3827
+ }
3828
+ else {
3829
+ value = this.formatMultilineAttribute(content, name, equals, open_quote, close_quote);
3830
+ }
3831
+ }
3832
+ else {
3833
+ value = open_quote + content + close_quote;
3834
+ }
3708
3835
  }
3709
3836
  return name + equals + value;
3710
3837
  }
3711
3838
  /**
3712
3839
  * Try to render a complete element inline including opening tag, children, and closing tag
3713
3840
  */
3714
- tryRenderInlineFull(node, tagName, attributes, children) {
3841
+ tryRenderInlineFull(_node, tagName, attributes, children) {
3715
3842
  let result = `<${tagName}`;
3716
3843
  result += this.renderAttributesString(attributes);
3717
3844
  result += ">";