@dogsbay/markdown-it-mdx-jsx 0.2.0-beta.132 → 0.2.0-beta.134

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.
Files changed (2) hide show
  1. package/dist/index.js +107 -42
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -101,41 +101,69 @@ export function parseJsxAttrs(str) {
101
101
  * Handles multi-line by accepting the full remaining source.
102
102
  */
103
103
  function parseTag(src, pos) {
104
- if (src.charCodeAt(pos) !== 0x3c /* < */)
105
- return null;
106
- let i = pos + 1;
107
- const len = src.length;
108
- // Skip whitespace after <
109
- while (i < len && /\s/.test(src[i]))
110
- i++;
111
- // Check for closing tag: </Tag>
112
- const isClose = src[i] === "/";
113
- if (isClose)
114
- i++;
115
- // Must start with uppercase letter (JSX convention)
116
- if (i >= len || !/[A-Z]/.test(src[i]))
117
- return null;
118
- // Read tag name
119
- const nameStart = i;
120
- while (i < len && /[\w.]/.test(src[i]))
121
- i++;
122
- const tag = src.slice(nameStart, i);
123
- if (isClose) {
124
- // Closing tag: skip whitespace, expect >
104
+ const r = scanTag(src, pos);
105
+ return r && "length" in r ? r : null;
106
+ }
107
+ /**
108
+ * {@link parseTag}, or — when an opening tag's attributes run past the end of `src` — where the
109
+ * scan stopped, to resume on a longer `src` (`resume`). parseBlockTag appends a tag's lines one
110
+ * at a time; re-scanning from the start each time was quadratic: camunda's OpenAPI pages open
111
+ * `<RequestSchema` with 3,700 lines of attributes, 12 s for one page, 114 s for the site.
112
+ */
113
+ function scanTag(src, pos, resume,
114
+ /** Where `src` starts in the whole tag text: a resumed scan is fed one new line at a time. */
115
+ base = 0,
116
+ /** The whole tag text, for the attributes, when `src` is only its last piece. */
117
+ whole,
118
+ /** Scan no further than this index: a tag that has not closed by then is not a tag. */
119
+ maxEnd = Infinity) {
120
+ // Indices are into the whole text; `src` holds [base, len). Each line is scanned on its own and
121
+ // joined once at the end: reading a character of a string built by `+=` flattens the rope,
122
+ // so scanning the growing string was quadratic even when resumed.
123
+ const len = Math.min(base + src.length, maxEnd);
124
+ const at = (k) => src[k - base];
125
+ let i;
126
+ let tag;
127
+ let attrStart;
128
+ let braceDepth = 0;
129
+ let inQuote = null;
130
+ if (resume) {
131
+ ({ tag, attrStart, i, braceDepth, inQuote } = resume);
132
+ }
133
+ else {
134
+ if (src.charCodeAt(pos) !== 0x3c /* < */)
135
+ return null;
136
+ i = pos + 1;
137
+ // Skip whitespace after <
125
138
  while (i < len && /\s/.test(src[i]))
126
139
  i++;
127
- if (i < len && src[i] === ">") {
128
- return { tag, attrs: {}, selfClosing: false, isClose: true, length: i - pos + 1 };
140
+ // Check for closing tag: </Tag>
141
+ const isClose = src[i] === "/";
142
+ if (isClose)
143
+ i++;
144
+ // Must start with uppercase letter (JSX convention)
145
+ if (i >= len || !/[A-Z]/.test(src[i]))
146
+ return null;
147
+ // Read tag name
148
+ const nameStart = i;
149
+ while (i < len && /[\w.]/.test(src[i]))
150
+ i++;
151
+ tag = src.slice(nameStart, i);
152
+ if (isClose) {
153
+ // Closing tag: skip whitespace, expect >
154
+ while (i < len && /\s/.test(src[i]))
155
+ i++;
156
+ if (i < len && src[i] === ">") {
157
+ return { tag, attrs: {}, selfClosing: false, isClose: true, length: i - pos + 1 };
158
+ }
159
+ return null;
129
160
  }
130
- return null;
161
+ // Opening tag — parse attributes until > or />
162
+ // Track brace depth for JSX expression attributes
163
+ attrStart = i;
131
164
  }
132
- // Opening tag — parse attributes until > or />
133
- // Track brace depth for JSX expression attributes
134
- let braceDepth = 0;
135
- let inQuote = null;
136
- const attrStart = i;
137
165
  while (i < len) {
138
- const ch = src[i];
166
+ const ch = at(i);
139
167
  if (inQuote) {
140
168
  if (ch === "\\") {
141
169
  i += 2;
@@ -163,8 +191,8 @@ function parseTag(src, pos) {
163
191
  }
164
192
  // Only check for tag end when not inside braces or quotes
165
193
  if (braceDepth === 0) {
166
- if (ch === "/" && i + 1 < len && src[i + 1] === ">") {
167
- const attrsStr = src.slice(attrStart, i).trim();
194
+ if (ch === "/" && i + 1 < len && at(i + 1) === ">") {
195
+ const attrsStr = (whole ? whole() : src).slice(attrStart, i).trim();
168
196
  return {
169
197
  tag,
170
198
  attrs: parseJsxAttrs(attrsStr),
@@ -174,7 +202,7 @@ function parseTag(src, pos) {
174
202
  };
175
203
  }
176
204
  if (ch === ">") {
177
- const attrsStr = src.slice(attrStart, i).trim();
205
+ const attrsStr = (whole ? whole() : src).slice(attrStart, i).trim();
178
206
  return {
179
207
  tag,
180
208
  attrs: parseJsxAttrs(attrsStr),
@@ -186,7 +214,8 @@ function parseTag(src, pos) {
186
214
  }
187
215
  i++;
188
216
  }
189
- return null; // Never found closing >
217
+ // Never found closing > — where to resume when there is more
218
+ return { tag, attrStart, i, braceDepth, inQuote };
190
219
  }
191
220
  // ── Block rule helpers ─────────────────────────────────────
192
221
  function getLine(state, line) {
@@ -253,6 +282,23 @@ function findCloseTag(state, startLine, endLine, tag) {
253
282
  * Concatenates lines as needed for multi-line tags.
254
283
  * Returns null if no valid tag found.
255
284
  */
285
+ /**
286
+ * How much multi-line tag scanning one document may do, per parse: each line that opens a tag
287
+ * with no `>` scans to the end of the document, so a page of unclosed `<A x={` lines was
288
+ * quadratic (review 2026-10-21: 56 KB, 15 s). Real pages scan a few times their length
289
+ * (camunda's 3,700-line `<RequestSchema>`); past the budget a tag that has not closed is
290
+ * not a tag, and the line is read as text.
291
+ */
292
+ const scanBudgets = new WeakMap();
293
+ function scanBudget(state) {
294
+ const key = (state.env && typeof state.env === "object" ? state.env : state);
295
+ let b = scanBudgets.get(key);
296
+ if (!b || b.src !== state.src) {
297
+ b = { src: state.src, left: 8 * state.src.length + 100_000 };
298
+ scanBudgets.set(key, b);
299
+ }
300
+ return b;
301
+ }
256
302
  function parseBlockTag(state, startLine, endLine, offset = 0) {
257
303
  const linePos = state.bMarks[startLine] + state.tShift[startLine];
258
304
  const lineEnd = state.eMarks[startLine];
@@ -267,18 +313,27 @@ function parseBlockTag(state, startLine, endLine, offset = 0) {
267
313
  const tagStart = remaining.match(/^<\/?([A-Z]\w*)/);
268
314
  if (!tagStart)
269
315
  return null;
270
- // Concatenate lines until we find > or />
271
- let combined = remaining;
316
+ // Add lines until we find > or />, resuming the scan where the last line left it
317
+ const budget = scanBudget(state);
318
+ const parts = [remaining];
319
+ let total = remaining.length;
320
+ const whole = () => parts.join("");
321
+ let scan = scanTag(remaining, 0);
272
322
  let currentLine = startLine + 1;
273
323
  while (currentLine < endLine) {
274
324
  // The whole line, indentation included: a multi-line attribute VALUE
275
325
  // (`<Code code={`…`} />`) is content, and trimming each line flattened
276
326
  // every code sample written that way.
277
- const nextLine = state.src.slice(state.bMarks[currentLine], state.eMarks[currentLine]);
278
- combined += "\n" + nextLine;
279
- const parsed = parseTag(combined, 0);
280
- if (parsed) {
281
- return { ...parsed, endLine: currentLine };
327
+ const piece = "\n" + state.src.slice(state.bMarks[currentLine], state.eMarks[currentLine]);
328
+ budget.left -= piece.length;
329
+ if (budget.left < 0)
330
+ return null;
331
+ parts.push(piece);
332
+ // A closing tag split over lines (`</Tag\n>`) is rare and short: scanned from the start
333
+ scan = scan && !("length" in scan) ? scanTag(piece, 0, scan, total, whole) : scanTag(whole(), 0);
334
+ total += piece.length;
335
+ if (scan && "length" in scan) {
336
+ return { ...scan, endLine: currentLine };
282
337
  }
283
338
  currentLine++;
284
339
  }
@@ -421,6 +476,9 @@ function processFlowTags(state, startLine, endLine, linePos, lineText, component
421
476
  }
422
477
  // ── Inline rule ────────────────────────────────────────────
423
478
  const openInline = new WeakMap();
479
+ /** What one inline run may still scan for tags (see `scanBudgets`): each `<` scanned to the end
480
+ * of the paragraph when no `>` came, quadratic in a paragraph of unclosed `<A x={` lines. */
481
+ const inlineBudgets = new WeakMap();
424
482
  function createJsxInlineRule(options) {
425
483
  const componentSet = options.components ? new Set(options.components) : null;
426
484
  const selfClosingSet = new Set(options.selfClosing || []);
@@ -428,7 +486,14 @@ function createJsxInlineRule(options) {
428
486
  const pos = state.pos;
429
487
  if (state.src.charCodeAt(pos) !== 0x3c /* < */)
430
488
  return false;
431
- const parsed = parseTag(state.src, pos);
489
+ let budget = inlineBudgets.get(state);
490
+ if (!budget)
491
+ inlineBudgets.set(state, (budget = { left: 8 * state.src.length + 100_000 }));
492
+ if (budget.left <= 0)
493
+ return false;
494
+ const scan = scanTag(state.src, pos, undefined, 0, undefined, pos + budget.left);
495
+ budget.left -= scan && "length" in scan ? scan.length : scan ? scan.i - pos : 1;
496
+ const parsed = scan && "length" in scan ? scan : null;
432
497
  if (!parsed)
433
498
  return false;
434
499
  if (componentSet && !componentSet.has(parsed.tag))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dogsbay/markdown-it-mdx-jsx",
3
- "version": "0.2.0-beta.132",
3
+ "version": "0.2.0-beta.134",
4
4
  "description": "markdown-it plugin that tokenizes JSX-style component tags inside Markdown — used by @dogsbay/format-starlight",
5
5
  "type": "module",
6
6
  "exports": {