@effected/markdown 0.6.3 → 0.8.0

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.
@@ -0,0 +1,57 @@
1
+ import { Point, Position } from "../MarkdownNode.js";
2
+ import { preprocessLines } from "./preprocess.js";
3
+ import { LineIndex } from "./lineIndex.js";
4
+ import { parseInlines } from "./inlineParser.js";
5
+ import { trimWithSegments } from "./rawInline.js";
6
+
7
+ //#region src/internal/phrasing.ts
8
+ const EMPTY_REFMAP = /* @__PURE__ */ new Map();
9
+ const EMPTY_FOOTNOTE_LABELS = /* @__PURE__ */ new Set();
10
+ /**
11
+ * Parse a text fragment as a single paragraph's inline content.
12
+ */
13
+ const parsePhrasingText = (text, dialect) => {
14
+ const lines = preprocessLines(text);
15
+ const segments = [];
16
+ let content = "";
17
+ for (const line of lines) {
18
+ segments.push({
19
+ textOffset: content.length,
20
+ sourceOffset: line.start,
21
+ length: line.text.length
22
+ });
23
+ content += `${line.text}\n`;
24
+ }
25
+ const trimmed = trimWithSegments(content, segments, /\s/);
26
+ if (trimmed.text.length === 0) return [];
27
+ const first = trimmed.segments[0];
28
+ const startOffset = first === void 0 ? 0 : first.sourceOffset;
29
+ const lineIndex = LineIndex.fromLineStarts(text, lines.map((line) => line.start));
30
+ const sourceLength = text.length;
31
+ const positionOf = (start, end) => {
32
+ const clampedStart = Math.min(Math.max(start, 0), sourceLength);
33
+ const clampedEnd = Math.min(Math.max(end, clampedStart), sourceLength);
34
+ const startPoint = lineIndex.positionAt(clampedStart);
35
+ const endPoint = lineIndex.positionAt(clampedEnd);
36
+ return Position.make({
37
+ start: Point.make({
38
+ line: startPoint.line,
39
+ column: startPoint.column,
40
+ offset: clampedStart
41
+ }),
42
+ end: Point.make({
43
+ line: endPoint.line,
44
+ column: endPoint.column,
45
+ offset: clampedEnd
46
+ })
47
+ });
48
+ };
49
+ return parseInlines({
50
+ text: trimmed.text,
51
+ startOffset,
52
+ segments: trimmed.segments
53
+ }, EMPTY_REFMAP, positionOf, dialect, EMPTY_FOOTNOTE_LABELS);
54
+ };
55
+
56
+ //#endregion
57
+ export { parsePhrasingText };
@@ -6,6 +6,8 @@ const reCmarkSpace = /[ \t\n\r]/;
6
6
  /**
7
7
  * Trim `text` the way commonmark.js does before inline parsing, carrying the
8
8
  * segment table along so the surviving characters keep their source offsets.
9
+ * Exported for the phrasing-level parse entry point (`phrasing.ts`), which
10
+ * prepares content the same way a paragraph does.
9
11
  */
10
12
  const trimWithSegments = (text, segments, whitespace) => {
11
13
  let start = 0;
@@ -54,4 +56,4 @@ const prepareInline = (block, position, refmap, dialect = "commonmark", footnote
54
56
  };
55
57
 
56
58
  //#endregion
57
- export { prepareInline };
59
+ export { prepareInline, trimWithSegments };
@@ -1,3 +1,4 @@
1
+ import { ENTITY } from "./unescape.js";
1
2
  import { GuardExceeded } from "./carriers.js";
2
3
 
3
4
  //#region src/internal/stringify.ts
@@ -21,15 +22,15 @@ const ALWAYS_ESCAPE = /* @__PURE__ */ new Set([
21
22
  "\\",
22
23
  "`",
23
24
  "*",
24
- "_",
25
25
  "[",
26
26
  "]",
27
27
  "<",
28
- ">",
29
- "&",
30
28
  "~",
31
29
  "|"
32
30
  ]);
31
+ /** A character reference at the head of the string: the engine's own grammar. */
32
+ const reEntityAhead = new RegExp(`^${ENTITY}`, "i");
33
+ const reAlphanumeric = /[\p{L}\p{N}]/u;
33
34
  /** Line-start characters that could open a block construct. */
34
35
  const LINE_START_ESCAPE = /* @__PURE__ */ new Set([
35
36
  "#",
@@ -68,11 +69,37 @@ const isSchemeColon = (value, index) => {
68
69
  while (start > 0 && /[a-zA-Z]/.test(value[start - 1])) start -= 1;
69
70
  return SCHEME_WORDS.has(value.slice(start, index).toLowerCase());
70
71
  };
72
+ /** Whether `value[index]` is a Unicode letter or number (`undefined` is not). */
73
+ const isAlphanumericAt = (value, index) => {
74
+ const char = value[index];
75
+ return char !== void 0 && reAlphanumeric.test(char);
76
+ };
77
+ /**
78
+ * Whether the `#` at `value[index]` heads a run that would read as an ATX
79
+ * closing sequence: preceded by whitespace or the value start, and followed
80
+ * by nothing but whitespace to the end of the contiguous text (this value
81
+ * plus `followingText`). A non-text sibling after that may contribute only
82
+ * blank content, so it keeps the escape (conservative).
83
+ */
84
+ const isClosingHashRun = (value, index, followingText, nonTextFollows) => {
85
+ const before = value[index - 1];
86
+ if (before !== void 0 && before !== " " && before !== " ") return false;
87
+ const rest = value.slice(index) + followingText;
88
+ let end = 0;
89
+ while (rest[end] === "#") end += 1;
90
+ return nonTextFollows || /^[ \t\n]*$/.test(rest.slice(end));
91
+ };
92
+ /** A run that a later sibling could still complete into a character reference. */
93
+ const reEntityPrefix = /^&[#a-z0-9]*$/i;
71
94
  /**
72
95
  * Escape one text value into `out`, tracking line starts. Returns whether the
73
- * emission ended at a line start.
96
+ * emission ended at a line start. `followingText` is the concatenated value
97
+ * of the contiguous text siblings after this one (the parser never splits a
98
+ * text run, but a synthesized tree may), and `nonTextFollows` says whether a
99
+ * non-text inline comes after those; together they decide the value-end
100
+ * cases (`&`, and `#` in a heading) against what will actually be emitted.
74
101
  */
75
- const escapeText = (value, context, atLineStart) => {
102
+ const escapeText = (value, context, atLineStart, mdx, followingText, nonTextFollows) => {
76
103
  let out = "";
77
104
  let lineStart = atLineStart;
78
105
  for (let index = 0; index < value.length; index += 1) {
@@ -125,7 +152,21 @@ const escapeText = (value, context, atLineStart) => {
125
152
  out += `\\${char}`;
126
153
  continue;
127
154
  }
128
- if (context.inHeading && char === "#") {
155
+ if (mdx && char === "{") {
156
+ out += "\\{";
157
+ continue;
158
+ }
159
+ if (char === "_" && !(isAlphanumericAt(value, index - 1) && isAlphanumericAt(value, index + 1))) {
160
+ out += "\\_";
161
+ continue;
162
+ }
163
+ if (char === "&") {
164
+ const ahead = value.slice(index) + followingText;
165
+ const entityShaped = reEntityAhead.test(ahead) || nonTextFollows && reEntityPrefix.test(ahead);
166
+ out += entityShaped ? "\\&" : "&";
167
+ continue;
168
+ }
169
+ if (context.inHeading && char === "#" && isClosingHashRun(value, index, followingText, nonTextFollows)) {
129
170
  out += "\\#";
130
171
  continue;
131
172
  }
@@ -214,7 +255,13 @@ const serializeInlines = (children, context, state, startsLine, junctionGuard) =
214
255
  };
215
256
  switch (child.type) {
216
257
  case "text": {
217
- const escaped = escapeText(child.value, context, atLineStart);
258
+ let followingText = "";
259
+ let next = index + 1;
260
+ while (next < children.length && children[next]?.type === "text") {
261
+ followingText += children[next].value;
262
+ next += 1;
263
+ }
264
+ const escaped = escapeText(child.value, context, atLineStart, state.mdx, followingText, next < children.length);
218
265
  out += escaped.text;
219
266
  atLineStart = escaped.atLineStart;
220
267
  break;
@@ -291,12 +338,126 @@ const serializeInlines = (children, context, state, startsLine, junctionGuard) =
291
338
  case "footnoteReference":
292
339
  out += `[^${escapeLabel(child.label ?? child.identifier)}]`;
293
340
  atLineStart = false;
341
+ break;
342
+ case "mdxJsxTextElement":
343
+ out += serializeMdxJsxText(child, context, state);
344
+ atLineStart = false;
345
+ break;
346
+ case "mdxTextExpression":
347
+ out += mdxExpression(child.value);
348
+ atLineStart = false;
294
349
  }
295
350
  index += 1;
296
351
  unguard(state);
297
352
  }
298
353
  return out;
299
354
  };
355
+ /** The tag string of every MDX node type. */
356
+ const MDX_NODE_TYPES = /* @__PURE__ */ new Set([
357
+ "mdxJsxFlowElement",
358
+ "mdxJsxTextElement",
359
+ "mdxFlowExpression",
360
+ "mdxTextExpression",
361
+ "mdxjsEsm"
362
+ ]);
363
+ /** Whether the tree carries any MDX node — iterative, deliberately unguarded. */
364
+ const treeContainsMdx = (root) => {
365
+ const stack = [root];
366
+ while (stack.length > 0) {
367
+ const node = stack.pop();
368
+ if (MDX_NODE_TYPES.has(node.type)) return true;
369
+ if (node.children !== void 0) for (const child of node.children) stack.push(child);
370
+ }
371
+ return false;
372
+ };
373
+ /**
374
+ * The oracle's `indentLines` line model (mdast-util-to-markdown
375
+ * `lib/util/indent-lines.js`): lines split on every terminator spelling
376
+ * (`\r\n`, `\n`, bare `\r`), terminators preserved verbatim between the
377
+ * mapped lines, and `blank` meaning an empty line — so a blank CRLF line
378
+ * stays bare instead of having a retained `\r` treated as content.
379
+ */
380
+ const mapMdxLines = (value, map) => {
381
+ const eol = /\r?\n|\r/g;
382
+ const result = [];
383
+ let start = 0;
384
+ let index = 0;
385
+ for (let match = eol.exec(value); match !== null; match = eol.exec(value)) {
386
+ const line = value.slice(start, match.index);
387
+ result.push(map(line, index, line === ""), match[0]);
388
+ start = match.index + match[0].length;
389
+ index += 1;
390
+ }
391
+ const last = value.slice(start);
392
+ result.push(map(last, index, last === ""));
393
+ return result.join("");
394
+ };
395
+ /**
396
+ * Serialize an MDX expression body between braces. Continuation lines take
397
+ * the oracle's two-space indent; the first line and blank lines take none.
398
+ */
399
+ const mdxExpression = (value) => `{${mapMdxLines(value, (line, index, blank) => index === 0 || blank ? line : ` ${line}`)}}`;
400
+ /**
401
+ * Serialize one JSX attribute. A `null` or absent value is a boolean
402
+ * attribute; a string value is quoted with `"` (the oracle default), the
403
+ * quote itself escaped as `&#x22;` exactly as `stringify-entities` spells
404
+ * it; an expression value goes between braces.
405
+ */
406
+ const serializeMdxAttribute = (attribute) => {
407
+ if (attribute.type === "mdxJsxExpressionAttribute") return `{${attribute.value}}`;
408
+ const value = attribute.value;
409
+ if (value === void 0 || value === null) return attribute.name;
410
+ if (typeof value === "string") return `${attribute.name}="${value.replaceAll("\"", "&#x22;")}"`;
411
+ return `${attribute.name}={${value.value}}`;
412
+ };
413
+ /** Serialize a text (phrasing) JSX element. */
414
+ const serializeMdxJsxText = (node, context, state) => {
415
+ const attributes = node.attributes.map(serializeMdxAttribute);
416
+ const selfClosing = node.name !== null && node.children.length === 0;
417
+ let out = `<${node.name ?? ""}`;
418
+ if (attributes.length > 0) out += ` ${attributes.join(" ")}`;
419
+ if (selfClosing) return `${out} />`;
420
+ out += ">";
421
+ out += serializeInlines(node.children, context, state, false);
422
+ out += `</${node.name ?? ""}>`;
423
+ return out;
424
+ };
425
+ /** Prefix every line of a rendered child block; blank lines stay bare. */
426
+ const indentBlockLines = (content, indent) => mapMdxLines(content, (line, _index, blank) => blank ? line : `${indent}${line}`);
427
+ /**
428
+ * Serialize a flow (block) JSX element: opening tag (attributes on their own
429
+ * lines when one carries a line ending), children as indented block layout,
430
+ * closing tag — the oracle's `mdxElement` flow branch.
431
+ */
432
+ const serializeMdxJsxFlow = (node, state) => {
433
+ const currentIndent = " ".repeat(state.jsxDepth);
434
+ const attributes = node.attributes.map(serializeMdxAttribute);
435
+ const selfClosing = node.name !== null && node.children.length === 0;
436
+ const attributesOnOneLine = attributes.join(" ");
437
+ const attributesOnTheirOwnLine = /[\r\n]/.test(attributesOnOneLine);
438
+ let value = `${currentIndent}<${node.name ?? ""}`;
439
+ if (attributesOnTheirOwnLine) value += `\n${attributes.map((attribute) => `${currentIndent} ${attribute}`).join("\n")}\n${currentIndent}`;
440
+ else if (attributesOnOneLine !== "") value += ` ${attributesOnOneLine}`;
441
+ if (selfClosing) value += `${attributesOnTheirOwnLine ? "" : " "}/`;
442
+ value += ">";
443
+ if (node.children.length > 0) {
444
+ state.jsxDepth += 1;
445
+ const childIndent = " ".repeat(state.jsxDepth);
446
+ const parts = node.children.map((child) => {
447
+ if (child.type === "mdxJsxFlowElement") {
448
+ guard(state, child);
449
+ const rendered = serializeMdxJsxFlow(child, state);
450
+ unguard(state);
451
+ return rendered;
452
+ }
453
+ return indentBlockLines(serializeBlocks([child], state), childIndent);
454
+ });
455
+ state.jsxDepth -= 1;
456
+ value += `\n${parts.join("\n\n")}\n`;
457
+ }
458
+ if (!selfClosing) value += `${currentIndent}</${node.name ?? ""}>`;
459
+ return value;
460
+ };
300
461
  /** Prefix every line of `content`; blank lines take the trimmed prefix. */
301
462
  const prefixLines = (content, prefix, blankPrefix) => content.split("\n").map((line) => line === "" ? blankPrefix : `${prefix}${line}`).join("\n");
302
463
  /** First line gets `marker`, continuation lines get spaces of its width. */
@@ -325,8 +486,9 @@ const setextUnderline = (heading, content) => {
325
486
  return (heading.depth === 1 ? "=" : "-").repeat(Math.max(1, lastLine.length));
326
487
  };
327
488
  const serializeHeading = (heading, state) => {
328
- const content = serializeInlines(heading.children, HEADING_CONTEXT, state, false);
329
- if (heading.headingStyle === "setext" && heading.depth <= 2 && content !== "") return `${content}\n${setextUnderline(heading, content)}`;
489
+ const setext = heading.headingStyle === "setext" && heading.depth <= 2;
490
+ const content = serializeInlines(heading.children, HEADING_CONTEXT, state, setext);
491
+ if (setext && content !== "") return `${content}\n${setextUnderline(heading, content)}`;
330
492
  const hashes = "#".repeat(heading.depth);
331
493
  return content === "" ? hashes : `${hashes} ${content}`;
332
494
  };
@@ -381,7 +543,10 @@ const effectiveListMarker = (list, flipped) => {
381
543
  };
382
544
  const serializeListItem = (item, marker, state, tight) => {
383
545
  guard(state, item);
546
+ const savedJsxDepth = state.jsxDepth;
547
+ state.jsxDepth = 0;
384
548
  const inner = serializeBlocks(item.children, state, tight);
549
+ state.jsxDepth = savedJsxDepth;
385
550
  unguard(state);
386
551
  const content = `${item.checked === void 0 ? "" : item.checked ? "[x] " : "[ ] "}${inner}`;
387
552
  if (content === "") return marker.trimEnd();
@@ -455,7 +620,10 @@ const serializeBlocks = (children, state, tight = false) => {
455
620
  previousListMarker = void 0;
456
621
  break;
457
622
  case "blockquote": {
623
+ const savedJsxDepth = state.jsxDepth;
624
+ state.jsxDepth = 0;
458
625
  const inner = serializeBlocks(child.children, state);
626
+ state.jsxDepth = savedJsxDepth;
459
627
  parts.push(prefixLines(inner, "> ", ">"));
460
628
  previousListMarker = void 0;
461
629
  break;
@@ -485,6 +653,17 @@ const serializeBlocks = (children, state, tight = false) => {
485
653
  previousListMarker = void 0;
486
654
  break;
487
655
  }
656
+ case "mdxJsxFlowElement":
657
+ parts.push(serializeMdxJsxFlow(child, state));
658
+ previousListMarker = void 0;
659
+ break;
660
+ case "mdxFlowExpression":
661
+ parts.push(mdxExpression(child.value));
662
+ previousListMarker = void 0;
663
+ break;
664
+ case "mdxjsEsm":
665
+ parts.push(child.value);
666
+ previousListMarker = void 0;
488
667
  }
489
668
  previous = child;
490
669
  nodes.push(child);
@@ -510,7 +689,12 @@ const serializeBlocks = (children, state, tight = false) => {
510
689
  * empty for an empty root and ends with exactly one newline otherwise.
511
690
  */
512
691
  const stringifyTree = (root) => {
513
- const body = serializeBlocks(root.children, { depth: 0 });
692
+ const state = {
693
+ depth: 0,
694
+ jsxDepth: 0,
695
+ mdx: treeContainsMdx(root)
696
+ };
697
+ const body = serializeBlocks(root.children, state);
514
698
  return body === "" ? "" : `${body}\n`;
515
699
  };
516
700
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/markdown",
3
- "version": "0.6.3",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "description": "CommonMark and GFM markdown parse, edit and transform schemas for Effect",
6
6
  "keywords": [
@@ -39,9 +39,9 @@
39
39
  "./package.json": "./package.json"
40
40
  },
41
41
  "peerDependencies": {
42
- "@effected/jsonc": "^0.7.0",
42
+ "@effected/jsonc": "^0.8.1",
43
43
  "@effected/toml": "^0.5.0",
44
- "@effected/yaml": "^0.11.0",
44
+ "@effected/yaml": "^0.12.0",
45
45
  "effect": "4.0.0-rc.109"
46
46
  },
47
47
  "peerDependenciesMeta": {