@takazudo/mdx-formatter 0.4.1 → 0.4.3

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.
@@ -11,10 +11,19 @@ export declare class HybridFormatter {
11
11
  private ast;
12
12
  private positionMap;
13
13
  private indentDetector;
14
+ private readonly htmlFormatter;
14
15
  constructor(content: string, settings?: FormatterSettings | null);
15
16
  parseAST(content: string): Root;
16
17
  fixStandaloneClosingTags(content: string): string;
17
18
  buildPositionMap(): PositionMapEntry[];
19
+ /**
20
+ * Get the list of components to ignore during formatting.
21
+ */
22
+ private get ignoreComponents();
23
+ /**
24
+ * Check if a JSX node should be processed (correct type, has position, not HTML, not ignored).
25
+ */
26
+ private isFormattableJsxNode;
18
27
  /**
19
28
  * Detect indentation from content and update formatter settings
20
29
  */
@@ -64,6 +73,11 @@ export declare class HybridFormatter {
64
73
  */
65
74
  preprocessYamlForParsing(yamlText: string): string;
66
75
  collectYamlFormatOperations(operations: FormatterOperation[]): void;
76
+ /**
77
+ * Remove replaceLines/replaceHtmlBlock operations that are strictly contained
78
+ * within a wider replacement range (parent wins over child). Mutates the array in place.
79
+ */
80
+ private filterOverlappingReplacements;
67
81
  getLineAtPosition(charPos: number): number;
68
82
  applyOperation(lines: string[], op: FormatterOperation): void;
69
83
  }
@@ -20,12 +20,14 @@ export class HybridFormatter {
20
20
  ast;
21
21
  positionMap;
22
22
  indentDetector;
23
+ htmlFormatter;
23
24
  constructor(content, settings = null) {
24
25
  this.originalContent = content;
25
26
  this.content = content; // Use content directly without preprocessing
26
27
  this.lines = this.content.split('\n');
27
28
  this.settings = settings ? deepCloneSettings(settings) : deepCloneSettings(formatterSettings);
28
29
  this.indentDetector = null;
30
+ this.htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
29
31
  // Auto-detect indentation if enabled
30
32
  if (this.settings.autoDetectIndent && this.settings.autoDetectIndent.enabled) {
31
33
  this.detectAndApplyIndentation();
@@ -96,6 +98,29 @@ export class HybridFormatter {
96
98
  }
97
99
  return map;
98
100
  }
101
+ /**
102
+ * Get the list of components to ignore during formatting.
103
+ */
104
+ get ignoreComponents() {
105
+ return this.settings.formatMultiLineJsx.ignoreComponents || [];
106
+ }
107
+ /**
108
+ * Check if a JSX node should be processed (correct type, has position, not HTML, not ignored).
109
+ */
110
+ isFormattableJsxNode(node) {
111
+ if ((node.type !== 'mdxJsxFlowElement' && node.type !== 'mdxJsxTextElement') ||
112
+ !node.position) {
113
+ return false;
114
+ }
115
+ const jsxNode = node;
116
+ if (jsxNode.name && this.htmlFormatter.isHtmlElement(jsxNode.name)) {
117
+ return false;
118
+ }
119
+ if (jsxNode.name && this.ignoreComponents.includes(jsxNode.name)) {
120
+ return false;
121
+ }
122
+ return true;
123
+ }
99
124
  /**
100
125
  * Detect indentation from content and update formatter settings
101
126
  */
@@ -185,9 +210,34 @@ export class HybridFormatter {
185
210
  if (this.settings.formatHtmlBlocksInMdx && this.settings.formatHtmlBlocksInMdx.enabled) {
186
211
  await this.collectHtmlBlockOperations(operations);
187
212
  }
213
+ // When parent and child JSX elements both produce replaceLines operations
214
+ // with overlapping ranges, keep only the wider range (parent).
215
+ this.filterOverlappingReplacements(operations);
216
+ // Collect line ranges covered by replaceLines/replaceHtmlBlock operations.
217
+ // Other operations (insertLine, indentLine) that fall within these ranges
218
+ // must be dropped to prevent duplication — the replacement already rewrites
219
+ // the entire range.
220
+ const replacedRanges = [];
221
+ for (const op of operations) {
222
+ if ((op.type === 'replaceLines' || op.type === 'replaceHtmlBlock') && 'endLine' in op) {
223
+ replacedRanges.push([op.startLine, op.endLine]);
224
+ }
225
+ }
226
+ const isInsideReplacedRange = (line) => {
227
+ return replacedRanges.some(([start, end]) => line >= start && line <= end);
228
+ };
229
+ // Filter out operations that conflict with replaceLines ranges
230
+ const filteredOperations = operations.filter((op) => {
231
+ if (op.type === 'replaceLines' || op.type === 'replaceHtmlBlock') {
232
+ return true; // Always keep replacement operations
233
+ }
234
+ // Drop insertLine / indentLine / fixListIndent if they target a line
235
+ // inside a range that will be completely replaced
236
+ return !isInsideReplacedRange(op.startLine);
237
+ });
188
238
  // Sort operations by position (reverse order to preserve positions)
189
239
  // Also sort by operation type to ensure replacements happen before insertions at the same line
190
- operations.sort((a, b) => {
240
+ filteredOperations.sort((a, b) => {
191
241
  if (b.startLine !== a.startLine) {
192
242
  return b.startLine - a.startLine;
193
243
  }
@@ -204,7 +254,7 @@ export class HybridFormatter {
204
254
  // Apply operations to lines with deduplication
205
255
  const resultLines = [...this.lines];
206
256
  const appliedOperations = new Set();
207
- for (const op of operations) {
257
+ for (const op of filteredOperations) {
208
258
  // Create a unique key for this operation
209
259
  const endLine = 'endLine' in op ? op.endLine : op.startLine;
210
260
  const opKey = `${op.type}-${op.startLine}-${endLine}`;
@@ -220,8 +270,6 @@ export class HybridFormatter {
220
270
  return result.replace(/\n{3,}/g, '\n\n');
221
271
  }
222
272
  collectSpacingOperations(operations) {
223
- // Initialize HTML formatter to check if elements are HTML
224
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
225
273
  visit(this.ast, (node) => {
226
274
  // Add spacing after headings
227
275
  if (node.type === 'heading' && node.position) {
@@ -239,13 +287,8 @@ export class HybridFormatter {
239
287
  }
240
288
  }
241
289
  // FIXED: Add spacing after JSX components when followed by text or another JSX component
242
- if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
243
- node.position) {
290
+ if (this.isFormattableJsxNode(node)) {
244
291
  const jsxNode = node;
245
- // Skip HTML elements - they will be handled by HTML formatter
246
- if (jsxNode.name && htmlFormatter.isHtmlElement(jsxNode.name)) {
247
- return;
248
- }
249
292
  const endLine = jsxNode.position.end.line - 1;
250
293
  // Skip JSX elements inside table rows
251
294
  const currentLineContent = this.lines[endLine];
@@ -364,21 +407,9 @@ export class HybridFormatter {
364
407
  });
365
408
  }
366
409
  collectJsxFormatOperations(operations) {
367
- // Initialize HTML formatter to check if elements are HTML
368
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
369
410
  visit(this.ast, (node) => {
370
- if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
371
- node.position) {
411
+ if (this.isFormattableJsxNode(node)) {
372
412
  const jsxNode = node;
373
- // Skip HTML elements - they will be handled by HTML formatter
374
- if (jsxNode.name && htmlFormatter.isHtmlElement(jsxNode.name)) {
375
- return;
376
- }
377
- // Skip components in the ignore list
378
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
379
- if (jsxNode.name && ignoreComponents.includes(jsxNode.name)) {
380
- return;
381
- }
382
413
  const startLine = jsxNode.position.start.line - 1;
383
414
  const endLine = jsxNode.position.end.line - 1;
384
415
  // Extract the original JSX text
@@ -400,8 +431,7 @@ export class HybridFormatter {
400
431
  }
401
432
  needsJsxFormatting(node, originalText) {
402
433
  // Check if component is in ignore list
403
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
404
- if (node.name && ignoreComponents.includes(node.name)) {
434
+ if (node.name && this.ignoreComponents.includes(node.name)) {
405
435
  return false;
406
436
  }
407
437
  const attributes = node.attributes || [];
@@ -420,6 +450,30 @@ export class HybridFormatter {
420
450
  const expectedIndent = this.indentDetector
421
451
  ? this.indentDetector.getIndentString()
422
452
  : ' '.repeat(this.settings.formatMultiLineJsx.indentSize || 2);
453
+ // Determine where the opening tag ends so we only check attribute lines.
454
+ // For self-closing elements the opening tag ends at /> or the last line.
455
+ // For non-self-closing elements the opening tag ends at the first line
456
+ // containing a bare > (not />).
457
+ let openingTagEndLine = lines.length - 1;
458
+ const hasClosingTag = originalText.includes(`</${node.name}>`);
459
+ if (hasClosingTag) {
460
+ let braceDepth = 0;
461
+ for (let i = 0; i < lines.length; i++) {
462
+ const line = lines[i];
463
+ // Track brace depth to avoid matching > inside expressions like {a > b}
464
+ for (const ch of line) {
465
+ if (ch === '{')
466
+ braceDepth++;
467
+ if (ch === '}')
468
+ braceDepth--;
469
+ }
470
+ const trimmed = line.trim();
471
+ if (braceDepth === 0 && trimmed.endsWith('>') && !trimmed.endsWith('/>')) {
472
+ openingTagEndLine = i;
473
+ break;
474
+ }
475
+ }
476
+ }
423
477
  // Check for attributes split across lines incorrectly
424
478
  // Like: <ExImg src="..." className="..."
425
479
  // alt="..." />
@@ -428,24 +482,20 @@ export class HybridFormatter {
428
482
  // Has attributes on first line but doesn't close
429
483
  return true;
430
484
  }
431
- // Check if /> is on its own line (this is always incorrect)
432
- for (let i = 1; i < lines.length; i++) {
485
+ // Only check lines within the opening tag (not children content)
486
+ for (let i = 1; i <= openingTagEndLine; i++) {
433
487
  const trimmed = lines[i].trim();
488
+ // Check if /> is on its own line (this is always incorrect)
434
489
  if (trimmed === '/>') {
435
- // /> should never be on its own line in JSX/MDX
436
490
  return true;
437
491
  }
438
- }
439
- // Check indentation on subsequent lines
440
- for (let i = 1; i < lines.length; i++) {
441
- const line = lines[i];
442
- const trimmed = line.trim();
443
492
  // Skip empty lines or closing tag
444
493
  if (!trimmed || trimmed.startsWith(`</${node.name}`)) {
445
494
  continue;
446
495
  }
447
496
  // Check proper indentation for attribute lines
448
497
  // Attributes should be indented by exactly one indent level
498
+ const line = lines[i];
449
499
  if (!line.startsWith(expectedIndent)) {
450
500
  return true;
451
501
  }
@@ -548,9 +598,19 @@ export class HybridFormatter {
548
598
  }
549
599
  // Add children content if not self-closing
550
600
  if (!selfClosing) {
601
+ // Check if this is a block component that needs empty lines
602
+ const blockComponents = this.settings.addEmptyLinesInBlockJsx?.blockComponents || [];
603
+ const isBlockComponent = this.settings.addEmptyLinesInBlockJsx?.enabled !== false && blockComponents.includes(name);
551
604
  // Extract children content from original
552
605
  const childrenText = this.extractChildrenText(node, originalText);
553
606
  if (childrenText) {
607
+ // Add empty line after opening tag for block components
608
+ if (isBlockComponent) {
609
+ const firstContentLine = childrenText.split('\n')[0];
610
+ if (firstContentLine && firstContentLine.trim() !== '') {
611
+ lines.push('');
612
+ }
613
+ }
554
614
  // Check if this is a container component that needs indented content
555
615
  const containerComponents = this.settings.indentJsxContent.containerComponents || [];
556
616
  const isContainer = containerComponents.includes(name);
@@ -566,6 +626,13 @@ export class HybridFormatter {
566
626
  else {
567
627
  lines.push(...childrenText.split('\n'));
568
628
  }
629
+ // Add empty line before closing tag for block components
630
+ if (isBlockComponent) {
631
+ const lastContentLine = lines[lines.length - 1];
632
+ if (lastContentLine && lastContentLine.trim() !== '') {
633
+ lines.push('');
634
+ }
635
+ }
569
636
  }
570
637
  // Closing tag
571
638
  lines.push(`</${name}>`);
@@ -756,15 +823,13 @@ export class HybridFormatter {
756
823
  * Collect HTML block formatting operations using HtmlBlockFormatter
757
824
  */
758
825
  async collectHtmlBlockOperations(operations) {
759
- // Initialize the HTML formatter
760
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx);
761
826
  // Collect all HTML nodes first, tracking parent-child relationships
762
827
  const htmlNodes = [];
763
828
  const processedRanges = [];
764
829
  visit(this.ast, 'mdxJsxFlowElement', (node) => {
765
830
  const jsxNode = node;
766
831
  // Check if this is an HTML element (not a JSX component)
767
- if (jsxNode.name && htmlFormatter.isHtmlElement(jsxNode.name)) {
832
+ if (jsxNode.name && this.htmlFormatter.isHtmlElement(jsxNode.name)) {
768
833
  const startLine = jsxNode.position.start.line;
769
834
  const endLine = jsxNode.position.end.line;
770
835
  // Check if this node is within an already processed range
@@ -788,7 +853,7 @@ export class HybridFormatter {
788
853
  const htmlContent = this.extractHtmlFromNode(node);
789
854
  if (htmlContent) {
790
855
  // Format just this HTML block using Prettier
791
- const formatted = await htmlFormatter.formatWithPrettier(htmlContent);
856
+ const formatted = await this.htmlFormatter.formatWithPrettier(htmlContent);
792
857
  // Only add operation if formatting changed the content
793
858
  if (formatted !== htmlContent) {
794
859
  operations.push({
@@ -818,18 +883,10 @@ export class HybridFormatter {
818
883
  }
819
884
  collectJsxIndentOperations(operations) {
820
885
  const containerNames = this.settings.indentJsxContent.containerComponents || [];
821
- // Initialize HTML formatter to check if elements are HTML
822
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
823
- // Get components to ignore
824
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
825
886
  visit(this.ast, (node) => {
826
- if (node.type === 'mdxJsxFlowElement' && node.position) {
887
+ if (this.isFormattableJsxNode(node)) {
827
888
  const jsxNode = node;
828
- if (containerNames.includes(jsxNode.name || '') &&
829
- // Skip HTML elements - they are handled by HTML formatter
830
- !htmlFormatter.isHtmlElement(jsxNode.name || '') &&
831
- // Skip ignored components
832
- !ignoreComponents.includes(jsxNode.name || '')) {
889
+ if (containerNames.includes(jsxNode.name || '')) {
833
890
  const startLine = jsxNode.position.start.line - 1;
834
891
  const endLine = jsxNode.position.end.line - 1;
835
892
  // Check if content needs indentation
@@ -855,19 +912,10 @@ export class HybridFormatter {
855
912
  }
856
913
  collectBlockJsxEmptyLineOperations(operations) {
857
914
  const blockComponents = this.settings.addEmptyLinesInBlockJsx.blockComponents || [];
858
- // Initialize HTML formatter to check if elements are HTML
859
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
860
- // Get components to ignore
861
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
862
915
  visit(this.ast, (node) => {
863
- if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
864
- node.position) {
916
+ if (this.isFormattableJsxNode(node)) {
865
917
  const jsxNode = node;
866
- if (blockComponents.includes(jsxNode.name || '') &&
867
- // Skip HTML elements - they are handled by HTML formatter
868
- !htmlFormatter.isHtmlElement(jsxNode.name || '') &&
869
- // Skip ignored components
870
- !ignoreComponents.includes(jsxNode.name || '')) {
918
+ if (blockComponents.includes(jsxNode.name || '')) {
871
919
  const startLine = jsxNode.position.start.line - 1;
872
920
  const endLine = jsxNode.position.end.line - 1;
873
921
  // Handle single-line components
@@ -893,14 +941,24 @@ export class HybridFormatter {
893
941
  }
894
942
  return;
895
943
  }
944
+ // Find the actual end of the opening tag (may span multiple lines
945
+ // for elements with attributes like <Danger\n title="..."\n>)
946
+ let openingTagEndLine = startLine;
947
+ for (let i = startLine; i <= endLine; i++) {
948
+ const trimmed = this.lines[i].trim();
949
+ if (trimmed.endsWith('>') && !trimmed.endsWith('/>') && !trimmed.startsWith('</')) {
950
+ openingTagEndLine = i;
951
+ break;
952
+ }
953
+ }
896
954
  // Check if there's an empty line after the opening tag
897
- if (startLine + 1 < this.lines.length) {
898
- const lineAfterOpening = this.lines[startLine + 1];
955
+ if (openingTagEndLine + 1 < this.lines.length) {
956
+ const lineAfterOpening = this.lines[openingTagEndLine + 1];
899
957
  if (lineAfterOpening.trim() !== '') {
900
958
  // Add empty line after opening tag
901
959
  operations.push({
902
960
  type: 'insertLine',
903
- startLine: startLine + 1,
961
+ startLine: openingTagEndLine + 1,
904
962
  content: '',
905
963
  });
906
964
  }
@@ -1019,6 +1077,38 @@ export class HybridFormatter {
1019
1077
  }
1020
1078
  });
1021
1079
  }
1080
+ /**
1081
+ * Remove replaceLines/replaceHtmlBlock operations that are strictly contained
1082
+ * within a wider replacement range (parent wins over child). Mutates the array in place.
1083
+ */
1084
+ filterOverlappingReplacements(operations) {
1085
+ const replaceOps = [];
1086
+ for (const op of operations) {
1087
+ if ((op.type === 'replaceLines' || op.type === 'replaceHtmlBlock') && 'endLine' in op) {
1088
+ replaceOps.push(op);
1089
+ }
1090
+ }
1091
+ // Find ops that are strictly contained within another op's range
1092
+ const dropped = new Set();
1093
+ for (const inner of replaceOps) {
1094
+ for (const outer of replaceOps) {
1095
+ if (inner === outer)
1096
+ continue;
1097
+ if (inner.startLine >= outer.startLine &&
1098
+ inner.endLine <= outer.endLine &&
1099
+ (inner.startLine !== outer.startLine || inner.endLine !== outer.endLine)) {
1100
+ dropped.add(inner);
1101
+ break;
1102
+ }
1103
+ }
1104
+ }
1105
+ // Remove dropped operations in place
1106
+ for (let i = operations.length - 1; i >= 0; i--) {
1107
+ if (dropped.has(operations[i])) {
1108
+ operations.splice(i, 1);
1109
+ }
1110
+ }
1111
+ }
1022
1112
  getLineAtPosition(charPos) {
1023
1113
  for (let i = 0; i < this.positionMap.length; i++) {
1024
1114
  if (charPos >= this.positionMap[i].start && charPos <= this.positionMap[i].end) {
package/dist/index.js CHANGED
@@ -19,8 +19,19 @@ export function detectMdx(content) {
19
19
  export async function format(content, options = {}) {
20
20
  try {
21
21
  const settings = loadConfig(options);
22
- const formatter = new HybridFormatter(content, settings);
23
- return formatter.format();
22
+ let result = content;
23
+ // Some rule interactions (e.g., list indent normalization + addEmptyLinesInBlockJsx)
24
+ // may require multiple passes to converge. 3 iterations is sufficient for all known
25
+ // cases (most files converge in 1, edge cases in 2).
26
+ const MAX_ITERATIONS = 3;
27
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
28
+ const formatter = new HybridFormatter(result, settings);
29
+ const formatted = await formatter.format();
30
+ if (formatted === result)
31
+ break;
32
+ result = formatted;
33
+ }
34
+ return result;
24
35
  }
25
36
  catch {
26
37
  // Silently return original content if formatting fails
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/mdx-formatter",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "AST-based markdown and MDX formatter with Japanese text support",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",