@takazudo/mdx-formatter 0.4.0 → 0.4.2

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,14 +287,14 @@ 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)) {
292
+ const endLine = jsxNode.position.end.line - 1;
293
+ // Skip JSX elements inside table rows
294
+ const currentLineContent = this.lines[endLine];
295
+ if (currentLineContent && currentLineContent.trim().startsWith('|')) {
247
296
  return;
248
297
  }
249
- const endLine = jsxNode.position.end.line - 1;
250
298
  if (endLine < this.lines.length - 1) {
251
299
  const nextLine = this.lines[endLine + 1];
252
300
  // Check if next line is text (not empty, not heading)
@@ -359,21 +407,9 @@ export class HybridFormatter {
359
407
  });
360
408
  }
361
409
  collectJsxFormatOperations(operations) {
362
- // Initialize HTML formatter to check if elements are HTML
363
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
364
410
  visit(this.ast, (node) => {
365
- if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
366
- node.position) {
411
+ if (this.isFormattableJsxNode(node)) {
367
412
  const jsxNode = node;
368
- // Skip HTML elements - they will be handled by HTML formatter
369
- if (jsxNode.name && htmlFormatter.isHtmlElement(jsxNode.name)) {
370
- return;
371
- }
372
- // Skip components in the ignore list
373
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
374
- if (jsxNode.name && ignoreComponents.includes(jsxNode.name)) {
375
- return;
376
- }
377
413
  const startLine = jsxNode.position.start.line - 1;
378
414
  const endLine = jsxNode.position.end.line - 1;
379
415
  // Extract the original JSX text
@@ -395,8 +431,7 @@ export class HybridFormatter {
395
431
  }
396
432
  needsJsxFormatting(node, originalText) {
397
433
  // Check if component is in ignore list
398
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
399
- if (node.name && ignoreComponents.includes(node.name)) {
434
+ if (node.name && this.ignoreComponents.includes(node.name)) {
400
435
  return false;
401
436
  }
402
437
  const attributes = node.attributes || [];
@@ -415,6 +450,30 @@ export class HybridFormatter {
415
450
  const expectedIndent = this.indentDetector
416
451
  ? this.indentDetector.getIndentString()
417
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
+ }
418
477
  // Check for attributes split across lines incorrectly
419
478
  // Like: <ExImg src="..." className="..."
420
479
  // alt="..." />
@@ -423,24 +482,20 @@ export class HybridFormatter {
423
482
  // Has attributes on first line but doesn't close
424
483
  return true;
425
484
  }
426
- // Check if /> is on its own line (this is always incorrect)
427
- 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++) {
428
487
  const trimmed = lines[i].trim();
488
+ // Check if /> is on its own line (this is always incorrect)
429
489
  if (trimmed === '/>') {
430
- // /> should never be on its own line in JSX/MDX
431
490
  return true;
432
491
  }
433
- }
434
- // Check indentation on subsequent lines
435
- for (let i = 1; i < lines.length; i++) {
436
- const line = lines[i];
437
- const trimmed = line.trim();
438
492
  // Skip empty lines or closing tag
439
493
  if (!trimmed || trimmed.startsWith(`</${node.name}`)) {
440
494
  continue;
441
495
  }
442
496
  // Check proper indentation for attribute lines
443
497
  // Attributes should be indented by exactly one indent level
498
+ const line = lines[i];
444
499
  if (!line.startsWith(expectedIndent)) {
445
500
  return true;
446
501
  }
@@ -543,9 +598,19 @@ export class HybridFormatter {
543
598
  }
544
599
  // Add children content if not self-closing
545
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);
546
604
  // Extract children content from original
547
605
  const childrenText = this.extractChildrenText(node, originalText);
548
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
+ }
549
614
  // Check if this is a container component that needs indented content
550
615
  const containerComponents = this.settings.indentJsxContent.containerComponents || [];
551
616
  const isContainer = containerComponents.includes(name);
@@ -561,6 +626,13 @@ export class HybridFormatter {
561
626
  else {
562
627
  lines.push(...childrenText.split('\n'));
563
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
+ }
564
636
  }
565
637
  // Closing tag
566
638
  lines.push(`</${name}>`);
@@ -751,15 +823,13 @@ export class HybridFormatter {
751
823
  * Collect HTML block formatting operations using HtmlBlockFormatter
752
824
  */
753
825
  async collectHtmlBlockOperations(operations) {
754
- // Initialize the HTML formatter
755
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx);
756
826
  // Collect all HTML nodes first, tracking parent-child relationships
757
827
  const htmlNodes = [];
758
828
  const processedRanges = [];
759
829
  visit(this.ast, 'mdxJsxFlowElement', (node) => {
760
830
  const jsxNode = node;
761
831
  // Check if this is an HTML element (not a JSX component)
762
- if (jsxNode.name && htmlFormatter.isHtmlElement(jsxNode.name)) {
832
+ if (jsxNode.name && this.htmlFormatter.isHtmlElement(jsxNode.name)) {
763
833
  const startLine = jsxNode.position.start.line;
764
834
  const endLine = jsxNode.position.end.line;
765
835
  // Check if this node is within an already processed range
@@ -783,7 +853,7 @@ export class HybridFormatter {
783
853
  const htmlContent = this.extractHtmlFromNode(node);
784
854
  if (htmlContent) {
785
855
  // Format just this HTML block using Prettier
786
- const formatted = await htmlFormatter.formatWithPrettier(htmlContent);
856
+ const formatted = await this.htmlFormatter.formatWithPrettier(htmlContent);
787
857
  // Only add operation if formatting changed the content
788
858
  if (formatted !== htmlContent) {
789
859
  operations.push({
@@ -813,18 +883,10 @@ export class HybridFormatter {
813
883
  }
814
884
  collectJsxIndentOperations(operations) {
815
885
  const containerNames = this.settings.indentJsxContent.containerComponents || [];
816
- // Initialize HTML formatter to check if elements are HTML
817
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
818
- // Get components to ignore
819
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
820
886
  visit(this.ast, (node) => {
821
- if (node.type === 'mdxJsxFlowElement' && node.position) {
887
+ if (this.isFormattableJsxNode(node)) {
822
888
  const jsxNode = node;
823
- if (containerNames.includes(jsxNode.name || '') &&
824
- // Skip HTML elements - they are handled by HTML formatter
825
- !htmlFormatter.isHtmlElement(jsxNode.name || '') &&
826
- // Skip ignored components
827
- !ignoreComponents.includes(jsxNode.name || '')) {
889
+ if (containerNames.includes(jsxNode.name || '')) {
828
890
  const startLine = jsxNode.position.start.line - 1;
829
891
  const endLine = jsxNode.position.end.line - 1;
830
892
  // Check if content needs indentation
@@ -850,19 +912,10 @@ export class HybridFormatter {
850
912
  }
851
913
  collectBlockJsxEmptyLineOperations(operations) {
852
914
  const blockComponents = this.settings.addEmptyLinesInBlockJsx.blockComponents || [];
853
- // Initialize HTML formatter to check if elements are HTML
854
- const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx || {});
855
- // Get components to ignore
856
- const ignoreComponents = this.settings.formatMultiLineJsx.ignoreComponents || [];
857
915
  visit(this.ast, (node) => {
858
- if ((node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') &&
859
- node.position) {
916
+ if (this.isFormattableJsxNode(node)) {
860
917
  const jsxNode = node;
861
- if (blockComponents.includes(jsxNode.name || '') &&
862
- // Skip HTML elements - they are handled by HTML formatter
863
- !htmlFormatter.isHtmlElement(jsxNode.name || '') &&
864
- // Skip ignored components
865
- !ignoreComponents.includes(jsxNode.name || '')) {
918
+ if (blockComponents.includes(jsxNode.name || '')) {
866
919
  const startLine = jsxNode.position.start.line - 1;
867
920
  const endLine = jsxNode.position.end.line - 1;
868
921
  // Handle single-line components
@@ -1014,6 +1067,38 @@ export class HybridFormatter {
1014
1067
  }
1015
1068
  });
1016
1069
  }
1070
+ /**
1071
+ * Remove replaceLines/replaceHtmlBlock operations that are strictly contained
1072
+ * within a wider replacement range (parent wins over child). Mutates the array in place.
1073
+ */
1074
+ filterOverlappingReplacements(operations) {
1075
+ const replaceOps = [];
1076
+ for (const op of operations) {
1077
+ if ((op.type === 'replaceLines' || op.type === 'replaceHtmlBlock') && 'endLine' in op) {
1078
+ replaceOps.push(op);
1079
+ }
1080
+ }
1081
+ // Find ops that are strictly contained within another op's range
1082
+ const dropped = new Set();
1083
+ for (const inner of replaceOps) {
1084
+ for (const outer of replaceOps) {
1085
+ if (inner === outer)
1086
+ continue;
1087
+ if (inner.startLine >= outer.startLine &&
1088
+ inner.endLine <= outer.endLine &&
1089
+ (inner.startLine !== outer.startLine || inner.endLine !== outer.endLine)) {
1090
+ dropped.add(inner);
1091
+ break;
1092
+ }
1093
+ }
1094
+ }
1095
+ // Remove dropped operations in place
1096
+ for (let i = operations.length - 1; i >= 0; i--) {
1097
+ if (dropped.has(operations[i])) {
1098
+ operations.splice(i, 1);
1099
+ }
1100
+ }
1101
+ }
1017
1102
  getLineAtPosition(charPos) {
1018
1103
  for (let i = 0; i < this.positionMap.length; i++) {
1019
1104
  if (charPos >= this.positionMap[i].start && charPos <= this.positionMap[i].end) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takazudo/mdx-formatter",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "AST-based markdown and MDX formatter with Japanese text support",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",