@effected/markdown 0.2.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.
Files changed (65) hide show
  1. package/Frontmatter.js +272 -0
  2. package/FrontmatterResolver.js +273 -0
  3. package/JsonFrontmatter.js +46 -0
  4. package/LICENSE +21 -0
  5. package/Markdown.js +251 -0
  6. package/MarkdownDiagnostic.js +85 -0
  7. package/MarkdownDocument.js +248 -0
  8. package/MarkdownEdit.js +52 -0
  9. package/MarkdownFormat.js +380 -0
  10. package/MarkdownNode.js +641 -0
  11. package/MarkdownVisitor.js +88 -0
  12. package/Mdast.js +384 -0
  13. package/README.md +255 -0
  14. package/TomlFrontmatter.js +44 -0
  15. package/YamlFrontmatter.js +45 -0
  16. package/index.d.ts +2190 -0
  17. package/index.js +15 -0
  18. package/internal/blockParser.js +419 -0
  19. package/internal/blockRegistry.js +90 -0
  20. package/internal/blockTypes.js +27 -0
  21. package/internal/blocks/atxHeading.js +54 -0
  22. package/internal/blocks/blockquote.js +40 -0
  23. package/internal/blocks/code.js +90 -0
  24. package/internal/blocks/document.js +17 -0
  25. package/internal/blocks/fencedCode.js +26 -0
  26. package/internal/blocks/footnoteDefinition.js +84 -0
  27. package/internal/blocks/frontmatter.js +56 -0
  28. package/internal/blocks/htmlBlock.js +72 -0
  29. package/internal/blocks/indentedCode.js +16 -0
  30. package/internal/blocks/linkReferenceDefinition.js +72 -0
  31. package/internal/blocks/list.js +159 -0
  32. package/internal/blocks/paragraph.js +27 -0
  33. package/internal/blocks/setextHeading.js +24 -0
  34. package/internal/blocks/table.js +378 -0
  35. package/internal/blocks/taskListItem.js +36 -0
  36. package/internal/blocks/thematicBreak.js +35 -0
  37. package/internal/carriers.js +42 -0
  38. package/internal/entities.js +26 -0
  39. package/internal/entityMap.js +7 -0
  40. package/internal/htmlTags.js +18 -0
  41. package/internal/inlineNode.js +54 -0
  42. package/internal/inlineParser.js +403 -0
  43. package/internal/inlineRegistry.js +68 -0
  44. package/internal/inlines/autolink.js +36 -0
  45. package/internal/inlines/autolinkLiteral.js +374 -0
  46. package/internal/inlines/codeSpan.js +32 -0
  47. package/internal/inlines/emphasis.js +79 -0
  48. package/internal/inlines/entity.js +21 -0
  49. package/internal/inlines/escape.js +34 -0
  50. package/internal/inlines/footnoteReference.js +63 -0
  51. package/internal/inlines/lineBreak.js +25 -0
  52. package/internal/inlines/link.js +212 -0
  53. package/internal/inlines/rawHtml.js +27 -0
  54. package/internal/inlines/strikethrough.js +81 -0
  55. package/internal/inlines/text.js +22 -0
  56. package/internal/lineIndex.js +76 -0
  57. package/internal/patterns.js +26 -0
  58. package/internal/preprocess.js +58 -0
  59. package/internal/rawInline.js +57 -0
  60. package/internal/references.js +160 -0
  61. package/internal/segments.js +36 -0
  62. package/internal/stringify.js +519 -0
  63. package/internal/unescape.js +18 -0
  64. package/package.json +61 -0
  65. package/tsdoc-metadata.json +11 -0
@@ -0,0 +1,380 @@
1
+ import { MarkdownEdit } from "./MarkdownEdit.js";
2
+ import { BulletChar, EmphasisChar, FenceChar, HeadingStyle, Paragraph, Root, Text, ThematicBreakChar } from "./MarkdownNode.js";
3
+ import { Markdown, MarkdownDialect, MarkdownParseOptions } from "./Markdown.js";
4
+ import { Effect, Result, Schema } from "effect";
5
+
6
+ //#region src/MarkdownFormat.ts
7
+ /**
8
+ * Options controlling formatting: which concrete-syntax markers to normalize,
9
+ * plus the parse knobs (`dialect`, `frontmatter`) the formatter parses the
10
+ * source with (same defaults as `Markdown.parse`). Every marker option is
11
+ * optional and independent; an absent option normalizes nothing.
12
+ *
13
+ * Day-one scope is marker normalization only — heading style, bullet
14
+ * character, emphasis/strong marker, fence character, thematic-break
15
+ * character. Content is never rewritten, rewrapped or reflowed.
16
+ *
17
+ * @public
18
+ */
19
+ var MarkdownFormattingOptions = class extends Schema.Class("MarkdownFormattingOptions")({
20
+ dialect: Schema.optionalKey(MarkdownDialect),
21
+ frontmatter: Schema.optionalKey(Schema.Boolean),
22
+ headingStyle: Schema.optionalKey(HeadingStyle),
23
+ bulletChar: Schema.optionalKey(BulletChar),
24
+ emphasisChar: Schema.optionalKey(EmphasisChar),
25
+ fenceChar: Schema.optionalKey(FenceChar),
26
+ thematicBreakChar: Schema.optionalKey(ThematicBreakChar)
27
+ }) {};
28
+ /**
29
+ * Error codes `MarkdownFormat.modify` can fail with.
30
+ *
31
+ * @public
32
+ */
33
+ const MarkdownModificationErrorCode = Schema.Literals([
34
+ "NodeNotInDocument",
35
+ "UnsupportedTarget",
36
+ "FragmentCategoryMismatch",
37
+ "FragmentUnrenderable"
38
+ ]);
39
+ /**
40
+ * Raised when `MarkdownFormat.modify` cannot perform the requested
41
+ * replacement: the target node is not in the document (`NodeNotInDocument`),
42
+ * the target kind or splice context is outside the day-one scope
43
+ * (`UnsupportedTarget`), the fragment's content category does not fit the
44
+ * target's slot (`FragmentCategoryMismatch`), or the fragment trips the
45
+ * stringifier's hardening guard (`FragmentUnrenderable`). Carries the typed
46
+ * `code` plus the target's `offset`/`length` where known — never a collapsed
47
+ * reason string alone.
48
+ *
49
+ * @public
50
+ */
51
+ var MarkdownModificationError = class extends Schema.TaggedErrorClass()("MarkdownModificationError", {
52
+ code: MarkdownModificationErrorCode,
53
+ detail: Schema.String,
54
+ offset: Schema.Number,
55
+ length: Schema.Number
56
+ }) {
57
+ get message() {
58
+ return `Markdown modification failed: ${this.code} ${this.detail}`;
59
+ }
60
+ };
61
+ const childrenOf = (node) => node.children ?? [];
62
+ /** Walk the tree, invoking `visit` with each node, its parent and its siblings. */
63
+ const walk = (node, parent, siblings, index, visit) => {
64
+ visit(node, parent, siblings, index);
65
+ const children = childrenOf(node);
66
+ for (let i = 0; i < children.length; i++) walk(children[i], node, children, i, visit);
67
+ };
68
+ const spanOf = (node) => ({
69
+ start: node.position.start.offset,
70
+ end: node.position.end.offset
71
+ });
72
+ const ALPHANUMERIC = /[\p{L}\p{N}]/u;
73
+ /** The start offset of the line containing `offset`. */
74
+ const lineStartOf = (source, offset) => {
75
+ let i = offset;
76
+ while (i > 0 && source.charCodeAt(i - 1) !== 10) i--;
77
+ return i;
78
+ };
79
+ /** True when the line before the one containing `offset` is blank or absent. */
80
+ const previousLineBlank = (source, offset) => {
81
+ const lineStart = lineStartOf(source, offset);
82
+ if (lineStart === 0) return true;
83
+ let i = lineStart - 1;
84
+ if (i > 0 && source.charCodeAt(i - 1) === 13) i--;
85
+ const prevStart = lineStartOf(source, i);
86
+ for (let j = prevStart; j < i; j++) {
87
+ const code = source.charCodeAt(j);
88
+ if (code !== 32 && code !== 9) return false;
89
+ }
90
+ return true;
91
+ };
92
+ /** Content that cannot stand as a setext heading's text line. */
93
+ const SETEXT_CONTENT_HAZARD = /^(?:[-+*>#=]|\d{1,9}[.)](?:\s|$)|`{3}|~{3}|\s)/;
94
+ /** The longest run of `char` anywhere in `value`. */
95
+ const longestRun = (value, char) => {
96
+ let longest = 0;
97
+ let current = 0;
98
+ for (const ch of value) {
99
+ current = ch === char ? current + 1 : 0;
100
+ if (current > longest) longest = current;
101
+ }
102
+ return longest;
103
+ };
104
+ const isUnorderedList = (node) => node.type === "list" && node.ordered !== true;
105
+ /** True when only blank text separates the two spans. */
106
+ const adjacentSpans = (source, endOfFirst, startOfSecond) => {
107
+ for (let i = endOfFirst; i < startOfSecond; i++) {
108
+ const code = source.charCodeAt(i);
109
+ if (code !== 32 && code !== 9 && code !== 10 && code !== 13) return false;
110
+ }
111
+ return true;
112
+ };
113
+ /** Accumulates format edits; drops no-op splices to keep format idempotent. */
114
+ var FormatEmitter = class {
115
+ source;
116
+ edits = [];
117
+ constructor(source) {
118
+ this.source = source;
119
+ }
120
+ push(node, offset, length, content) {
121
+ if (this.source.slice(offset, offset + length) !== content) {
122
+ const { start, end } = spanOf(node);
123
+ this.edits.push({
124
+ offset,
125
+ length,
126
+ content,
127
+ nodeStart: start,
128
+ nodeEnd: end
129
+ });
130
+ }
131
+ }
132
+ };
133
+ const formatThematicBreak = (source, emit, node, target) => {
134
+ if ((node.markerChar ?? "*") === target) return;
135
+ const { start, end } = spanOf(node);
136
+ if (target === "-" && !previousLineBlank(source, start)) return;
137
+ emit.push(node, start, end - start, target.repeat(3));
138
+ };
139
+ const formatHeading = (source, emit, node, target) => {
140
+ if ((node.headingStyle ?? "atx") === target || node.children.length === 0) return;
141
+ const { start, end } = spanOf(node);
142
+ const contentStart = node.children[0].position.start.offset;
143
+ const contentEnd = node.children[node.children.length - 1].position.end.offset;
144
+ const content = source.slice(contentStart, contentEnd);
145
+ if (content.includes("\n") || content.includes("\r")) return;
146
+ if (target === "atx") {
147
+ emit.push(node, start, end - start, `${"#".repeat(node.depth)} ${content}`);
148
+ return;
149
+ }
150
+ if (node.depth > 2 || content.length === 0 || SETEXT_CONTENT_HAZARD.test(content)) return;
151
+ if (start !== 0 && source.charCodeAt(start - 1) !== 10) return;
152
+ const underline = (node.depth === 1 ? "=" : "-").repeat(Math.max(3, content.length));
153
+ emit.push(node, start, end - start, `${content}\n${underline}`);
154
+ };
155
+ const formatList = (source, emit, node, siblings, index, target) => {
156
+ if (!isUnorderedList(node) || (node.bulletChar ?? "-") === target) return;
157
+ const previous = index > 0 ? siblings[index - 1] : void 0;
158
+ const next = index + 1 < siblings.length ? siblings[index + 1] : void 0;
159
+ const { start, end } = spanOf(node);
160
+ if (previous !== void 0 && isUnorderedList(previous) && adjacentSpans(source, spanOf(previous).end, start)) return;
161
+ if (next !== void 0 && isUnorderedList(next) && adjacentSpans(source, end, spanOf(next).start)) return;
162
+ for (const item of node.children) emit.push(node, item.position.start.offset, 1, target);
163
+ };
164
+ const isEmphasisLike = (node) => node.type === "emphasis" || node.type === "strong";
165
+ const formatEmphasis = (source, emit, node, siblings, index, target) => {
166
+ if ((node.markerChar ?? "*") === target) return;
167
+ const markerLength = node.type === "strong" ? 2 : 1;
168
+ const { start, end } = spanOf(node);
169
+ if (target === "_" && (start > 0 && ALPHANUMERIC.test(source[start - 1]) || end < source.length && ALPHANUMERIC.test(source[end]))) return;
170
+ for (const child of childrenOf(node)) {
171
+ if (!isEmphasisLike(child)) continue;
172
+ const childSpan = spanOf(child);
173
+ if (childSpan.start === start + markerLength || childSpan.end === end - markerLength) return;
174
+ }
175
+ const previous = index > 0 ? siblings[index - 1] : void 0;
176
+ const next = index + 1 < siblings.length ? siblings[index + 1] : void 0;
177
+ if (previous !== void 0 && isEmphasisLike(previous) && spanOf(previous).end === start) return;
178
+ if (next !== void 0 && isEmphasisLike(next) && spanOf(next).start === end) return;
179
+ emit.push(node, start, markerLength, target.repeat(markerLength));
180
+ emit.push(node, end - markerLength, markerLength, target.repeat(markerLength));
181
+ };
182
+ const formatFence = (source, emit, node, target) => {
183
+ const fidelity = node.fenceChar;
184
+ if (fidelity === void 0 || fidelity === target) return;
185
+ if (target === "`" && `${node.lang ?? ""}${node.meta ?? ""}`.includes("`")) return;
186
+ const { start, end } = spanOf(node);
187
+ let openLength = 0;
188
+ while (start + openLength < end && source[start + openLength] === fidelity) openLength++;
189
+ if (openLength === 0) return;
190
+ const newLength = Math.max(3, openLength, longestRun(node.value, target) + 1);
191
+ emit.push(node, start, openLength, target.repeat(newLength));
192
+ let closeLength = 0;
193
+ while (end - closeLength > start + openLength && source[end - closeLength - 1] === fidelity) closeLength++;
194
+ if (closeLength >= 3) emit.push(node, end - closeLength, closeLength, target.repeat(newLength));
195
+ };
196
+ const FLOW_TYPES = /* @__PURE__ */ new Set([
197
+ "blockquote",
198
+ "code",
199
+ "definition",
200
+ "footnoteDefinition",
201
+ "heading",
202
+ "html",
203
+ "list",
204
+ "paragraph",
205
+ "table",
206
+ "thematicBreak"
207
+ ]);
208
+ const PHRASING_TYPES = /* @__PURE__ */ new Set([
209
+ "break",
210
+ "delete",
211
+ "emphasis",
212
+ "footnoteReference",
213
+ "html",
214
+ "image",
215
+ "imageReference",
216
+ "inlineCode",
217
+ "link",
218
+ "linkReference",
219
+ "strong",
220
+ "text"
221
+ ]);
222
+ /** Parent types whose child slot holds flow content. */
223
+ const FLOW_PARENTS = /* @__PURE__ */ new Set([
224
+ "root",
225
+ "blockquote",
226
+ "listItem",
227
+ "footnoteDefinition"
228
+ ]);
229
+ /** Parent types whose child slot holds phrasing content. */
230
+ const PHRASING_PARENTS = /* @__PURE__ */ new Set([
231
+ "paragraph",
232
+ "heading",
233
+ "emphasis",
234
+ "strong",
235
+ "delete",
236
+ "link",
237
+ "linkReference",
238
+ "tableCell"
239
+ ]);
240
+ /** Ancestor types whose continuation lines carry a prefix (or forbid newlines outright). */
241
+ const NO_MULTILINE_ANCESTORS = /* @__PURE__ */ new Set([
242
+ "blockquote",
243
+ "list",
244
+ "listItem",
245
+ "footnoteDefinition",
246
+ "table",
247
+ "tableRow",
248
+ "tableCell",
249
+ "heading"
250
+ ]);
251
+ /** Find `target` by identity; returns its ancestor chain (nearest first) or undefined. */
252
+ const findAncestry = (root, target) => {
253
+ const search = (node, trail) => {
254
+ if (node === target) return trail;
255
+ for (const child of childrenOf(node)) {
256
+ const found = search(child, [node, ...trail]);
257
+ if (found !== void 0) return found;
258
+ }
259
+ };
260
+ return search(root, []);
261
+ };
262
+ /**
263
+ * Formatting and modification statics. Not instantiable.
264
+ *
265
+ * @remarks
266
+ * `format`/`formatToString` are pure and total: input that trips a parse
267
+ * hardening guard yields no edits rather than corrupting the document, and
268
+ * every emitted edit is guarded against the re-parse hazards listed in the
269
+ * module documentation — a hazardous conversion is skipped, never attempted.
270
+ * `modify`/`modifyToString` carry a real error channel
271
+ * ({@link MarkdownModificationError}) and render every replacement through
272
+ * the canonical stringifier, so a modified document re-parses cleanly by
273
+ * construction.
274
+ *
275
+ * @public
276
+ */
277
+ var MarkdownFormat = class MarkdownFormat {
278
+ constructor() {}
279
+ /**
280
+ * Compute marker-normalization edits per the requested
281
+ * {@link MarkdownFormattingOptions}: heading style, bullet character,
282
+ * emphasis/strong marker, fence character and thematic-break character,
283
+ * each only where the node's concrete syntax differs from the target and
284
+ * the rewrite is provably safe. Content is never touched. `range`
285
+ * restricts edits to the nodes intersecting it (the owning-node
286
+ * intersection posture, matching toml). Non-mutating — apply with
287
+ * `MarkdownEdit.applyAll` (or use {@link MarkdownFormat.formatToString}).
288
+ */
289
+ static format(text, range, options) {
290
+ const parsed = Markdown.parseResult(text, MarkdownParseOptions.make({
291
+ ...options?.dialect !== void 0 ? { dialect: options.dialect } : {},
292
+ ...options?.frontmatter !== void 0 ? { frontmatter: options.frontmatter } : {}
293
+ }));
294
+ if (Result.isFailure(parsed)) return [];
295
+ const emit = new FormatEmitter(text);
296
+ walk(parsed.success, void 0, [], 0, (node, _parent, siblings, index) => {
297
+ if (options?.thematicBreakChar !== void 0 && node.type === "thematicBreak") formatThematicBreak(text, emit, node, options.thematicBreakChar);
298
+ if (options?.headingStyle !== void 0 && node.type === "heading") formatHeading(text, emit, node, options.headingStyle);
299
+ if (options?.bulletChar !== void 0 && node.type === "list") formatList(text, emit, node, siblings, index, options.bulletChar);
300
+ if (options?.emphasisChar !== void 0 && isEmphasisLike(node)) formatEmphasis(text, emit, node, siblings, index, options.emphasisChar);
301
+ if (options?.fenceChar !== void 0 && node.type === "code") formatFence(text, emit, node, options.fenceChar);
302
+ });
303
+ return (range === void 0 ? emit.edits : emit.edits.filter((edit) => Math.max(edit.nodeStart, range.offset) <= Math.min(edit.nodeEnd, range.offset + range.length))).map((edit) => MarkdownEdit.make({
304
+ offset: edit.offset,
305
+ length: edit.length,
306
+ content: edit.content
307
+ }));
308
+ }
309
+ /**
310
+ * Format `text` and apply the resulting edits in one step
311
+ * (`MarkdownEdit.applyAll ∘ format`). Pure and total.
312
+ */
313
+ static formatToString(text, range, options) {
314
+ return MarkdownEdit.applyAll(text, MarkdownFormat.format(text, range, options));
315
+ }
316
+ /**
317
+ * Compute the edit that replaces `target` — a node from `document`'s own
318
+ * tree, matched by identity — with `replacement`: a plain string (treated
319
+ * as literal text and escaped; block-wrapped when the target is a flow
320
+ * node) or a node fragment whose content category must fit the target's
321
+ * slot (flow for flow targets, phrasing for phrasing targets and table
322
+ * cells). Every replacement renders through the canonical stringifier, so
323
+ * the modified document re-parses cleanly by construction. Day-one scope:
324
+ * list items, table rows, frontmatter and the root refuse with
325
+ * `UnsupportedTarget`, as does a multi-line replacement whose target sits
326
+ * inside a container (a blockquote, list, table or heading) whose
327
+ * continuation lines the splice cannot prefix.
328
+ */
329
+ static modify = Effect.fn("MarkdownFormat.modify")(function* (document, target, replacement) {
330
+ const { start, end } = spanOf(target);
331
+ const fail = (code, detail) => new MarkdownModificationError({
332
+ code,
333
+ detail,
334
+ offset: start,
335
+ length: end - start
336
+ });
337
+ const ancestry = findAncestry(document.root, target);
338
+ if (ancestry === void 0) return yield* fail("NodeNotInDocument", "the target node is not part of the document's tree");
339
+ const parent = ancestry[0];
340
+ if (parent === void 0 || target.type === "frontmatter") return yield* fail("UnsupportedTarget", `a ${target.type} node cannot be replaced`);
341
+ let slot;
342
+ if (target.type === "tableCell" && parent.type === "tableRow") slot = "cell";
343
+ else if (FLOW_PARENTS.has(parent.type)) slot = "flow";
344
+ else if (PHRASING_PARENTS.has(parent.type)) slot = "phrasing";
345
+ else return yield* fail("UnsupportedTarget", `a ${target.type} node inside a ${parent.type} cannot be replaced`);
346
+ let renderRoot;
347
+ if (typeof replacement === "string") {
348
+ const textNode = Text.make({ value: replacement });
349
+ const paragraph = Paragraph.make({ children: [textNode] });
350
+ renderRoot = Root.make({ children: [paragraph] });
351
+ } else if (slot === "flow") {
352
+ if (!FLOW_TYPES.has(replacement.type)) return yield* fail("FragmentCategoryMismatch", `a ${replacement.type} fragment does not fit a flow slot — pass flow content or a plain string`);
353
+ renderRoot = Root.make({ children: [replacement] });
354
+ } else {
355
+ if (!PHRASING_TYPES.has(replacement.type)) return yield* fail("FragmentCategoryMismatch", `a ${replacement.type} fragment does not fit a phrasing slot — pass phrasing content or a plain string`);
356
+ const paragraph = Paragraph.make({ children: [replacement] });
357
+ renderRoot = Root.make({ children: [paragraph] });
358
+ }
359
+ const rendered = Markdown.stringifyResult(renderRoot);
360
+ if (Result.isFailure(rendered)) return yield* fail("FragmentUnrenderable", rendered.failure.message);
361
+ const content = rendered.success.replace(/\n+$/, "");
362
+ if (content.includes("\n") && ancestry.some((ancestor) => NO_MULTILINE_ANCESTORS.has(ancestor.type))) return yield* fail("UnsupportedTarget", "a multi-line replacement cannot be spliced inside a container whose continuation lines carry a prefix");
363
+ return [MarkdownEdit.make({
364
+ offset: start,
365
+ length: end - start,
366
+ content
367
+ })];
368
+ });
369
+ /**
370
+ * Modify `document` and apply the resulting edit in one step
371
+ * (`MarkdownEdit.applyAll ∘ modify`).
372
+ */
373
+ static modifyToString = Effect.fn("MarkdownFormat.modifyToString")(function* (document, target, replacement) {
374
+ const edits = yield* MarkdownFormat.modify(document, target, replacement);
375
+ return MarkdownEdit.applyAll(document.source, edits);
376
+ });
377
+ };
378
+
379
+ //#endregion
380
+ export { MarkdownFormat, MarkdownFormattingOptions, MarkdownModificationError, MarkdownModificationErrorCode };