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