@takazudo/mdx-formatter 0.5.0-next.2 → 0.5.0-next.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.
- package/README.md +53 -19
- package/dist/browser.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -3
- package/dist/legacy-line-formatter.d.ts +30 -0
- package/dist/legacy-line-formatter.js +469 -0
- package/dist/mdx-formatter.d.ts +96 -0
- package/dist/mdx-formatter.js +1335 -0
- package/dist/types.d.ts +1 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,15 +4,16 @@ AST-based markdown and MDX formatter with Japanese text support. Built on top of
|
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **AST-based formatting**
|
|
8
|
-
- **MDX support**
|
|
9
|
-
- **Japanese text
|
|
10
|
-
- **Docusaurus
|
|
11
|
-
- **HTML block formatting**
|
|
12
|
-
- **GFM features**
|
|
13
|
-
- **
|
|
14
|
-
- **CLI and API**
|
|
15
|
-
- **
|
|
7
|
+
- **AST-based formatting** — Uses remark's AST for reliable, structural formatting
|
|
8
|
+
- **MDX support** — Full support for MDX syntax including JSX components, imports, exports
|
|
9
|
+
- **Japanese text handling** — Preserves Japanese punctuation and text formatting
|
|
10
|
+
- **Docusaurus admonitions** — Preserves `:::note`, `:::tip`, `:::warning` etc. syntax
|
|
11
|
+
- **HTML block formatting** — Proper indentation for HTML blocks (dl, table, ul, div, etc.) via Prettier
|
|
12
|
+
- **GFM features** — Tables, strikethrough, task lists
|
|
13
|
+
- **YAML frontmatter** — Formatting with safe value pre-processing
|
|
14
|
+
- **CLI and API** — Use as command-line tool or import as library
|
|
15
|
+
- **Browser export** — `@takazudo/mdx-formatter/browser` for Vite/WebView/Tauri builds
|
|
16
|
+
- **Configurable** — 10 independently toggleable rules via config file or API
|
|
16
17
|
|
|
17
18
|
## Installation
|
|
18
19
|
|
|
@@ -26,6 +27,12 @@ Or use directly with npx:
|
|
|
26
27
|
npx @takazudo/mdx-formatter --write "**/*.md"
|
|
27
28
|
```
|
|
28
29
|
|
|
30
|
+
### Prerelease (next)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @takazudo/mdx-formatter@next
|
|
34
|
+
```
|
|
35
|
+
|
|
29
36
|
## Usage
|
|
30
37
|
|
|
31
38
|
### CLI
|
|
@@ -36,6 +43,9 @@ mdx-formatter --check "**/*.{md,mdx}"
|
|
|
36
43
|
|
|
37
44
|
# Format files in place
|
|
38
45
|
mdx-formatter --write "**/*.{md,mdx}"
|
|
46
|
+
|
|
47
|
+
# With config file
|
|
48
|
+
mdx-formatter --config .mdx-formatter.json --write "**/*.mdx"
|
|
39
49
|
```
|
|
40
50
|
|
|
41
51
|
### API
|
|
@@ -47,24 +57,48 @@ const formatted = await format('# Hello\nWorld');
|
|
|
47
57
|
console.log(formatted); // '# Hello\n\nWorld'
|
|
48
58
|
```
|
|
49
59
|
|
|
60
|
+
### Browser / WebView
|
|
61
|
+
|
|
62
|
+
```javascript
|
|
63
|
+
import { format } from '@takazudo/mdx-formatter/browser';
|
|
64
|
+
|
|
65
|
+
const formatted = await format('# Hello\nWorld');
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The browser export avoids Node.js `fs`/`path` dependencies. See [Browser Usage](https://takazudomodular.com/pj/mdx-formatter/docs/overview/browser-usage) for details.
|
|
69
|
+
|
|
50
70
|
## Documentation
|
|
51
71
|
|
|
52
|
-
|
|
72
|
+
Full documentation at **[takazudomodular.com/pj/mdx-formatter](https://takazudomodular.com/pj/mdx-formatter/)**:
|
|
73
|
+
|
|
74
|
+
- [Overview](https://takazudomodular.com/pj/mdx-formatter/docs/overview) — Installation, usage, API, configuration
|
|
75
|
+
- [Formatting Rules](https://takazudomodular.com/pj/mdx-formatter/docs/formatting) — How the formatter handles each construct
|
|
76
|
+
- [Options](https://takazudomodular.com/pj/mdx-formatter/docs/options) — Per-rule configuration reference
|
|
77
|
+
- [Architecture](https://takazudomodular.com/pj/mdx-formatter/docs/architecture) — Hybrid formatter approach, Rust rewrite strategy
|
|
78
|
+
- [Changelog](https://takazudomodular.com/pj/mdx-formatter/docs/changelog) — Release history
|
|
79
|
+
|
|
80
|
+
## Rust Rewrite (Experimental)
|
|
81
|
+
|
|
82
|
+
An experimental Rust implementation using [markdown-rs](https://github.com/wooorm/markdown-rs) and [napi-rs](https://napi.rs/) is in progress at `crates/`. See [Architecture: Rust Rewrite](https://takazudomodular.com/pj/mdx-formatter/docs/architecture/rust-rewrite) for details.
|
|
53
83
|
|
|
54
84
|
## Development
|
|
55
85
|
|
|
56
86
|
```bash
|
|
57
|
-
# Install dependencies
|
|
58
|
-
pnpm
|
|
59
|
-
|
|
60
|
-
#
|
|
61
|
-
pnpm test
|
|
87
|
+
pnpm install # Install dependencies
|
|
88
|
+
pnpm build # Compile TypeScript
|
|
89
|
+
pnpm test # Run tests (274 tests)
|
|
90
|
+
pnpm test:watch # Watch mode
|
|
91
|
+
pnpm test:coverage # Coverage report
|
|
92
|
+
pnpm lint # ESLint check
|
|
93
|
+
pnpm check # Prettier + ESLint check
|
|
94
|
+
```
|
|
62
95
|
|
|
63
|
-
|
|
64
|
-
pnpm test:watch
|
|
96
|
+
### Doc Site
|
|
65
97
|
|
|
66
|
-
|
|
67
|
-
pnpm
|
|
98
|
+
```bash
|
|
99
|
+
pnpm --dir doc dev # Dev server on port 3518
|
|
100
|
+
pnpm --dir doc dev:network # Network accessible (0.0.0.0:3518)
|
|
101
|
+
pnpm --dir doc build # Production build
|
|
68
102
|
```
|
|
69
103
|
|
|
70
104
|
## License
|
package/dist/browser.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* that rule depends on prettier, which is a Node.js dependency. If the code
|
|
11
11
|
* path is never reached, bundlers can tree-shake the prettier import away.
|
|
12
12
|
*/
|
|
13
|
-
import {
|
|
13
|
+
import { MdxFormatter } from './mdx-formatter.js';
|
|
14
14
|
import { detectMdx } from './detect-mdx.js';
|
|
15
15
|
import { formatterSettings } from './settings.js';
|
|
16
16
|
import { deepCloneSettings, deepMerge } from './utils.js';
|
|
@@ -35,7 +35,7 @@ export async function format(content, settings = {}) {
|
|
|
35
35
|
try {
|
|
36
36
|
let result = content;
|
|
37
37
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
38
|
-
const formatter = new
|
|
38
|
+
const formatter = new MdxFormatter(result, merged);
|
|
39
39
|
const formatted = await formatter.format();
|
|
40
40
|
if (formatted === result)
|
|
41
41
|
break;
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Main entry point for the markdown formatter
|
|
3
|
-
* Uses
|
|
3
|
+
* Uses MdxFormatter for AST-based formatting
|
|
4
4
|
*/
|
|
5
5
|
import { promises as fs } from 'fs';
|
|
6
|
-
import {
|
|
6
|
+
import { MdxFormatter } from './mdx-formatter.js';
|
|
7
7
|
import { loadConfig } from './load-config.js';
|
|
8
8
|
import { detectMdx } from './detect-mdx.js';
|
|
9
9
|
export { detectMdx };
|
|
@@ -19,7 +19,7 @@ export async function format(content, options = {}) {
|
|
|
19
19
|
// cases (most files converge in 1, edge cases in 2).
|
|
20
20
|
const MAX_ITERATIONS = 3;
|
|
21
21
|
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
22
|
-
const formatter = new
|
|
22
|
+
const formatter = new MdxFormatter(result, settings);
|
|
23
23
|
const formatted = await formatter.format();
|
|
24
24
|
if (formatted === result)
|
|
25
25
|
break;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LegacyLineFormatter - Applies only specific formatting rules to MDX content
|
|
3
|
+
* Does NOT reformat everything, only applies targeted fixes
|
|
4
|
+
*/
|
|
5
|
+
import type { FormatterSettings } from './types.js';
|
|
6
|
+
export declare class LegacyLineFormatter {
|
|
7
|
+
private content;
|
|
8
|
+
private lines;
|
|
9
|
+
settings: FormatterSettings;
|
|
10
|
+
constructor(content: string, settings?: FormatterSettings | null);
|
|
11
|
+
format(): Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Rule 1: Add 1 empty line between elements
|
|
14
|
+
*/
|
|
15
|
+
addEmptyLineBetweenElements(content: string): string;
|
|
16
|
+
shouldAddEmptyLine(currentLine: string, nextLine: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Combined JSX formatting - handles structure and content indentation together
|
|
19
|
+
*/
|
|
20
|
+
formatJsxStructure(content: string): string;
|
|
21
|
+
/**
|
|
22
|
+
* Rule 4: Expand single-line JSX with 2+ props
|
|
23
|
+
*/
|
|
24
|
+
expandSingleLineJsx(content: string): string;
|
|
25
|
+
parseJsxProps(propsString: string): string[];
|
|
26
|
+
/**
|
|
27
|
+
* Rule 7: Validate MDX and throw errors on invalid content
|
|
28
|
+
*/
|
|
29
|
+
validateMdx(content: string): boolean;
|
|
30
|
+
}
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LegacyLineFormatter - Applies only specific formatting rules to MDX content
|
|
3
|
+
* Does NOT reformat everything, only applies targeted fixes
|
|
4
|
+
*/
|
|
5
|
+
import { formatterSettings } from './settings.js';
|
|
6
|
+
import { HtmlBlockFormatter } from './html-block-formatter.js';
|
|
7
|
+
import { deepCloneSettings } from './utils.js';
|
|
8
|
+
export class LegacyLineFormatter {
|
|
9
|
+
content;
|
|
10
|
+
lines;
|
|
11
|
+
settings;
|
|
12
|
+
constructor(content, settings = null) {
|
|
13
|
+
// Normalize line endings to \n
|
|
14
|
+
this.content = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
15
|
+
this.lines = this.content.split('\n');
|
|
16
|
+
this.settings = settings ? deepCloneSettings(settings) : deepCloneSettings(formatterSettings);
|
|
17
|
+
}
|
|
18
|
+
async format() {
|
|
19
|
+
let result = this.content;
|
|
20
|
+
// Apply rules in specific order to avoid conflicts
|
|
21
|
+
// HTML block formatting
|
|
22
|
+
if (this.settings.formatHtmlBlocksInMdx && this.settings.formatHtmlBlocksInMdx.enabled) {
|
|
23
|
+
const htmlFormatter = new HtmlBlockFormatter(this.settings.formatHtmlBlocksInMdx);
|
|
24
|
+
result = await htmlFormatter.format(result);
|
|
25
|
+
}
|
|
26
|
+
if (this.settings.expandSingleLineJsx.enabled) {
|
|
27
|
+
result = this.expandSingleLineJsx(result);
|
|
28
|
+
}
|
|
29
|
+
// Combined JSX formatting - do multi-line first, then content indenting
|
|
30
|
+
if (this.settings.formatMultiLineJsx.enabled || this.settings.indentJsxContent.enabled) {
|
|
31
|
+
result = this.formatJsxStructure(result);
|
|
32
|
+
}
|
|
33
|
+
if (this.settings.addEmptyLineBetweenElements.enabled) {
|
|
34
|
+
result = this.addEmptyLineBetweenElements(result);
|
|
35
|
+
}
|
|
36
|
+
if (this.settings.preserveAdmonitions.enabled) {
|
|
37
|
+
// Admonitions are preserved by not touching them
|
|
38
|
+
// This is handled in other methods by detecting and skipping them
|
|
39
|
+
}
|
|
40
|
+
if (this.settings.errorHandling.throwOnError) {
|
|
41
|
+
this.validateMdx(result);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Rule 1: Add 1 empty line between elements
|
|
47
|
+
*/
|
|
48
|
+
addEmptyLineBetweenElements(content) {
|
|
49
|
+
const lines = content.split('\n');
|
|
50
|
+
const result = [];
|
|
51
|
+
let inCodeBlock = false;
|
|
52
|
+
let inJsxBlock = false;
|
|
53
|
+
let inAdmonition = false;
|
|
54
|
+
let inFrontmatter = false;
|
|
55
|
+
let jsxDepth = 0;
|
|
56
|
+
for (let i = 0; i < lines.length; i++) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
const nextLine = lines[i + 1] || '';
|
|
59
|
+
const trimmedLine = line.trim();
|
|
60
|
+
const trimmedNext = nextLine.trim();
|
|
61
|
+
// Track frontmatter (YAML between --- markers at start of file)
|
|
62
|
+
if (i === 0 && line === '---') {
|
|
63
|
+
inFrontmatter = true;
|
|
64
|
+
}
|
|
65
|
+
else if (inFrontmatter && line === '---') {
|
|
66
|
+
inFrontmatter = false;
|
|
67
|
+
}
|
|
68
|
+
// Track code blocks
|
|
69
|
+
if (line.startsWith('```')) {
|
|
70
|
+
inCodeBlock = !inCodeBlock;
|
|
71
|
+
}
|
|
72
|
+
// Track admonitions
|
|
73
|
+
if (line.match(/^:::(note|tip|info|warning|danger|caution)/)) {
|
|
74
|
+
inAdmonition = true;
|
|
75
|
+
}
|
|
76
|
+
else if (inAdmonition && line === ':::') {
|
|
77
|
+
inAdmonition = false;
|
|
78
|
+
}
|
|
79
|
+
// Track JSX blocks
|
|
80
|
+
if (!inCodeBlock && !inAdmonition && !inFrontmatter) {
|
|
81
|
+
const openTags = (line.match(/<[A-Z][^>]*(?<!\/\s*)>/g) || []).length;
|
|
82
|
+
const closeTags = (line.match(/<\/[A-Z][^>]*>/g) || []).length;
|
|
83
|
+
jsxDepth += openTags - closeTags;
|
|
84
|
+
inJsxBlock = jsxDepth > 0;
|
|
85
|
+
}
|
|
86
|
+
result.push(line);
|
|
87
|
+
// Don't add empty lines inside code blocks, JSX blocks, admonitions, or frontmatter
|
|
88
|
+
if (inCodeBlock || inJsxBlock || inAdmonition || inFrontmatter) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Check if we need to add an empty line
|
|
92
|
+
if (i < lines.length - 1 && trimmedLine && trimmedNext && nextLine !== '') {
|
|
93
|
+
const needsEmptyLine = this.shouldAddEmptyLine(line, nextLine);
|
|
94
|
+
// Only add if there isn't already an empty line
|
|
95
|
+
if (needsEmptyLine && lines[i + 1] !== '') {
|
|
96
|
+
result.push('');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Remove multiple consecutive empty lines (normalize to 1)
|
|
101
|
+
return result.join('\n').replace(/\n{3,}/g, '\n\n');
|
|
102
|
+
}
|
|
103
|
+
shouldAddEmptyLine(currentLine, nextLine) {
|
|
104
|
+
const current = currentLine.trim();
|
|
105
|
+
const next = nextLine.trim();
|
|
106
|
+
// Heading followed by anything
|
|
107
|
+
if (current.match(/^#{1,6}\s/))
|
|
108
|
+
return true;
|
|
109
|
+
// Anything followed by heading
|
|
110
|
+
if (next.match(/^#{1,6}\s/))
|
|
111
|
+
return true;
|
|
112
|
+
// List followed by paragraph
|
|
113
|
+
if (current.match(/^[-*+]\s/) && !next.match(/^[-*+]\s/))
|
|
114
|
+
return true;
|
|
115
|
+
// Paragraph followed by list
|
|
116
|
+
if (!current.match(/^[-*+]\s/) && next.match(/^[-*+]\s/))
|
|
117
|
+
return true;
|
|
118
|
+
// Code block boundaries
|
|
119
|
+
if (current.startsWith('```'))
|
|
120
|
+
return true;
|
|
121
|
+
if (next.startsWith('```'))
|
|
122
|
+
return true;
|
|
123
|
+
// JSX component boundaries (both single line and multi-line)
|
|
124
|
+
if (current.match(/^<[A-Z].*\/>$/))
|
|
125
|
+
return true;
|
|
126
|
+
if (next.match(/^<[A-Z].*\/>$/))
|
|
127
|
+
return true;
|
|
128
|
+
if (current.match(/^<\/[A-Z]/))
|
|
129
|
+
return true; // Closing JSX tag
|
|
130
|
+
if (next.match(/^<[A-Z]/))
|
|
131
|
+
return true; // Opening JSX tag
|
|
132
|
+
// Admonition boundaries
|
|
133
|
+
if (current === ':::')
|
|
134
|
+
return true;
|
|
135
|
+
if (next.match(/^:::(note|tip|info|warning|danger|caution)/))
|
|
136
|
+
return true;
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Combined JSX formatting - handles structure and content indentation together
|
|
141
|
+
*/
|
|
142
|
+
formatJsxStructure(content) {
|
|
143
|
+
const lines = content.split('\n');
|
|
144
|
+
const result = [];
|
|
145
|
+
const jsxStack = [];
|
|
146
|
+
let inCodeBlock = false;
|
|
147
|
+
const containerComponents = this.settings.indentJsxContent.containerComponents;
|
|
148
|
+
for (let i = 0; i < lines.length; i++) {
|
|
149
|
+
const line = lines[i];
|
|
150
|
+
const trimmed = line.trim();
|
|
151
|
+
// Handle code blocks - but still apply indentation if inside JSX
|
|
152
|
+
if (trimmed.startsWith('```')) {
|
|
153
|
+
if (jsxStack.length > 0) {
|
|
154
|
+
// Code block inside JSX - apply indentation
|
|
155
|
+
const baseIndent = ' '.repeat(jsxStack.length);
|
|
156
|
+
result.push(baseIndent + trimmed);
|
|
157
|
+
inCodeBlock = !inCodeBlock;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
// Code block outside JSX - no indentation
|
|
161
|
+
result.push(line);
|
|
162
|
+
inCodeBlock = !inCodeBlock;
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (inCodeBlock) {
|
|
167
|
+
if (jsxStack.length > 0) {
|
|
168
|
+
// Inside code block within JSX - apply indentation
|
|
169
|
+
const baseIndent = ' '.repeat(jsxStack.length);
|
|
170
|
+
result.push(baseIndent + trimmed);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Inside code block outside JSX - preserve as is
|
|
174
|
+
result.push(line);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Handle JSX tags
|
|
179
|
+
if (trimmed.match(/^<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>$/)) {
|
|
180
|
+
// Opening tag
|
|
181
|
+
const tagMatch = trimmed.match(/^<([A-Z][A-Za-z0-9]*)/);
|
|
182
|
+
const tagName = tagMatch[1];
|
|
183
|
+
// Add appropriate indentation
|
|
184
|
+
const indent = ' '.repeat(jsxStack.length);
|
|
185
|
+
result.push(indent + trimmed);
|
|
186
|
+
// Push to stack if not self-closing
|
|
187
|
+
if (!trimmed.endsWith('/>')) {
|
|
188
|
+
jsxStack.push({
|
|
189
|
+
name: tagName,
|
|
190
|
+
isContainer: containerComponents.includes(tagName),
|
|
191
|
+
hasContent: false,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (trimmed.match(/^<\/([A-Z][A-Za-z0-9]*)>$/)) {
|
|
196
|
+
// Closing tag
|
|
197
|
+
jsxStack.pop();
|
|
198
|
+
const indent = ' '.repeat(jsxStack.length);
|
|
199
|
+
result.push(indent + trimmed);
|
|
200
|
+
}
|
|
201
|
+
else if (jsxStack.length > 0) {
|
|
202
|
+
// Content inside JSX
|
|
203
|
+
const currentTag = jsxStack[jsxStack.length - 1];
|
|
204
|
+
const baseIndent = ' '.repeat(jsxStack.length);
|
|
205
|
+
// Add spacing between heading and content if needed
|
|
206
|
+
if (trimmed.match(/^#{1,6}\s/) && currentTag.hasContent) {
|
|
207
|
+
// This is a heading after some content, add blank line before
|
|
208
|
+
result.push('');
|
|
209
|
+
}
|
|
210
|
+
// If the line is empty, just push empty line (no indent)
|
|
211
|
+
if (trimmed === '') {
|
|
212
|
+
result.push('');
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
result.push(baseIndent + trimmed);
|
|
216
|
+
}
|
|
217
|
+
// Check if we need to add blank line after heading
|
|
218
|
+
if (trimmed.match(/^#{1,6}\s/)) {
|
|
219
|
+
// Look ahead to see if next line is content
|
|
220
|
+
if (i + 1 < lines.length &&
|
|
221
|
+
lines[i + 1].trim() &&
|
|
222
|
+
!lines[i + 1].trim().match(/^#{1,6}\s/)) {
|
|
223
|
+
result.push('');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (trimmed !== '') {
|
|
227
|
+
currentTag.hasContent = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Regular content outside JSX
|
|
232
|
+
result.push(line);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return result.join('\n');
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Rule 4: Expand single-line JSX with 2+ props
|
|
239
|
+
*/
|
|
240
|
+
expandSingleLineJsx(content) {
|
|
241
|
+
const threshold = this.settings.expandSingleLineJsx.propsThreshold;
|
|
242
|
+
// First, handle self-closing tags - use more careful parsing
|
|
243
|
+
const lines = content.split('\n');
|
|
244
|
+
const processedLines = lines.map((line) => {
|
|
245
|
+
// Look for JSX components
|
|
246
|
+
if (line.includes('<') && line.includes('/>')) {
|
|
247
|
+
// Parse more carefully
|
|
248
|
+
const startMatch = line.match(/<([A-Z][A-Za-z0-9]*)\s+/);
|
|
249
|
+
if (startMatch) {
|
|
250
|
+
const componentName = startMatch[1];
|
|
251
|
+
const startIndex = line.indexOf(startMatch[0]);
|
|
252
|
+
const endIndex = line.lastIndexOf('/>');
|
|
253
|
+
if (endIndex > startIndex) {
|
|
254
|
+
const fullTag = line.substring(startIndex, endIndex + 2);
|
|
255
|
+
const propsStart = startMatch[0].length;
|
|
256
|
+
const propsString = fullTag.substring(propsStart, fullTag.length - 2).trim();
|
|
257
|
+
// Parse and count props
|
|
258
|
+
const props = this.parseJsxProps(propsString);
|
|
259
|
+
if (props.length >= threshold) {
|
|
260
|
+
// Format as multi-line
|
|
261
|
+
let result = `<${componentName}`;
|
|
262
|
+
props.forEach((prop) => {
|
|
263
|
+
result += `\n ${prop}`;
|
|
264
|
+
});
|
|
265
|
+
result += '\n/>';
|
|
266
|
+
// Replace in line
|
|
267
|
+
return line.substring(0, startIndex) + result + line.substring(endIndex + 2);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return line;
|
|
273
|
+
});
|
|
274
|
+
content = processedLines.join('\n');
|
|
275
|
+
// Then handle opening tags with content
|
|
276
|
+
content = content.replace(/<([A-Z][A-Za-z0-9]*)\s+([^>]+)>([^<]*)<\/\1>/g, (match, componentName, propsString, innerContent) => {
|
|
277
|
+
// Parse and count props
|
|
278
|
+
const props = this.parseJsxProps(propsString);
|
|
279
|
+
if (props.length >= threshold) {
|
|
280
|
+
// Format as multi-line
|
|
281
|
+
let result = `<${componentName}`;
|
|
282
|
+
props.forEach((prop) => {
|
|
283
|
+
result += `\n ${prop}`;
|
|
284
|
+
});
|
|
285
|
+
result += '\n>\n ' + innerContent.trim() + `\n</${componentName}>`;
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
return match;
|
|
289
|
+
});
|
|
290
|
+
return content;
|
|
291
|
+
}
|
|
292
|
+
parseJsxProps(propsString) {
|
|
293
|
+
const props = [];
|
|
294
|
+
let current = '';
|
|
295
|
+
let inQuotes = false;
|
|
296
|
+
let quoteChar = '';
|
|
297
|
+
let braceDepth = 0;
|
|
298
|
+
let i = 0;
|
|
299
|
+
while (i < propsString.length) {
|
|
300
|
+
const char = propsString[i];
|
|
301
|
+
if (!inQuotes && braceDepth === 0 && (char === '"' || char === "'")) {
|
|
302
|
+
inQuotes = true;
|
|
303
|
+
quoteChar = char;
|
|
304
|
+
current += char;
|
|
305
|
+
}
|
|
306
|
+
else if (inQuotes && char === quoteChar && propsString[i - 1] !== '\\') {
|
|
307
|
+
inQuotes = false;
|
|
308
|
+
quoteChar = '';
|
|
309
|
+
current += char;
|
|
310
|
+
}
|
|
311
|
+
else if (!inQuotes && char === '{') {
|
|
312
|
+
braceDepth++;
|
|
313
|
+
current += char;
|
|
314
|
+
}
|
|
315
|
+
else if (!inQuotes && char === '}') {
|
|
316
|
+
braceDepth--;
|
|
317
|
+
current += char;
|
|
318
|
+
}
|
|
319
|
+
else if (!inQuotes && braceDepth === 0 && char === ' ') {
|
|
320
|
+
if (current.trim()) {
|
|
321
|
+
props.push(current.trim());
|
|
322
|
+
}
|
|
323
|
+
current = '';
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
current += char;
|
|
327
|
+
}
|
|
328
|
+
i++;
|
|
329
|
+
}
|
|
330
|
+
if (current.trim()) {
|
|
331
|
+
props.push(current.trim());
|
|
332
|
+
}
|
|
333
|
+
return props;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Rule 7: Validate MDX and throw errors on invalid content
|
|
337
|
+
*/
|
|
338
|
+
validateMdx(content) {
|
|
339
|
+
// First, remove all code blocks and inline code to avoid false positives
|
|
340
|
+
let cleanedContent = content;
|
|
341
|
+
// Remove fenced code blocks (```...```)
|
|
342
|
+
cleanedContent = cleanedContent.replace(/```[\s\S]*?```/g, '');
|
|
343
|
+
// Remove inline code (`...`)
|
|
344
|
+
cleanedContent = cleanedContent.replace(/`[^`]*`/g, '');
|
|
345
|
+
// Now validate only the cleaned content
|
|
346
|
+
// Simple check for unclosed quotes - but be more careful with complex props
|
|
347
|
+
const lines = cleanedContent.split('\n');
|
|
348
|
+
for (const line of lines) {
|
|
349
|
+
// Check for unclosed quotes in simpler cases
|
|
350
|
+
let inString = false;
|
|
351
|
+
let stringChar = '';
|
|
352
|
+
let braceDepth = 0;
|
|
353
|
+
for (let i = 0; i < line.length; i++) {
|
|
354
|
+
const char = line[i];
|
|
355
|
+
const prevChar = i > 0 ? line[i - 1] : '';
|
|
356
|
+
if (!inString && (char === '"' || char === "'")) {
|
|
357
|
+
inString = true;
|
|
358
|
+
stringChar = char;
|
|
359
|
+
}
|
|
360
|
+
else if (inString && char === stringChar && prevChar !== '\\') {
|
|
361
|
+
inString = false;
|
|
362
|
+
stringChar = '';
|
|
363
|
+
}
|
|
364
|
+
else if (!inString && char === '{') {
|
|
365
|
+
braceDepth++;
|
|
366
|
+
}
|
|
367
|
+
else if (!inString && char === '}') {
|
|
368
|
+
braceDepth--;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Only throw error if we have unmatched quotes outside of braces
|
|
372
|
+
if (inString && braceDepth === 0 && line.includes('<') && line.includes('=')) {
|
|
373
|
+
throw new Error('Invalid MDX: Unclosed attribute quotes');
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Check for unclosed JSX tags, but handle self-closing tags properly
|
|
377
|
+
const openTags = [];
|
|
378
|
+
// Parse JSX tags more carefully to handle complex props
|
|
379
|
+
let i = 0;
|
|
380
|
+
while (i < cleanedContent.length) {
|
|
381
|
+
if (cleanedContent[i] === '<') {
|
|
382
|
+
// Check if it's a closing tag
|
|
383
|
+
if (cleanedContent[i + 1] === '/') {
|
|
384
|
+
// Find the closing tag name
|
|
385
|
+
let j = i + 2;
|
|
386
|
+
while (j < cleanedContent.length && cleanedContent[j].match(/[A-Za-z0-9]/)) {
|
|
387
|
+
j++;
|
|
388
|
+
}
|
|
389
|
+
if (j > i + 2 && cleanedContent[j] === '>') {
|
|
390
|
+
const tagName = cleanedContent.substring(i + 2, j);
|
|
391
|
+
if (tagName[0] === tagName[0].toUpperCase()) {
|
|
392
|
+
const lastOpen = openTags.pop();
|
|
393
|
+
if (lastOpen !== tagName) {
|
|
394
|
+
throw new Error(`Invalid MDX: Mismatched JSX tags. Expected </${lastOpen}> but found </${tagName}>`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
i = j + 1;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// Check if it's an opening tag (starts with capital letter)
|
|
402
|
+
else if (cleanedContent[i + 1] && cleanedContent[i + 1].match(/[A-Z]/)) {
|
|
403
|
+
// Find the tag name
|
|
404
|
+
let j = i + 1;
|
|
405
|
+
while (j < cleanedContent.length && cleanedContent[j].match(/[A-Za-z0-9]/)) {
|
|
406
|
+
j++;
|
|
407
|
+
}
|
|
408
|
+
const tagName = cleanedContent.substring(i + 1, j);
|
|
409
|
+
// Make sure this is actually a JSX tag (next char should be space, /, or >)
|
|
410
|
+
if (j < cleanedContent.length && !cleanedContent[j].match(/[\s\/>]/)) {
|
|
411
|
+
// Not a JSX tag, skip it
|
|
412
|
+
i++;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
// Now skip to the end of the tag, handling props properly
|
|
416
|
+
let inStr = false;
|
|
417
|
+
let strChar = '';
|
|
418
|
+
let brDepth = 0;
|
|
419
|
+
let isSelfClosing = false;
|
|
420
|
+
while (j < cleanedContent.length) {
|
|
421
|
+
const char = cleanedContent[j];
|
|
422
|
+
const prevChar = j > 0 ? cleanedContent[j - 1] : '';
|
|
423
|
+
if (!inStr && (char === '"' || char === "'")) {
|
|
424
|
+
inStr = true;
|
|
425
|
+
strChar = char;
|
|
426
|
+
}
|
|
427
|
+
else if (inStr && char === strChar && prevChar !== '\\') {
|
|
428
|
+
inStr = false;
|
|
429
|
+
}
|
|
430
|
+
else if (!inStr && char === '{') {
|
|
431
|
+
brDepth++;
|
|
432
|
+
}
|
|
433
|
+
else if (!inStr && char === '}') {
|
|
434
|
+
brDepth--;
|
|
435
|
+
}
|
|
436
|
+
else if (!inStr && brDepth === 0 && char === '/' && cleanedContent[j + 1] === '>') {
|
|
437
|
+
isSelfClosing = true;
|
|
438
|
+
j += 2;
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
else if (!inStr && brDepth === 0 && char === '>') {
|
|
442
|
+
j++;
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
j++;
|
|
446
|
+
}
|
|
447
|
+
if (!isSelfClosing) {
|
|
448
|
+
openTags.push(tagName);
|
|
449
|
+
}
|
|
450
|
+
i = j;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
i++;
|
|
455
|
+
}
|
|
456
|
+
if (openTags.length > 0) {
|
|
457
|
+
throw new Error(`Invalid MDX: Unclosed JSX tags: ${openTags.join(', ')}`);
|
|
458
|
+
}
|
|
459
|
+
// Check for invalid nesting (simplified check)
|
|
460
|
+
if (cleanedContent.includes('<p>') && cleanedContent.includes('<div>')) {
|
|
461
|
+
// Check if div is inside p (invalid in HTML/MDX)
|
|
462
|
+
const invalidNesting = /<p[^>]*>[\s\S]*?<div[^>]*>[\s\S]*?<\/div>[\s\S]*?<\/p>/;
|
|
463
|
+
if (invalidNesting.test(cleanedContent)) {
|
|
464
|
+
throw new Error('Invalid MDX: <div> cannot be nested inside <p>');
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
}
|