@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
package/Markdown.js ADDED
@@ -0,0 +1,251 @@
1
+ import { Root } from "./MarkdownNode.js";
2
+ import { isGuardExceeded, isRawMarkdownError } from "./internal/carriers.js";
3
+ import { parseBlocks } from "./internal/blockParser.js";
4
+ import { stringifyTree } from "./internal/stringify.js";
5
+ import { MarkdownDiagnostic } from "./MarkdownDiagnostic.js";
6
+ import { Effect, Option, Result, Schema, SchemaIssue, SchemaTransformation } from "effect";
7
+
8
+ //#region src/Markdown.ts
9
+ /**
10
+ * The markdown dialects the parser can be pointed at. `"gfm"` — CommonMark
11
+ * 0.31.2 plus the GitHub extensions (tables, strikethrough, autolink
12
+ * literals, task-list items, footnotes, and the tagfilter's output contract)
13
+ * — is the default; `"commonmark"` opts out of every extension. A dialect is
14
+ * a registry composition in the engine, so widening this union is additive
15
+ * and never changes an existing dialect's behavior.
16
+ *
17
+ * @public
18
+ */
19
+ const MarkdownDialect = Schema.Literals(["commonmark", "gfm"]);
20
+ /**
21
+ * Options controlling parse behavior: `dialect` (omitted, `"gfm"`) and
22
+ * `frontmatter` (omitted, `false` — capture is opt-in).
23
+ *
24
+ * @remarks
25
+ * Frontmatter capture is OFF by default because it changes how a document
26
+ * opening with `---` parses: CommonMark reads the fence document
27
+ * `---\nfoo: bar\n---` as a thematic break and a setext heading, and that
28
+ * spec-conformant reading must hold unless a consumer opts in. Enabled, the
29
+ * engine captures `---`/`+++`/`---json` blocks at offset 0 into a raw
30
+ * `Frontmatter` head node — decoding it is the frontmatter codec modules'
31
+ * job.
32
+ *
33
+ * @public
34
+ */
35
+ var MarkdownParseOptions = class extends Schema.Class("MarkdownParseOptions")({
36
+ dialect: Schema.optionalKey(MarkdownDialect),
37
+ frontmatter: Schema.optionalKey(Schema.Boolean)
38
+ }) {};
39
+ /**
40
+ * Parse failure: the {@link MarkdownDiagnostic} describing why the document
41
+ * was rejected.
42
+ *
43
+ * @remarks
44
+ * CommonMark has no syntax errors — every string is a valid document — so
45
+ * this error carries only hardening-guard trips (P1: `NestingDepthExceeded`).
46
+ * Recoverable oddities are diagnostics on {@link MarkdownDocument}, not
47
+ * failures. A malformed-looking document parses; a nesting bomb fails here
48
+ * rather than crashing with a `RangeError`.
49
+ *
50
+ * @public
51
+ */
52
+ var MarkdownParseError = class extends Schema.TaggedErrorClass()("MarkdownParseError", { diagnostic: MarkdownDiagnostic }) {
53
+ get message() {
54
+ const { code, line, character, message } = this.diagnostic;
55
+ return `Markdown parse failed: ${code} at ${line}:${character} ${message}`;
56
+ }
57
+ };
58
+ /**
59
+ * Stringify failure: the {@link MarkdownDiagnostic} describing why the tree
60
+ * was refused.
61
+ *
62
+ * @remarks
63
+ * Serialization is total over parser-produced trees — the parser cannot
64
+ * produce one that nests past the guard, because parsing it would have been
65
+ * refused first. The only failures here are hardening-guard trips on
66
+ * synthesized or decoded hostile trees, symmetric with
67
+ * {@link MarkdownParseError}'s posture on parse. Stringify has no source
68
+ * text to derive positions from, so the diagnostic's `line`/`character` are
69
+ * `0` and `offset` carries whatever the offending node's position claimed.
70
+ *
71
+ * @public
72
+ */
73
+ var MarkdownStringifyError = class extends Schema.TaggedErrorClass()("MarkdownStringifyError", { diagnostic: MarkdownDiagnostic }) {
74
+ get message() {
75
+ const { code, message } = this.diagnostic;
76
+ return `Markdown stringify failed: ${code} ${message}`;
77
+ }
78
+ };
79
+ /**
80
+ * The dialect an options object resolves to. The PUBLIC default — `"gfm"`,
81
+ * a product decision — is spelled exactly once, here. The engine's own
82
+ * `parseBlocks` default is `"commonmark"` and means something different: the
83
+ * base dialect the registries compose on top of. The facade always passes
84
+ * this resolved value explicitly, so the two defaults never interact.
85
+ */
86
+ const dialectOf = (options) => options?.dialect ?? "gfm";
87
+ /**
88
+ * Whether an options object opts into frontmatter capture. The default —
89
+ * `false`, a P3 ruling — is spelled exactly once, here: capture changes how
90
+ * a `---` at offset 0 parses, so the spec-conformant reading is the default
91
+ * and frontmatter is the consumer's explicit choice.
92
+ */
93
+ const frontmatterOf = (options) => options?.frontmatter ?? false;
94
+ /**
95
+ * Run the block pass, converting the engine's raw carriers into a typed
96
+ * {@link MarkdownParseError} and letting everything else through as a defect.
97
+ *
98
+ * Shared by {@link Markdown.parseResult} and `MarkdownDocument.parseResult`
99
+ * so the two entry points can never disagree about what is typed and what is
100
+ * a defect.
101
+ *
102
+ * @internal
103
+ */
104
+ const parsePassResult = (text, options) => {
105
+ try {
106
+ return Result.succeed(parseBlocks(text, dialectOf(options), frontmatterOf(options)));
107
+ } catch (caught) {
108
+ if (isGuardExceeded(caught)) return Result.fail(new MarkdownParseError({ diagnostic: MarkdownDiagnostic.fromRaw(text, {
109
+ code: caught.reason,
110
+ message: caught.message,
111
+ offset: caught.offset,
112
+ length: 0
113
+ }) }));
114
+ if (isRawMarkdownError(caught)) return Result.fail(new MarkdownParseError({ diagnostic: MarkdownDiagnostic.fromRaw(text, caught.diagnostic) }));
115
+ throw caught;
116
+ }
117
+ };
118
+ /**
119
+ * The markdown facade: parse markdown source — GFM by default, CommonMark by
120
+ * option — into an mdast-shaped {@link Root} tree, as a pure `Result` or as
121
+ * an `Effect`.
122
+ *
123
+ * @public
124
+ */
125
+ var Markdown = class Markdown {
126
+ /**
127
+ * Parse markdown into a {@link Root} tree, synchronously, as a `Result`.
128
+ * The pure primitive: a non-Effect caller (a build script, a Vite plugin,
129
+ * a language-server tick) can call this directly instead of wrapping
130
+ * `Effect.runSync(Effect.result(Markdown.parse(text)))`.
131
+ *
132
+ * @remarks
133
+ * {@link Markdown.parse} is defined in terms of this function; the two
134
+ * never diverge. Reach for the `Effect` variant inside Effect code — it
135
+ * carries the `Markdown.parse` tracing span — and for this one at
136
+ * synchronous boundaries. This function carries no span: it is not an
137
+ * `Effect`.
138
+ *
139
+ * Failure is rare by design: every string is a valid markdown document in
140
+ * both dialects, so the only failures are hardening-guard trips such as
141
+ * nesting past the 256-container cap. Programmer errors are not converted
142
+ * — they propagate as thrown defects.
143
+ *
144
+ * @example
145
+ * ```ts
146
+ * import { Markdown } from "@effected/markdown";
147
+ * import { Result } from "effect";
148
+ *
149
+ * const ok = Markdown.parseResult("# Title\n\nBody *text*.\n");
150
+ * if (Result.isSuccess(ok)) {
151
+ * console.log(ok.success.children.length); // => 2
152
+ * }
153
+ * ```
154
+ *
155
+ * @param text - The markdown source to parse.
156
+ * @param options - Optional {@link MarkdownParseOptions}; the dialect
157
+ * defaults to `"gfm"`.
158
+ * @returns A `Result` succeeding with the document {@link Root}, or
159
+ * failing with {@link MarkdownParseError}.
160
+ */
161
+ static parseResult(text, options) {
162
+ return Result.map(parsePassResult(text, options), (pass) => pass.root);
163
+ }
164
+ /**
165
+ * Parse markdown into a {@link Root} tree. Defined in terms of
166
+ * {@link Markdown.parseResult} — synchronous callers can use that variant
167
+ * directly.
168
+ *
169
+ * @param text - The markdown source to parse.
170
+ * @param options - Optional {@link MarkdownParseOptions}; the dialect
171
+ * defaults to `"gfm"`.
172
+ * @returns An `Effect` that succeeds with the document {@link Root}, or
173
+ * fails with {@link MarkdownParseError}.
174
+ */
175
+ static parse = Effect.fn("Markdown.parse")((text, options) => Effect.fromResult(Markdown.parseResult(text, options)));
176
+ /**
177
+ * Serialize a {@link Root} tree to canonical markdown, synchronously, as a
178
+ * `Result`. The pure primitive twin of {@link Markdown.stringify}, on the
179
+ * same terms as {@link Markdown.parseResult}.
180
+ *
181
+ * @remarks
182
+ * Canonical serialization: fidelity fields (marker characters, fence
183
+ * style, heading spelling) win when present; documented canonical
184
+ * defaults apply when absent. The output re-parses to a render-equivalent
185
+ * document — the corpus-pinned contract — but is not a byte-level
186
+ * round-trip of any original source; surgical editing goes through the
187
+ * offset-splice edit layer instead.
188
+ *
189
+ * Failure is rare by design, symmetric with parse: only a hardening-guard
190
+ * trip on a tree nesting past the depth cap fails, and only synthesized
191
+ * or decoded trees can nest that far.
192
+ *
193
+ * @param root - The document tree to serialize.
194
+ * @returns A `Result` succeeding with markdown source, or failing with
195
+ * {@link MarkdownStringifyError}.
196
+ */
197
+ static stringifyResult(root) {
198
+ try {
199
+ return Result.succeed(stringifyTree(root));
200
+ } catch (caught) {
201
+ if (isGuardExceeded(caught)) return Result.fail(new MarkdownStringifyError({ diagnostic: MarkdownDiagnostic.make({
202
+ code: caught.reason,
203
+ message: caught.message,
204
+ offset: caught.offset,
205
+ length: 0,
206
+ line: 0,
207
+ character: 0
208
+ }) }));
209
+ throw caught;
210
+ }
211
+ }
212
+ /**
213
+ * Serialize a {@link Root} tree to canonical markdown. Defined in terms of
214
+ * {@link Markdown.stringifyResult} — synchronous callers can use that
215
+ * variant directly.
216
+ *
217
+ * @param root - The document tree to serialize.
218
+ * @returns An `Effect` that succeeds with markdown source, or fails with
219
+ * {@link MarkdownStringifyError}.
220
+ */
221
+ static stringify = Effect.fn("Markdown.stringify")((root) => Effect.fromResult(Markdown.stringifyResult(root)));
222
+ /**
223
+ * A `Schema<Root, string>` decoding markdown source into a {@link Root}
224
+ * tree and encoding a tree back to canonical markdown via
225
+ * {@link Markdown.stringifyResult}.
226
+ *
227
+ * @remarks
228
+ * Schema-producing: each call returns a fresh schema whose derivation
229
+ * caches are not shared across calls. Bind the result to a `const` on hot
230
+ * paths; the pre-bound {@link Markdown.MarkdownFromString} covers the
231
+ * common case.
232
+ *
233
+ * @param options - Optional {@link MarkdownParseOptions} applied on
234
+ * decode.
235
+ * @returns A `Schema.Codec<Root, string>`.
236
+ */
237
+ static fromString(options) {
238
+ return Schema.String.pipe(Schema.decodeTo(Root, SchemaTransformation.transformOrFail({
239
+ decode: (input) => Effect.mapError(Markdown.parse(input, options), (error) => new SchemaIssue.InvalidValue(Option.some(input), { message: error.message })),
240
+ encode: (value) => Effect.mapError(Markdown.stringify(value), (error) => new SchemaIssue.InvalidValue(Option.some(value), { message: error.message }))
241
+ })));
242
+ }
243
+ /**
244
+ * The zero-config `Schema<Root, string>` — `Markdown.fromString()`
245
+ * pre-bound so the common case needs no memoization discipline.
246
+ */
247
+ static MarkdownFromString = Markdown.fromString();
248
+ };
249
+
250
+ //#endregion
251
+ export { Markdown, MarkdownDialect, MarkdownParseError, MarkdownParseOptions, MarkdownStringifyError, parsePassResult };
@@ -0,0 +1,85 @@
1
+ import { MARKDOWN_PARSE_ERROR_CODES } from "./internal/carriers.js";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/MarkdownDiagnostic.ts
5
+ /**
6
+ * Error codes `Markdown.parse`/`MarkdownDocument.parse` can fail with. P1
7
+ * registers exactly the hardening-guard trip; later phases widen the union
8
+ * as new fatal (as opposed to diagnostic-only) conditions are identified.
9
+ *
10
+ * @public
11
+ */
12
+ const MarkdownParseErrorCode = Schema.Literals(MARKDOWN_PARSE_ERROR_CODES);
13
+ /**
14
+ * One structured diagnostic: its {@link (MarkdownParseErrorCode:type)}, a
15
+ * human-readable `message`, and its exact position (`offset`/`length`, plus
16
+ * zero-based `line`/`character`).
17
+ *
18
+ * @remarks
19
+ * The five-field positional core (`code`/`offset`/`length`/`line`/`character`)
20
+ * is structurally identical to `@effected/toml`'s `TomlDiagnostic` (and, by
21
+ * the same cross-package contract, `@effected/jsonc`'s parse-error detail
22
+ * shape and `@effected/yaml`'s `YamlDiagnostic`); `message` is this
23
+ * package's additive extra. `line`/`character` here are zero-based to match
24
+ * that contract — a different numbering from the one-based `line`/`column`
25
+ * unist `Point`s carried on `MarkdownNode` positions (`internal/lineIndex.ts`),
26
+ * which is a deliberate, unrelated convention for the AST rather than a
27
+ * mismatch to reconcile.
28
+ *
29
+ * @public
30
+ */
31
+ var MarkdownDiagnostic = class MarkdownDiagnostic extends Schema.Class("MarkdownDiagnostic")({
32
+ code: MarkdownParseErrorCode,
33
+ message: Schema.String,
34
+ offset: Schema.Number,
35
+ length: Schema.Number,
36
+ line: Schema.Number,
37
+ character: Schema.Number
38
+ }) {
39
+ /**
40
+ * Materialize an engine record, deriving zero-based `line`/`character`
41
+ * from `offset` against the source `text`. Advanced — the parse entry
42
+ * points call this for you.
43
+ */
44
+ static fromRaw(source, raw) {
45
+ const { line, character } = lineChar(source, raw.offset);
46
+ return MarkdownDiagnostic.make({
47
+ code: raw.code,
48
+ message: raw.message,
49
+ offset: raw.offset,
50
+ length: raw.length,
51
+ line,
52
+ character
53
+ });
54
+ }
55
+ };
56
+ /**
57
+ * Compute the zero-based line/character position of `offset` within `text`.
58
+ * Recognizes `\n`, `\r` and `\r\n` as line breaks; a CRLF pair counts as a
59
+ * single newline (toml `TomlDiagnostic` precedent — kept independent of
60
+ * `internal/lineIndex.ts`, which answers a different, one-based question
61
+ * for AST positions).
62
+ */
63
+ function lineChar(text, offset) {
64
+ let line = 0;
65
+ let lineStart = 0;
66
+ const limit = Math.min(offset, text.length);
67
+ for (let i = 0; i < limit; i++) {
68
+ const ch = text.charCodeAt(i);
69
+ if (ch === 10) {
70
+ line++;
71
+ lineStart = i + 1;
72
+ } else if (ch === 13) {
73
+ if (i + 1 < text.length && text.charCodeAt(i + 1) === 10) i++;
74
+ line++;
75
+ lineStart = i + 1;
76
+ }
77
+ }
78
+ return {
79
+ line,
80
+ character: offset - lineStart
81
+ };
82
+ }
83
+
84
+ //#endregion
85
+ export { MarkdownDiagnostic, MarkdownParseErrorCode };
@@ -0,0 +1,248 @@
1
+ import { MarkdownRange } from "./MarkdownEdit.js";
2
+ import { Definition, Root } from "./MarkdownNode.js";
3
+ import { normalizeLabelText } from "./internal/references.js";
4
+ import { MarkdownDiagnostic } from "./MarkdownDiagnostic.js";
5
+ import { parsePassResult } from "./Markdown.js";
6
+ import { Effect, Result, Schema } from "effect";
7
+
8
+ //#region src/MarkdownDocument.ts
9
+ const walkTree = (node, depth, visit) => {
10
+ if (depth > 256) throw new Error(`NestingDepthExceeded: limit ${256} exceeded while walking the document tree`);
11
+ visit(node);
12
+ if ("children" in node) for (const child of node.children) walkTree(child, depth + 1, visit);
13
+ };
14
+ const findInTree = (node, depth, predicate) => {
15
+ if (depth > 256) throw new Error(`NestingDepthExceeded: limit ${256} exceeded while walking the document tree`);
16
+ if (predicate(node)) return node;
17
+ if ("children" in node) for (const child of node.children) {
18
+ const found = findInTree(child, depth + 1, predicate);
19
+ if (found !== void 0) return found;
20
+ }
21
+ };
22
+ /** Normalize a find/findAll selector — a `type` tag or a predicate — to a predicate. */
23
+ const selectorPredicate = (selector) => typeof selector === "string" ? (node) => node.type === selector : selector;
24
+ const phrasingText = (nodes) => {
25
+ let out = "";
26
+ for (const node of nodes) switch (node.type) {
27
+ case "text":
28
+ case "inlineCode":
29
+ out += node.value;
30
+ break;
31
+ case "break":
32
+ out += " ";
33
+ break;
34
+ case "image":
35
+ case "imageReference":
36
+ out += node.alt ?? "";
37
+ break;
38
+ case "emphasis":
39
+ case "strong":
40
+ case "delete":
41
+ case "link":
42
+ case "linkReference":
43
+ out += phrasingText(node.children);
44
+ break;
45
+ default: break;
46
+ }
47
+ return out;
48
+ };
49
+ /**
50
+ * A parsed markdown document: the original `source`, the mdast-shaped
51
+ * {@link Root} tree, the non-fatal {@link MarkdownDiagnostic}s the parse
52
+ * produced, and the link-reference `definitions` index.
53
+ *
54
+ * @remarks
55
+ * The document is the lossless unit — `source` is retained so offsets on the
56
+ * tree stay meaningful and so P4's edit/format layer can splice against the
57
+ * exact bytes that were parsed.
58
+ *
59
+ * `definitions` is an index over the {@link Definition} nodes that remain in
60
+ * the tree, keyed by case-folded label with the first definition winning; it
61
+ * is not a place they were moved to. References are emitted unresolved, so
62
+ * resolution happens against this map.
63
+ *
64
+ * `diagnostics` is empty for every input the P1 parser accepts, and that is
65
+ * the current state of the world rather than a missing feature: the plumbing
66
+ * from the engine through to this field is real and exercised, but no P1
67
+ * construct emits a non-fatal diagnostic yet. The producers arrive with the
68
+ * conditions that warrant them — unresolved link references, and
69
+ * present-but-unparseable frontmatter in P3. Read an empty array as "nothing
70
+ * to report", not as "not implemented", and do not code against it staying
71
+ * empty.
72
+ *
73
+ * The navigation accessors (`headings`, `sections`, `links`) are derived
74
+ * getters over the tree — no stored state, no parse-time cost, and they can
75
+ * never disagree with the tree they read.
76
+ *
77
+ * @public
78
+ */
79
+ var MarkdownDocument = class MarkdownDocument extends Schema.Class("MarkdownDocument")({
80
+ source: Schema.String,
81
+ root: Root,
82
+ diagnostics: Schema.Array(MarkdownDiagnostic),
83
+ definitions: Schema.ReadonlyMap(Schema.String, Definition)
84
+ }) {
85
+ /**
86
+ * The document's frontmatter capture, or `undefined` when there is none.
87
+ *
88
+ * @remarks
89
+ * Derived from the tree rather than stored: a {@link Frontmatter} node can
90
+ * only ever sit at the head of `root.children` (the capture fires at most
91
+ * once, at offset 0), so the tree is the single source of truth and the
92
+ * accessor can never disagree with it. `undefined` covers both a document
93
+ * with no frontmatter block and one parsed with the capture toggle off.
94
+ */
95
+ get frontmatter() {
96
+ const head = this.root.children[0];
97
+ return head !== void 0 && head.type === "frontmatter" ? head : void 0;
98
+ }
99
+ /**
100
+ * Every heading in the document, in document order, wherever it sits —
101
+ * including headings nested inside blockquotes and list items.
102
+ *
103
+ * @remarks
104
+ * Each {@link DocumentHeading} carries the node, its depth and its
105
+ * plain-text content. For an outline restricted to section boundaries, use
106
+ * {@link MarkdownDocument.sections}, which considers root-level headings
107
+ * only.
108
+ */
109
+ get headings() {
110
+ const entries = [];
111
+ for (const child of this.root.children) walkTree(child, 1, (node) => {
112
+ if (node.type === "heading") entries.push({
113
+ node,
114
+ depth: node.depth,
115
+ text: phrasingText(node.children)
116
+ });
117
+ });
118
+ return entries;
119
+ }
120
+ /**
121
+ * The document's heading-delimited sections, flat and in document order.
122
+ *
123
+ * @remarks
124
+ * Only root-level headings delimit sections — a heading inside a
125
+ * blockquote or list cannot mark a span of root-level source. Content
126
+ * before the first heading (the preamble) and the frontmatter block belong
127
+ * to no section. Each {@link DocumentSection.range} is spliceable by the
128
+ * edit layer: it runs from the heading's start offset to the next
129
+ * boundary heading's start, or to the end of the source.
130
+ */
131
+ get sections() {
132
+ const blocks = this.root.children;
133
+ const sections = [];
134
+ for (let index = 0; index < blocks.length; index += 1) {
135
+ const block = blocks[index];
136
+ if (block === void 0 || block.type !== "heading") continue;
137
+ let boundary = this.source.length;
138
+ let end = blocks.length;
139
+ for (let next = index + 1; next < blocks.length; next += 1) {
140
+ const candidate = blocks[next];
141
+ if (candidate !== void 0 && candidate.type === "heading" && candidate.depth <= block.depth) {
142
+ boundary = candidate.position.start.offset;
143
+ end = next;
144
+ break;
145
+ }
146
+ }
147
+ const start = block.position.start.offset;
148
+ sections.push({
149
+ heading: block,
150
+ depth: block.depth,
151
+ range: MarkdownRange.make({
152
+ offset: start,
153
+ length: boundary - start
154
+ }),
155
+ children: blocks.slice(index + 1, end).filter((child) => child.type !== "frontmatter")
156
+ });
157
+ }
158
+ return sections;
159
+ }
160
+ /**
161
+ * Every link-bearing node in the document, in document order: links,
162
+ * images, definitions, and the reference forms resolved through the
163
+ * definition index.
164
+ *
165
+ * @remarks
166
+ * See {@link DocumentLink} for the url semantics — the raw `url` string
167
+ * passes through unmodified, and an unresolvable foreign reference leaves
168
+ * the field genuinely absent. Autolinks and GFM autolink literals are
169
+ * {@link Link} nodes, so they appear with no special casing. Footnote
170
+ * references carry no URL and are not link entries.
171
+ */
172
+ get links() {
173
+ const entries = [];
174
+ for (const child of this.root.children) walkTree(child, 1, (node) => {
175
+ switch (node.type) {
176
+ case "link":
177
+ case "image":
178
+ case "definition":
179
+ entries.push({
180
+ node,
181
+ url: node.url
182
+ });
183
+ break;
184
+ case "linkReference":
185
+ case "imageReference": {
186
+ const definition = this.definitions.get(normalizeLabelText(node.identifier));
187
+ entries.push(definition === void 0 ? { node } : {
188
+ node,
189
+ url: definition.url
190
+ });
191
+ break;
192
+ }
193
+ default: break;
194
+ }
195
+ });
196
+ return entries;
197
+ }
198
+ find(selector) {
199
+ return findInTree(this.root, 0, selectorPredicate(selector));
200
+ }
201
+ findAll(selector) {
202
+ const predicate = selectorPredicate(selector);
203
+ const matches = [];
204
+ walkTree(this.root, 0, (node) => {
205
+ if (predicate(node)) matches.push(node);
206
+ });
207
+ return matches;
208
+ }
209
+ /**
210
+ * Parse markdown into a {@link MarkdownDocument}, synchronously, as a
211
+ * `Result`. The pure primitive; {@link MarkdownDocument.parse} is defined
212
+ * in terms of it, so the two never diverge.
213
+ *
214
+ * @remarks
215
+ * Carries no span: it is not an `Effect`. Effect callers should reach for
216
+ * {@link MarkdownDocument.parse}, which carries the
217
+ * `MarkdownDocument.parse` tracing span.
218
+ *
219
+ * @param text - The markdown source to parse.
220
+ * @param options - Optional {@link MarkdownParseOptions}; the dialect
221
+ * defaults to `"gfm"`.
222
+ * @returns A `Result` succeeding with the document, or failing with
223
+ * `MarkdownParseError`.
224
+ */
225
+ static parseResult(text, options) {
226
+ return Result.map(parsePassResult(text, options), (pass) => MarkdownDocument.make({
227
+ source: text,
228
+ root: pass.root,
229
+ diagnostics: pass.carriers.map((carrier) => MarkdownDiagnostic.fromRaw(text, carrier)),
230
+ definitions: pass.refmap
231
+ }));
232
+ }
233
+ /**
234
+ * Parse markdown into a {@link MarkdownDocument}. Defined in terms of
235
+ * {@link MarkdownDocument.parseResult} — synchronous callers can use that
236
+ * variant directly.
237
+ *
238
+ * @param text - The markdown source to parse.
239
+ * @param options - Optional {@link MarkdownParseOptions}; the dialect
240
+ * defaults to `"gfm"`.
241
+ * @returns An `Effect` that succeeds with the document, or fails with
242
+ * `MarkdownParseError`.
243
+ */
244
+ static parse = Effect.fn("MarkdownDocument.parse")((text, options) => Effect.fromResult(MarkdownDocument.parseResult(text, options)));
245
+ };
246
+
247
+ //#endregion
248
+ export { MarkdownDocument };
@@ -0,0 +1,52 @@
1
+ import { Schema } from "effect";
2
+
3
+ //#region src/MarkdownEdit.ts
4
+ /**
5
+ * A range within a markdown document, expressed as a zero-based character
6
+ * `offset` and a `length` in UTF-16 code units. Pass to `MarkdownFormat.format`
7
+ * to restrict formatting to a region.
8
+ *
9
+ * @public
10
+ */
11
+ var MarkdownRange = class extends Schema.Class("MarkdownRange")({
12
+ offset: Schema.Number,
13
+ length: Schema.Number
14
+ }) {};
15
+ /**
16
+ * A non-mutating text edit: replace the span `[offset, offset + length)` with
17
+ * `content`. Set `length` to `0` to insert, `content` to `""` to delete.
18
+ *
19
+ * @remarks
20
+ * Structurally identical to `@effected/jsonc`'s, `@effected/yaml`'s and
21
+ * `@effected/toml`'s edit shapes (same field names, types and semantics) per
22
+ * the cross-package parity convention, so consumer code can be written once
23
+ * over "a document codec's Edit/Range/Path".
24
+ *
25
+ * @public
26
+ */
27
+ var MarkdownEdit = class extends Schema.Class("MarkdownEdit")({
28
+ offset: Schema.Number,
29
+ length: Schema.Number,
30
+ content: Schema.String
31
+ }) {
32
+ /**
33
+ * Apply `edits` to `text`, producing a new string. Edits are applied in
34
+ * reverse-offset order so earlier offsets stay valid; the input `edits`
35
+ * array is not mutated. Overlapping edits are a programmer error and throw
36
+ * as a defect — `MarkdownFormat` never produces them.
37
+ */
38
+ static applyAll(text, edits) {
39
+ const sorted = [...edits].sort((a, b) => b.offset - a.offset);
40
+ for (let i = 0; i + 1 < sorted.length; i++) {
41
+ const upper = sorted[i];
42
+ const lower = sorted[i + 1];
43
+ if (lower.offset + lower.length > upper.offset) throw new Error(`MarkdownEdit.applyAll received overlapping edits at offsets ${lower.offset} and ${upper.offset} — overlapping edits are a programmer error`);
44
+ }
45
+ let result = text;
46
+ for (const edit of sorted) result = result.slice(0, edit.offset) + edit.content + result.slice(edit.offset + edit.length);
47
+ return result;
48
+ }
49
+ };
50
+
51
+ //#endregion
52
+ export { MarkdownEdit, MarkdownRange };