@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.
- package/Frontmatter.js +272 -0
- package/FrontmatterResolver.js +273 -0
- package/JsonFrontmatter.js +46 -0
- package/LICENSE +21 -0
- package/Markdown.js +251 -0
- package/MarkdownDiagnostic.js +85 -0
- package/MarkdownDocument.js +248 -0
- package/MarkdownEdit.js +52 -0
- package/MarkdownFormat.js +380 -0
- package/MarkdownNode.js +641 -0
- package/MarkdownVisitor.js +88 -0
- package/Mdast.js +384 -0
- package/README.md +255 -0
- package/TomlFrontmatter.js +44 -0
- package/YamlFrontmatter.js +45 -0
- package/index.d.ts +2190 -0
- package/index.js +15 -0
- package/internal/blockParser.js +419 -0
- package/internal/blockRegistry.js +90 -0
- package/internal/blockTypes.js +27 -0
- package/internal/blocks/atxHeading.js +54 -0
- package/internal/blocks/blockquote.js +40 -0
- package/internal/blocks/code.js +90 -0
- package/internal/blocks/document.js +17 -0
- package/internal/blocks/fencedCode.js +26 -0
- package/internal/blocks/footnoteDefinition.js +84 -0
- package/internal/blocks/frontmatter.js +56 -0
- package/internal/blocks/htmlBlock.js +72 -0
- package/internal/blocks/indentedCode.js +16 -0
- package/internal/blocks/linkReferenceDefinition.js +72 -0
- package/internal/blocks/list.js +159 -0
- package/internal/blocks/paragraph.js +27 -0
- package/internal/blocks/setextHeading.js +24 -0
- package/internal/blocks/table.js +378 -0
- package/internal/blocks/taskListItem.js +36 -0
- package/internal/blocks/thematicBreak.js +35 -0
- package/internal/carriers.js +42 -0
- package/internal/entities.js +26 -0
- package/internal/entityMap.js +7 -0
- package/internal/htmlTags.js +18 -0
- package/internal/inlineNode.js +54 -0
- package/internal/inlineParser.js +403 -0
- package/internal/inlineRegistry.js +68 -0
- package/internal/inlines/autolink.js +36 -0
- package/internal/inlines/autolinkLiteral.js +374 -0
- package/internal/inlines/codeSpan.js +32 -0
- package/internal/inlines/emphasis.js +79 -0
- package/internal/inlines/entity.js +21 -0
- package/internal/inlines/escape.js +34 -0
- package/internal/inlines/footnoteReference.js +63 -0
- package/internal/inlines/lineBreak.js +25 -0
- package/internal/inlines/link.js +212 -0
- package/internal/inlines/rawHtml.js +27 -0
- package/internal/inlines/strikethrough.js +81 -0
- package/internal/inlines/text.js +22 -0
- package/internal/lineIndex.js +76 -0
- package/internal/patterns.js +26 -0
- package/internal/preprocess.js +58 -0
- package/internal/rawInline.js +57 -0
- package/internal/references.js +160 -0
- package/internal/segments.js +36 -0
- package/internal/stringify.js +519 -0
- package/internal/unescape.js +18 -0
- package/package.json +61 -0
- 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 };
|
package/MarkdownEdit.js
ADDED
|
@@ -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 };
|