@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.
- package/dist/hybrid-formatter.d.ts +14 -0
- package/dist/hybrid-formatter.js +142 -57
- package/package.json +1 -1
|
@@ -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
|
}
|
package/dist/hybrid-formatter.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
243
|
-
node.position) {
|
|
290
|
+
if (this.isFormattableJsxNode(node)) {
|
|
244
291
|
const jsxNode = node;
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
427
|
-
for (let i = 1; 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 (
|
|
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
|
|
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) {
|