@effected/markdown 0.2.5 → 0.4.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 CHANGED
@@ -69,22 +69,43 @@ var FrontmatterEncodeError = class extends Schema.TaggedErrorClass()("Frontmatte
69
69
  }
70
70
  };
71
71
  /**
72
+ * Why a frontmatter decoder found no capture on a document.
73
+ *
74
+ * @remarks
75
+ * `"absent"` — the source genuinely has no frontmatter block.
76
+ * `"captureDisabled"` — the source opens with a well-formed frontmatter
77
+ * block, but the document carries no capture node: it was parsed without
78
+ * `frontmatter: true` (the toggle defaults off), so spec-correct CommonMark
79
+ * read the fences as ordinary content.
80
+ *
81
+ * @public
82
+ */
83
+ const FrontmatterMissingReason = Schema.Literals(["absent", "captureDisabled"]);
84
+ /**
72
85
  * Indicates that a document handed to a frontmatter decoder carries no
73
86
  * frontmatter capture.
74
87
  *
75
88
  * @remarks
76
- * Raised when the document genuinely has no frontmatter block — including
77
- * when it has one in the source but was parsed with the capture toggle off
78
- * (`MarkdownParseOptions.frontmatter` defaults to `false`). Cause-free: there
79
- * is nothing to diagnose beyond the absence itself. Consumers wanting
80
- * optional semantics can `Effect.catchTag("FrontmatterMissingError", ...)`
81
- * to a default.
89
+ * `reason` distinguishes the two ways this happens: `"absent"` when the
90
+ * source genuinely has no frontmatter block, `"captureDisabled"` when the
91
+ * source opens with one but the document was parsed with the capture toggle
92
+ * off (`MarkdownParseOptions.frontmatter` defaults to `false`) — the fix
93
+ * there is parsing with `frontmatter: true`, not editing the document. The
94
+ * discriminant is computed from the same structural signal
95
+ * `MarkdownDocument.hasFrontmatterBlock` exposes, so the error and the
96
+ * accessor can never disagree.
97
+ *
98
+ * Cause-free: there is nothing to diagnose beyond the absence itself.
99
+ * Consumers wanting optional semantics can
100
+ * `Effect.catchTag("FrontmatterMissingError", ...)` to a default.
82
101
  *
83
102
  * @public
84
103
  */
85
- var FrontmatterMissingError = class extends Schema.TaggedErrorClass()("FrontmatterMissingError", {}) {
104
+ var FrontmatterMissingError = class extends Schema.TaggedErrorClass()("FrontmatterMissingError", {
105
+ /** Why there is no capture: no block at all, or capture left off. */
106
+ reason: FrontmatterMissingReason }) {
86
107
  get message() {
87
- return "the document has no frontmatter capture; was it parsed with `frontmatter: true`?";
108
+ return this.reason === "captureDisabled" ? "the source opens with a frontmatter block, but it was not captured; parse with `frontmatter: true`" : "the document has no frontmatter block";
88
109
  }
89
110
  };
90
111
  /**
@@ -157,7 +178,8 @@ var MarkdownFrontmatter = class MarkdownFrontmatter {
157
178
  * The decoder reads the document's frontmatter capture (parse with
158
179
  * `frontmatter: true` — the toggle defaults off), decodes its raw value
159
180
  * through the codec, then validates the data against `schema`. Each stage
160
- * fails typed: no capture is {@link FrontmatterMissingError}, a
181
+ * fails typed: no capture is {@link FrontmatterMissingError} (its `reason`
182
+ * says whether the block is genuinely absent or capture was left off), a
161
183
  * wrong-format codec is {@link FrontmatterFormatMismatchError}, unparseable
162
184
  * content is {@link FrontmatterDecodeError}, and schema-invalid data is
163
185
  * {@link FrontmatterValidationError} carrying the structured issue.
@@ -178,7 +200,7 @@ var MarkdownFrontmatter = class MarkdownFrontmatter {
178
200
  static schema(schema, codec) {
179
201
  return (document) => {
180
202
  const node = document.frontmatter;
181
- return node === void 0 ? Effect.fail(new FrontmatterMissingError()) : codec.decode(node).pipe(Effect.flatMap((data) => Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new FrontmatterValidationError({ issue: error.issue }))))));
203
+ return node === void 0 ? Effect.fail(new FrontmatterMissingError({ reason: document.hasFrontmatterBlock ? "captureDisabled" : "absent" })) : codec.decode(node).pipe(Effect.flatMap((data) => Schema.decodeUnknownEffect(schema)(data).pipe(Effect.catchTag("SchemaError", (error) => Effect.fail(new FrontmatterValidationError({ issue: error.issue }))))));
182
204
  };
183
205
  }
184
206
  /**
@@ -269,4 +291,4 @@ var MarkdownFrontmatter = class MarkdownFrontmatter {
269
291
  };
270
292
 
271
293
  //#endregion
272
- export { FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormatMismatchError, FrontmatterMissingError, FrontmatterValidationError, MarkdownFrontmatter };
294
+ export { FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormatMismatchError, FrontmatterMissingError, FrontmatterMissingReason, FrontmatterValidationError, MarkdownFrontmatter };
package/Markdown.js CHANGED
@@ -28,7 +28,9 @@ const MarkdownDialect = Schema.Literals(["commonmark", "gfm"]);
28
28
  * spec-conformant reading must hold unless a consumer opts in. Enabled, the
29
29
  * engine captures `---`/`+++`/`---json` blocks at offset 0 into a raw
30
30
  * `Frontmatter` head node — decoding it is the frontmatter codec modules'
31
- * job.
31
+ * job. Parsed with the toggle off, such a document reports no frontmatter —
32
+ * `MarkdownDocument.hasFrontmatterBlock` tells "no block in the source"
33
+ * apart from "parsed with capture off".
32
34
  *
33
35
  * @public
34
36
  */
@@ -181,10 +183,14 @@ var Markdown = class Markdown {
181
183
  * @remarks
182
184
  * Canonical serialization: fidelity fields (marker characters, fence
183
185
  * 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.
186
+ * defaults apply when absent. One default surprises: a language-less
187
+ * `Code` node with no explicit `fenceChar` serializes as an
188
+ * **indented** code block — set `fenceChar` (or a `lang`) on the node for
189
+ * a fence, or normalize afterward with
190
+ * `MarkdownFormattingOptions.codeBlockStyle: "fenced"`. The output
191
+ * re-parses to a render-equivalent document — the corpus-pinned
192
+ * contract — but is not a byte-level round-trip of any original source;
193
+ * surgical editing goes through the offset-splice edit layer instead.
188
194
  *
189
195
  * Failure is rare by design, symmetric with parse: only a hardening-guard
190
196
  * trip on a tree nesting past the depth cap fails, and only synthesized
@@ -1,11 +1,87 @@
1
1
  import { MarkdownRange } from "./MarkdownEdit.js";
2
2
  import { Definition, Root } from "./MarkdownNode.js";
3
+ import { preprocessLines } from "./internal/preprocess.js";
3
4
  import { normalizeLabelText } from "./internal/references.js";
5
+ import { scanFrontmatter } from "./internal/blocks/frontmatter.js";
4
6
  import { MarkdownDiagnostic } from "./MarkdownDiagnostic.js";
5
7
  import { parsePassResult } from "./Markdown.js";
6
8
  import { Effect, Result, Schema } from "effect";
7
9
 
8
10
  //#region src/MarkdownDocument.ts
11
+ /**
12
+ * A heading-delimited span from {@link MarkdownDocument.sections}: the
13
+ * heading, its depth, the source `range` the section occupies and the
14
+ * root-level blocks that follow the heading inside it.
15
+ *
16
+ * @remarks
17
+ * A section runs from its heading's start offset to the start of the next
18
+ * root-level heading of equal or shallower depth, or to the end of the
19
+ * source. Deeper headings nest inside, so a parent section's `range` and
20
+ * `children` include its subsections — the list is flat, in document order,
21
+ * with `depth` carrying the hierarchy.
22
+ *
23
+ * Instances come from {@link MarkdownDocument.sections} and the two finders;
24
+ * there is no reason to construct one directly.
25
+ *
26
+ * @public
27
+ */
28
+ var DocumentSection = class {
29
+ /** The heading that opens the section. */
30
+ heading;
31
+ /** The heading's depth, and the section's. */
32
+ depth;
33
+ /** The whole section including its heading — spliceable by `MarkdownEdit`. */
34
+ range;
35
+ /** The root-level blocks after the heading, subsections included. */
36
+ children;
37
+ /**
38
+ * The heading's plain-text content.
39
+ *
40
+ * @remarks
41
+ * The same derivation as {@link DocumentHeading.text}: text and inline-code
42
+ * values concatenated, image `alt` where present, a hard break as one space,
43
+ * nothing for raw HTML or footnote references. So `## **1.2.3**` reads
44
+ * `1.2.3`.
45
+ */
46
+ text;
47
+ /**
48
+ * The section WITHOUT its heading: from the heading's end offset to the
49
+ * section's end.
50
+ *
51
+ * @remarks
52
+ * This is the span a "give me the release notes under this version" reader
53
+ * wants, and the one that is fiddly to derive by hand.
54
+ */
55
+ bodyRange;
56
+ /** The document source these ranges index into. */
57
+ #documentSource;
58
+ constructor(options) {
59
+ this.heading = options.heading;
60
+ this.depth = options.depth;
61
+ this.range = options.range;
62
+ this.children = options.children;
63
+ this.text = options.text;
64
+ this.bodyRange = options.bodyRange;
65
+ this.#documentSource = options.documentSource;
66
+ }
67
+ /** The section's source text, heading included. */
68
+ get source() {
69
+ return this.#documentSource.slice(this.range.offset, this.range.offset + this.range.length);
70
+ }
71
+ /**
72
+ * The section's source text without its heading.
73
+ *
74
+ * @remarks
75
+ * **Untrimmed, deliberately.** It is exactly the bytes {@link
76
+ * DocumentSection.bodyRange} describes, so the string and the offsets can
77
+ * never disagree — a package that publishes a span's offsets and then hands
78
+ * back a different span is lying about it. Callers that want the tidy form
79
+ * write `.body.trim()`, visibly.
80
+ */
81
+ get body() {
82
+ return this.#documentSource.slice(this.bodyRange.offset, this.bodyRange.offset + this.bodyRange.length);
83
+ }
84
+ };
9
85
  const walkTree = (node, depth, visit) => {
10
86
  if (depth > 256) throw new Error(`NestingDepthExceeded: limit ${256} exceeded while walking the document tree`);
11
87
  visit(node);
@@ -90,13 +166,38 @@ var MarkdownDocument = class MarkdownDocument extends Schema.Class("MarkdownDocu
90
166
  * only ever sit at the head of `root.children` (the capture fires at most
91
167
  * once, at offset 0), so the tree is the single source of truth and the
92
168
  * accessor can never disagree with it. `undefined` covers both a document
93
- * with no frontmatter block and one parsed with the capture toggle off.
169
+ * with no frontmatter block and one parsed with the capture toggle off —
170
+ * {@link MarkdownDocument.hasFrontmatterBlock} tells the two apart.
94
171
  */
95
172
  get frontmatter() {
96
173
  const head = this.root.children[0];
97
174
  return head !== void 0 && head.type === "frontmatter" ? head : void 0;
98
175
  }
99
176
  /**
177
+ * Whether the source opens with a well-formed frontmatter block —
178
+ * regardless of how the document was parsed.
179
+ *
180
+ * @remarks
181
+ * The structural signal that disambiguates the `frontmatter` accessor's
182
+ * `undefined`: capture is opt-in (`MarkdownParseOptions.frontmatter`
183
+ * defaults off), so a document whose source visibly starts with `---` can
184
+ * still carry no capture node — spec-correct CommonMark reads the fences as
185
+ * a thematic break plus content. `frontmatter === undefined` with
186
+ * `hasFrontmatterBlock === true` means exactly "parsed with capture off";
187
+ * with `false` it means the source genuinely has no block.
188
+ *
189
+ * Derived, not stored: the getter runs the **same** pre-scan the parser
190
+ * runs when capture is enabled (the closed fence grammar — `---` yaml,
191
+ * `+++` toml, `---json` json; an unclosed fence is not frontmatter), so it
192
+ * is `true` exactly when parsing this source with `frontmatter: true`
193
+ * would produce a capture node, and the two can never drift. Like the
194
+ * other navigation accessors it recomputes per access — bind it once when
195
+ * checking repeatedly.
196
+ */
197
+ get hasFrontmatterBlock() {
198
+ return scanFrontmatter(preprocessLines(this.source), this.source) !== null;
199
+ }
200
+ /**
100
201
  * Every heading in the document, in document order, wherever it sits —
101
202
  * including headings nested inside blockquotes and list items.
102
203
  *
@@ -145,19 +246,68 @@ var MarkdownDocument = class MarkdownDocument extends Schema.Class("MarkdownDocu
145
246
  }
146
247
  }
147
248
  const start = block.position.start.offset;
148
- sections.push({
249
+ const bodyStart = block.position.end.offset;
250
+ sections.push(new DocumentSection({
149
251
  heading: block,
150
252
  depth: block.depth,
151
253
  range: MarkdownRange.make({
152
254
  offset: start,
153
255
  length: boundary - start
154
256
  }),
155
- children: blocks.slice(index + 1, end).filter((child) => child.type !== "frontmatter")
156
- });
257
+ children: blocks.slice(index + 1, end).filter((child) => child.type !== "frontmatter"),
258
+ text: phrasingText(block.children),
259
+ bodyRange: MarkdownRange.make({
260
+ offset: bodyStart,
261
+ length: Math.max(0, boundary - bodyStart)
262
+ }),
263
+ documentSource: this.source
264
+ }));
157
265
  }
158
266
  return sections;
159
267
  }
160
268
  /**
269
+ * The first section in document order, optionally restricted by heading
270
+ * depth.
271
+ *
272
+ * @remarks
273
+ * `firstSection({ depth: 2 })` is "the newest entry" for a changelog whose
274
+ * releases are H2s under an H1 title. Like the other navigation accessors
275
+ * this recomputes {@link MarkdownDocument.sections}; a caller scanning one
276
+ * document repeatedly should bind `document.sections` once instead.
277
+ *
278
+ * @param options - Depth restriction.
279
+ * @returns The first matching section, or `undefined`.
280
+ */
281
+ firstSection(options) {
282
+ const depth = options?.depth;
283
+ return this.sections.find((section) => depth === void 0 || section.depth === depth);
284
+ }
285
+ /**
286
+ * The first section whose heading matches, in document order.
287
+ *
288
+ * @remarks
289
+ * A **string matches the trimmed heading text exactly** — never a substring.
290
+ * That is a deliberate refusal of the obvious convenience: `"1.2.3"` would
291
+ * otherwise match a `## 1.2.30` heading, and in a changelog (where `1.2.30`
292
+ * sorts newer and therefore sits *earlier*) that silently publishes the
293
+ * wrong release. A `RegExp` is tested against the same trimmed text, and its
294
+ * `lastIndex` is reset first so a `/g/` pattern cannot skip sections. A
295
+ * predicate receives the raw {@link DocumentSection.text} plus the section
296
+ * itself and decides freely.
297
+ *
298
+ * @param match - A string, a RegExp, or a predicate.
299
+ * @param options - Depth restriction, applied before matching.
300
+ * @returns The first matching section, or `undefined`.
301
+ */
302
+ sectionByHeading(match, options) {
303
+ const depth = options?.depth;
304
+ const matches = typeof match === "string" ? (section) => section.text.trim() === match : match instanceof RegExp ? (section) => {
305
+ match.lastIndex = 0;
306
+ return match.test(section.text.trim());
307
+ } : (section) => match(section.text, section);
308
+ return this.sections.find((section) => (depth === void 0 || section.depth === depth) && matches(section));
309
+ }
310
+ /**
161
311
  * Every link-bearing node in the document, in document order: links,
162
312
  * images, definitions, and the reference forms resolved through the
163
313
  * definition index.
@@ -245,4 +395,4 @@ var MarkdownDocument = class MarkdownDocument extends Schema.Class("MarkdownDocu
245
395
  };
246
396
 
247
397
  //#endregion
248
- export { MarkdownDocument };
398
+ export { DocumentSection, MarkdownDocument };
package/MarkdownFormat.js CHANGED
@@ -5,6 +5,19 @@ import { Effect, Result, Schema } from "effect";
5
5
 
6
6
  //#region src/MarkdownFormat.ts
7
7
  /**
8
+ * The two ways CommonMark spells a code block: `fenced` (a backtick or tilde
9
+ * fence) and `indented` (four-space indentation).
10
+ *
11
+ * @remarks
12
+ * A formatting target for {@link MarkdownFormattingOptions}, not a node
13
+ * fidelity field: on a {@link Code} node the two spellings are told apart by
14
+ * the presence or absence of `fenceChar`/`fenceLength`, and a language-less
15
+ * node with neither serializes as an **indented** block by default.
16
+ *
17
+ * @public
18
+ */
19
+ const CodeBlockStyle = Schema.Literals(["fenced", "indented"]);
20
+ /**
8
21
  * Options controlling formatting: which concrete-syntax markers to normalize,
9
22
  * plus the parse knobs (`dialect`, `frontmatter`) the formatter parses the
10
23
  * source with (same defaults as `Markdown.parse`). Every marker option is
@@ -12,7 +25,25 @@ import { Effect, Result, Schema } from "effect";
12
25
  *
13
26
  * Day-one scope is marker normalization only — heading style, bullet
14
27
  * character, emphasis/strong marker, fence character, thematic-break
15
- * character. Content is never rewritten, rewrapped or reflowed.
28
+ * character, code-block style. Content is never rewritten, rewrapped or
29
+ * reflowed.
30
+ *
31
+ * @remarks
32
+ * `codeBlockStyle` is the opt-in that converts between the two code-block
33
+ * spellings for **language-less** blocks (a block with a `lang` has no
34
+ * indented spelling and is never touched). It exists because the default
35
+ * surprises: absent this option, a language-less code block keeps its
36
+ * spelling — and a language-less {@link Code} node with no explicit
37
+ * `fenceChar` **serializes as an indented block** through
38
+ * `Markdown.stringify`. `"fenced"` rewrites indented blocks to fences (the
39
+ * fence character follows `fenceChar` when also set, backtick otherwise);
40
+ * `"indented"` rewrites language-less fences to indented form where provably
41
+ * safe. Like every other option, hazardous conversions are skipped, never
42
+ * attempted: both directions apply to root-level, flush-left blocks only,
43
+ * and the `"indented"` direction additionally skips blocks whose indented
44
+ * spelling would lazily continue a paragraph, be absorbed into a preceding
45
+ * list or footnote definition, merge with an adjacent code block, or fail
46
+ * representability (empty, or blank first/last lines).
16
47
  *
17
48
  * @public
18
49
  */
@@ -23,7 +54,8 @@ var MarkdownFormattingOptions = class extends Schema.Class("MarkdownFormattingOp
23
54
  bulletChar: Schema.optionalKey(BulletChar),
24
55
  emphasisChar: Schema.optionalKey(EmphasisChar),
25
56
  fenceChar: Schema.optionalKey(FenceChar),
26
- thematicBreakChar: Schema.optionalKey(ThematicBreakChar)
57
+ thematicBreakChar: Schema.optionalKey(ThematicBreakChar),
58
+ codeBlockStyle: Schema.optionalKey(CodeBlockStyle)
27
59
  }) {};
28
60
  /**
29
61
  * Error codes `MarkdownFormat.modify` can fail with.
@@ -193,6 +225,51 @@ const formatFence = (source, emit, node, target) => {
193
225
  while (end - closeLength > start + openLength && source[end - closeLength - 1] === fidelity) closeLength++;
194
226
  if (closeLength >= 3) emit.push(node, end - closeLength, closeLength, target.repeat(newLength));
195
227
  };
228
+ /** A line that is empty or holds only spaces and tabs. */
229
+ const BLANK_LINE = /^[ \t]*$/;
230
+ /** Whether a sibling is a code block with no info string (either spelling). */
231
+ const isLanguagelessCode = (node) => node.type === "code" && node.lang === void 0 && node.meta === void 0;
232
+ /**
233
+ * Convert a language-less code block toward the requested style. Returns
234
+ * whether a whole-block conversion edit was emitted, so the caller can keep
235
+ * `formatFence` off a span this conversion already rewrote.
236
+ *
237
+ * Both directions are whole-block rewrites, so both are restricted to
238
+ * root-level, flush-left blocks — a container's continuation-line prefix
239
+ * (`> `, list indentation) is not reproducible by a single splice. The
240
+ * indented direction carries the re-parse hazards an indented block is
241
+ * subject to and skips each one: it cannot follow a non-blank line (lazy
242
+ * paragraph continuation), a list or footnote definition absorbs it as item
243
+ * continuation, an adjacent language-less code block would merge with it
244
+ * across the blank line, and an empty value or one with blank first/last
245
+ * lines (or interior whitespace-only lines, which blankness would erase) has
246
+ * no indented spelling at all.
247
+ */
248
+ const formatCodeBlockStyle = (source, emit, node, parent, siblings, index, target, fenceChar) => {
249
+ if (node.lang !== void 0 || node.meta !== void 0) return false;
250
+ if (parent === void 0 || parent.type !== "root") return false;
251
+ const { start, end } = spanOf(node);
252
+ if (lineStartOf(source, start) !== start) return false;
253
+ const value = node.value.endsWith("\n") ? node.value.slice(0, -1) : node.value;
254
+ if (target === "fenced") {
255
+ if (node.fenceChar !== void 0) return false;
256
+ const char = fenceChar ?? "`";
257
+ const fence = char.repeat(Math.max(3, longestRun(value, char) + 1));
258
+ emit.push(node, start, end - start, `${fence}\n${value}\n${fence}`);
259
+ return true;
260
+ }
261
+ if (node.fenceChar === void 0) return false;
262
+ const lines = value === "" ? [] : value.split("\n");
263
+ if (lines.length === 0 || BLANK_LINE.test(lines[0]) || BLANK_LINE.test(lines[lines.length - 1])) return false;
264
+ if (lines.some((line) => line !== "" && BLANK_LINE.test(line))) return false;
265
+ if (!previousLineBlank(source, start)) return false;
266
+ const previous = index > 0 ? siblings[index - 1] : void 0;
267
+ const next = index + 1 < siblings.length ? siblings[index + 1] : void 0;
268
+ if (previous !== void 0 && (previous.type === "list" || previous.type === "footnoteDefinition")) return false;
269
+ if (previous !== void 0 && isLanguagelessCode(previous) || next !== void 0 && isLanguagelessCode(next)) return false;
270
+ emit.push(node, start, end - start, lines.map((line) => line === "" ? "" : ` ${line}`).join("\n"));
271
+ return true;
272
+ };
196
273
  const FLOW_TYPES = /* @__PURE__ */ new Set([
197
274
  "blockquote",
198
275
  "code",
@@ -279,12 +356,18 @@ var MarkdownFormat = class MarkdownFormat {
279
356
  /**
280
357
  * Compute marker-normalization edits per the requested
281
358
  * {@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}).
359
+ * emphasis/strong marker, fence character, thematic-break character and
360
+ * code-block style, each only where the node's concrete syntax differs
361
+ * from the target and the rewrite is provably safe. Content is never
362
+ * touched. `range` restricts edits to the nodes intersecting it (the
363
+ * owning-node intersection posture, matching toml). Non-mutating — apply
364
+ * with `MarkdownEdit.applyAll` (or use
365
+ * {@link MarkdownFormat.formatToString}).
366
+ *
367
+ * Note the code-block default: absent `codeBlockStyle`, a language-less
368
+ * code block keeps whichever spelling it has — `fenceChar` alone
369
+ * normalizes existing fences and deliberately leaves indented blocks
370
+ * indented.
288
371
  */
289
372
  static format(text, range, options) {
290
373
  const parsed = Markdown.parseResult(text, MarkdownParseOptions.make({
@@ -293,12 +376,14 @@ var MarkdownFormat = class MarkdownFormat {
293
376
  }));
294
377
  if (Result.isFailure(parsed)) return [];
295
378
  const emit = new FormatEmitter(text);
296
- walk(parsed.success, void 0, [], 0, (node, _parent, siblings, index) => {
379
+ walk(parsed.success, void 0, [], 0, (node, parent, siblings, index) => {
297
380
  if (options?.thematicBreakChar !== void 0 && node.type === "thematicBreak") formatThematicBreak(text, emit, node, options.thematicBreakChar);
298
381
  if (options?.headingStyle !== void 0 && node.type === "heading") formatHeading(text, emit, node, options.headingStyle);
299
382
  if (options?.bulletChar !== void 0 && node.type === "list") formatList(text, emit, node, siblings, index, options.bulletChar);
300
383
  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);
384
+ if (node.type === "code") {
385
+ if (!(options?.codeBlockStyle !== void 0 && formatCodeBlockStyle(text, emit, node, parent, siblings, index, options.codeBlockStyle, options.fenceChar)) && options?.fenceChar !== void 0) formatFence(text, emit, node, options.fenceChar);
386
+ }
302
387
  });
303
388
  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
389
  offset: edit.offset,
@@ -377,4 +462,4 @@ var MarkdownFormat = class MarkdownFormat {
377
462
  };
378
463
 
379
464
  //#endregion
380
- export { MarkdownFormat, MarkdownFormattingOptions, MarkdownModificationError, MarkdownModificationErrorCode };
465
+ export { CodeBlockStyle, MarkdownFormat, MarkdownFormattingOptions, MarkdownModificationError, MarkdownModificationErrorCode };
package/MarkdownNode.js CHANGED
@@ -347,6 +347,12 @@ var ThematicBreak = class extends Schema.Class("ThematicBreak")({
347
347
  * for fenced blocks and **absent for indented blocks** — their absence is how
348
348
  * the two are told apart on the way back out.
349
349
  *
350
+ * A consequence worth knowing before it surprises: a language-less `Code`
351
+ * node with no explicit `fenceChar` **serializes as an indented block**
352
+ * through `Markdown.stringify`. To get a fence, set `fenceChar` on the node
353
+ * (or a `lang`, which forces one), or format the emitted source with
354
+ * `MarkdownFormattingOptions.codeBlockStyle: "fenced"`.
355
+ *
350
356
  * @public
351
357
  */
352
358
  var Code = class extends Schema.Class("Code")({
package/README.md CHANGED
@@ -106,7 +106,7 @@ Effect.runPromise(program).then(console.log);
106
106
 
107
107
  `find` walks the tree in document pre-order and returns the document's own node, so the match feeds `modify` by identity. Replacements are literal strings or node fragments, and both render through the canonical stringifier, so a modified document re-parses cleanly by construction — you cannot splice in raw markdown that reopens a fence or breaks a table.
108
108
 
109
- `MarkdownFormat.format` handles the other half: conservative marker normalization, never content rewriting. It converts heading style, bullet character, emphasis marker, fence character and thematic-break character, and skips any conversion that would not be safe rather than attempting it cleverly:
109
+ `MarkdownFormat.format` handles the other half: conservative marker normalization, never content rewriting. It converts heading style, bullet character, emphasis marker, fence character, thematic-break character and code-block style, and skips any conversion that would not be safe rather than attempting it cleverly:
110
110
 
111
111
  ```ts
112
112
  import { MarkdownFormat, MarkdownFormattingOptions } from "@effected/markdown";
@@ -136,6 +136,8 @@ console.log(MarkdownFormat.format(source, undefined, options));
136
136
 
137
137
  `format` is pure and total, and the edits are non-mutating data — hand them to `MarkdownEdit.applyAll`, or send them to an editor as a text-edit payload.
138
138
 
139
+ `codeBlockStyle` is the option to know about before you hit its default: absent it, a language-less code block keeps whichever spelling it already has, and a language-less `Code` node with no `fenceChar` serializes as an *indented* block. `"fenced"` rewrites indented blocks to fences, `"indented"` goes the other way where the result provably re-parses the same, and both directions apply to root-level flush-left blocks only. A block with a language has no indented spelling and is never touched.
140
+
139
141
  ## Frontmatter
140
142
 
141
143
  Frontmatter capture is opt-in, because enabling it changes how a document opening with `---` parses: CommonMark reads `---\ntitle: x\n---` as a thematic break and a setext heading, and that spec-conformant reading holds unless you ask for something else. Turn it on and compose your schema with a codec for typed gray-matter parity:
@@ -166,6 +168,8 @@ Effect.runPromise(program).then(console.log);
166
168
 
167
169
  Each stage fails typed and separately: no capture is a `FrontmatterMissingError` (catch the tag for optional semantics), a codec handed the wrong fence is a `FrontmatterFormatMismatchError`, unparseable content is a `FrontmatterDecodeError` carrying the format package's own positioned failure structurally, and schema-invalid data is a `FrontmatterValidationError` carrying the structured issue tree rather than a stringified rendering.
168
170
 
171
+ Because capture is opt-in, "there is no frontmatter" has two meanings, and `FrontmatterMissingError.reason` tells them apart: `"absent"` when the source genuinely has no block, `"captureDisabled"` when it opens with one but was parsed with the toggle off — where the fix is a parse option, not a document edit. `MarkdownDocument.hasFrontmatterBlock` answers the same question directly, by re-running the parser's own pre-scan, so the accessor and the error can never disagree.
172
+
169
173
  The three codecs — `YamlFrontmatter` (`---`), `TomlFrontmatter` (`+++`) and `JsonFrontmatter` (`---json`) — are free-standing named exports, one module each, deliberately never collected into a namespace object. Naming one codec is what pulls in its format engine, so a JSON-frontmatter consumer never pays for the yaml parser.
170
174
 
171
175
  Frontmatter blocks can also describe their own schema. `SchemaResolver.classify` sorts a `$schema` value into a tagged union — `ByUrl`, `ByPath`, `Inline` and `ByName` — and `SchemaResolver.fromRegistry` resolves `ByName` declarations like `skill@2.1.0` against schemas you register. URLs, paths and inline documents are carried as data and never fetched: this is a pure package and it performs no IO.
@@ -192,7 +196,33 @@ Effect.runPromise(program).then(console.log);
192
196
  // [ "./CHANGELOG.md", "https://example.com/docs" ]
193
197
  ```
194
198
 
195
- `headings` lists every heading wherever it sits, including inside blockquotes and list items. `sections` are delimited by root-level headings only, and each section's range spans its subsections, so the edit layer can splice a whole section out in one edit.
199
+ `headings` lists every heading wherever it sits, including inside blockquotes and list items. `sections` are delimited by root-level headings only, and each section's range spans its subsections, so the edit layer can splice a whole section out in one edit. `firstSection` and `sectionByHeading` find one `DocumentSection` without scanning `sections` yourself — the second matches on an exact trimmed heading string, a `RegExp`, or a predicate, and both accept `{ depth }` to restrict to one heading level:
200
+
201
+ ```ts
202
+ import { MarkdownDocument } from "@effected/markdown";
203
+ import { Effect } from "effect";
204
+
205
+ const source = `# Changelog
206
+
207
+ ## 1.2.3
208
+
209
+ Fixed a bug.
210
+
211
+ ## 1.2.2
212
+
213
+ Initial release.
214
+ `;
215
+
216
+ const program = Effect.gen(function* () {
217
+ const doc = yield* MarkdownDocument.parse(source);
218
+ return doc.sectionByHeading("1.2.3", { depth: 2 })?.body.trim();
219
+ });
220
+
221
+ Effect.runPromise(program).then(console.log);
222
+ // Fixed a bug.
223
+ ```
224
+
225
+ `DocumentSection.body` is the section without its heading, and it is deliberately untrimmed — exactly the bytes `bodyRange` describes — so callers write `.body.trim()` when they want the tidy form.
196
226
 
197
227
  For anything the accessors do not cover, `find` and `findAll` walk the whole tree in document pre-order. A string selector matches the node's `type` and narrows the result — `doc.findAll("heading")` is `ReadonlyArray<Heading>`, `doc.find("table")` is `Table | undefined` — and a type-guard predicate narrows the same way. The nodes come back by identity, so `doc.findAll("heading")[1]` addresses the second heading for `MarkdownFormat.modify` without raw child indexing.
198
228
 
@@ -234,13 +264,13 @@ if (Result.isSuccess(parsed)) {
234
264
  ## Features
235
265
 
236
266
  - `Markdown` — `parse`/`stringify` as `Effect`s with typed `MarkdownParseError`/`MarkdownStringifyError` channels, the pure `parseResult`/`stringifyResult` twins for synchronous callers, and the `MarkdownFromString` two-way codec.
237
- - `MarkdownDocument` — source, tree, diagnostics and the link-definition index, plus the derived `headings`, `sections` and `links` accessors, the `find`/`findAll` tree queries over type-narrowed selectors, and the `frontmatter` capture.
267
+ - `MarkdownDocument` — source, tree, diagnostics and the link-definition index, plus the derived `headings`, `sections` and `links` accessors, the `firstSection`/`sectionByHeading` finders over a `DocumentSection` (heading, depth, range, body), the `find`/`findAll` tree queries over type-narrowed selectors, and the `frontmatter` capture with `hasFrontmatterBlock` to tell an absent block from an uncaptured one.
238
268
  - The mdast-shaped node classes — the CommonMark types plus GFM's `delete`, `table`, `tableRow`, `tableCell`, `footnoteDefinition`, `footnoteReference` and task-list `checked`, each carrying unist positions with byte offsets and this package's fidelity fields. `position` defaults to the zero-width synthetic sentinel, so a replacement fragment constructs in one line — `Text.make({ value: "shipped" })`.
239
269
  - `MarkdownEdit` / `MarkdownRange` (with `applyAll`) — the non-mutating text-edit vocabulary, field-identical to `@effected/jsonc`'s, `@effected/yaml`'s and `@effected/toml`'s.
240
270
  - `MarkdownFormat` — `format`/`formatToString` compute conservative marker-normalization edits; `modify`/`modifyToString` replace a node by identity through the canonical stringifier.
241
271
  - `MarkdownVisitor` — walk a parsed tree as a lazy `Stream` of `Enter`/`Exit` events with child-index paths and depth.
242
272
  - `Mdast` — `toMdast` projects to plain mdast JSON; `fromMdast`/`fromMdastResult` admit a foreign mdast tree with validation.
243
- - `MarkdownFrontmatter.schema` plus the `YamlFrontmatter`, `TomlFrontmatter` and `JsonFrontmatter` codecs — typed frontmatter decoding over optional per-format peers, with four separately catchable failure modes.
273
+ - `MarkdownFrontmatter.schema` plus the `YamlFrontmatter`, `TomlFrontmatter` and `JsonFrontmatter` codecs — typed frontmatter decoding over optional per-format peers, with four separately catchable failure modes and a `FrontmatterMissingReason` on the absent one.
244
274
  - `SchemaResolver` — classify a frontmatter `$schema` declaration into `ByUrl`/`ByPath`/`Inline`/`ByName` and resolve names against a registry, with no IO and no dependencies.
245
275
  - `MarkdownDiagnostic` — the structured diagnostic (`code`, `message`, `offset`, `length`, `line`, `character`) every typed error carries, shaped identically to the sibling packages'.
246
276
 
package/index.d.ts CHANGED
@@ -388,6 +388,12 @@ declare const Code_base: Schema.Class<Code, Schema.Struct<{
388
388
  * for fenced blocks and **absent for indented blocks** — their absence is how
389
389
  * the two are told apart on the way back out.
390
390
  *
391
+ * A consequence worth knowing before it surprises: a language-less `Code`
392
+ * node with no explicit `fenceChar` **serializes as an indented block**
393
+ * through `Markdown.stringify`. To get a fence, set `fenceChar` on the node
394
+ * (or a `lang`, which forces one), or format the emitted source with
395
+ * `MarkdownFormattingOptions.codeBlockStyle: "fenced"`.
396
+ *
391
397
  * @public
392
398
  */
393
399
  declare class Code extends Code_base {}
@@ -808,7 +814,9 @@ declare const MarkdownParseOptions_base: Schema.Class<MarkdownParseOptions, Sche
808
814
  * spec-conformant reading must hold unless a consumer opts in. Enabled, the
809
815
  * engine captures `---`/`+++`/`---json` blocks at offset 0 into a raw
810
816
  * `Frontmatter` head node — decoding it is the frontmatter codec modules'
811
- * job.
817
+ * job. Parsed with the toggle off, such a document reports no frontmatter —
818
+ * `MarkdownDocument.hasFrontmatterBlock` tells "no block in the source"
819
+ * apart from "parsed with capture off".
812
820
  *
813
821
  * @public
814
822
  */
@@ -917,10 +925,14 @@ declare class Markdown {
917
925
  * @remarks
918
926
  * Canonical serialization: fidelity fields (marker characters, fence
919
927
  * style, heading spelling) win when present; documented canonical
920
- * defaults apply when absent. The output re-parses to a render-equivalent
921
- * document — the corpus-pinned contract — but is not a byte-level
922
- * round-trip of any original source; surgical editing goes through the
923
- * offset-splice edit layer instead.
928
+ * defaults apply when absent. One default surprises: a language-less
929
+ * `Code` node with no explicit `fenceChar` serializes as an
930
+ * **indented** code block — set `fenceChar` (or a `lang`) on the node for
931
+ * a fence, or normalize afterward with
932
+ * `MarkdownFormattingOptions.codeBlockStyle: "fenced"`. The output
933
+ * re-parses to a render-equivalent document — the corpus-pinned
934
+ * contract — but is not a byte-level round-trip of any original source;
935
+ * surgical editing goes through the offset-splice edit layer instead.
924
936
  *
925
937
  * Failure is rare by design, symmetric with parse: only a hardening-guard
926
938
  * trip on a tree nesting past the depth cap fails, and only synthesized
@@ -1049,14 +1061,88 @@ interface DocumentHeading {
1049
1061
  * `children` include its subsections — the list is flat, in document order,
1050
1062
  * with `depth` carrying the hierarchy.
1051
1063
  *
1064
+ * Instances come from {@link MarkdownDocument.sections} and the two finders;
1065
+ * there is no reason to construct one directly.
1066
+ *
1052
1067
  * @public
1053
1068
  */
1054
- interface DocumentSection {
1069
+ declare class DocumentSection {
1070
+ #private;
1071
+ /** The heading that opens the section. */
1055
1072
  readonly heading: Heading;
1073
+ /** The heading's depth, and the section's. */
1056
1074
  readonly depth: HeadingDepth;
1075
+ /** The whole section including its heading — spliceable by `MarkdownEdit`. */
1057
1076
  readonly range: MarkdownRange;
1077
+ /** The root-level blocks after the heading, subsections included. */
1058
1078
  readonly children: ReadonlyArray<FlowContent>;
1079
+ /**
1080
+ * The heading's plain-text content.
1081
+ *
1082
+ * @remarks
1083
+ * The same derivation as {@link DocumentHeading.text}: text and inline-code
1084
+ * values concatenated, image `alt` where present, a hard break as one space,
1085
+ * nothing for raw HTML or footnote references. So `## **1.2.3**` reads
1086
+ * `1.2.3`.
1087
+ */
1088
+ readonly text: string;
1089
+ /**
1090
+ * The section WITHOUT its heading: from the heading's end offset to the
1091
+ * section's end.
1092
+ *
1093
+ * @remarks
1094
+ * This is the span a "give me the release notes under this version" reader
1095
+ * wants, and the one that is fiddly to derive by hand.
1096
+ */
1097
+ readonly bodyRange: MarkdownRange;
1098
+ constructor(options: {
1099
+ readonly heading: Heading;
1100
+ readonly depth: HeadingDepth;
1101
+ readonly range: MarkdownRange;
1102
+ readonly children: ReadonlyArray<FlowContent>;
1103
+ readonly text: string;
1104
+ readonly bodyRange: MarkdownRange;
1105
+ readonly documentSource: string;
1106
+ });
1107
+ /** The section's source text, heading included. */
1108
+ get source(): string;
1109
+ /**
1110
+ * The section's source text without its heading.
1111
+ *
1112
+ * @remarks
1113
+ * **Untrimmed, deliberately.** It is exactly the bytes {@link
1114
+ * DocumentSection.bodyRange} describes, so the string and the offsets can
1115
+ * never disagree — a package that publishes a span's offsets and then hands
1116
+ * back a different span is lying about it. Callers that want the tidy form
1117
+ * write `.body.trim()`, visibly.
1118
+ */
1119
+ get body(): string;
1120
+ }
1121
+ /**
1122
+ * Narrowing options for {@link MarkdownDocument.firstSection} and
1123
+ * {@link MarkdownDocument.sectionByHeading}.
1124
+ *
1125
+ * @public
1126
+ */
1127
+ interface SectionQueryOptions {
1128
+ /**
1129
+ * Restrict to sections opened by a heading of exactly this depth.
1130
+ *
1131
+ * @remarks
1132
+ * Equality, not a maximum: `{ depth: 2 }` is "the H2 sections", which is the
1133
+ * changelog idiom. A depth *range* is a different question and belongs in a
1134
+ * predicate.
1135
+ */
1136
+ readonly depth?: HeadingDepth;
1059
1137
  }
1138
+ /**
1139
+ * How {@link MarkdownDocument.sectionByHeading} decides a section matches: an
1140
+ * exact trimmed-text string, a `RegExp`, or a predicate over the text and the
1141
+ * section.
1142
+ *
1143
+ * @public
1144
+ */
1145
+ type SectionHeadingMatch = string | RegExp | ((text: string, section: DocumentSection) => boolean);
1060
1146
  /**
1061
1147
  * The node types {@link MarkdownDocument.links} collects: the nodes that
1062
1148
  * carry an outbound URL themselves ({@link Link}, {@link Image},
@@ -1128,9 +1214,32 @@ declare class MarkdownDocument extends MarkdownDocument_base {
1128
1214
  * only ever sit at the head of `root.children` (the capture fires at most
1129
1215
  * once, at offset 0), so the tree is the single source of truth and the
1130
1216
  * accessor can never disagree with it. `undefined` covers both a document
1131
- * with no frontmatter block and one parsed with the capture toggle off.
1217
+ * with no frontmatter block and one parsed with the capture toggle off —
1218
+ * {@link MarkdownDocument.hasFrontmatterBlock} tells the two apart.
1132
1219
  */
1133
1220
  get frontmatter(): Frontmatter | undefined;
1221
+ /**
1222
+ * Whether the source opens with a well-formed frontmatter block —
1223
+ * regardless of how the document was parsed.
1224
+ *
1225
+ * @remarks
1226
+ * The structural signal that disambiguates the `frontmatter` accessor's
1227
+ * `undefined`: capture is opt-in (`MarkdownParseOptions.frontmatter`
1228
+ * defaults off), so a document whose source visibly starts with `---` can
1229
+ * still carry no capture node — spec-correct CommonMark reads the fences as
1230
+ * a thematic break plus content. `frontmatter === undefined` with
1231
+ * `hasFrontmatterBlock === true` means exactly "parsed with capture off";
1232
+ * with `false` it means the source genuinely has no block.
1233
+ *
1234
+ * Derived, not stored: the getter runs the **same** pre-scan the parser
1235
+ * runs when capture is enabled (the closed fence grammar — `---` yaml,
1236
+ * `+++` toml, `---json` json; an unclosed fence is not frontmatter), so it
1237
+ * is `true` exactly when parsing this source with `frontmatter: true`
1238
+ * would produce a capture node, and the two can never drift. Like the
1239
+ * other navigation accessors it recomputes per access — bind it once when
1240
+ * checking repeatedly.
1241
+ */
1242
+ get hasFrontmatterBlock(): boolean;
1134
1243
  /**
1135
1244
  * Every heading in the document, in document order, wherever it sits —
1136
1245
  * including headings nested inside blockquotes and list items.
@@ -1154,6 +1263,38 @@ declare class MarkdownDocument extends MarkdownDocument_base {
1154
1263
  * boundary heading's start, or to the end of the source.
1155
1264
  */
1156
1265
  get sections(): ReadonlyArray<DocumentSection>;
1266
+ /**
1267
+ * The first section in document order, optionally restricted by heading
1268
+ * depth.
1269
+ *
1270
+ * @remarks
1271
+ * `firstSection({ depth: 2 })` is "the newest entry" for a changelog whose
1272
+ * releases are H2s under an H1 title. Like the other navigation accessors
1273
+ * this recomputes {@link MarkdownDocument.sections}; a caller scanning one
1274
+ * document repeatedly should bind `document.sections` once instead.
1275
+ *
1276
+ * @param options - Depth restriction.
1277
+ * @returns The first matching section, or `undefined`.
1278
+ */
1279
+ firstSection(options?: SectionQueryOptions): DocumentSection | undefined;
1280
+ /**
1281
+ * The first section whose heading matches, in document order.
1282
+ *
1283
+ * @remarks
1284
+ * A **string matches the trimmed heading text exactly** — never a substring.
1285
+ * That is a deliberate refusal of the obvious convenience: `"1.2.3"` would
1286
+ * otherwise match a `## 1.2.30` heading, and in a changelog (where `1.2.30`
1287
+ * sorts newer and therefore sits *earlier*) that silently publishes the
1288
+ * wrong release. A `RegExp` is tested against the same trimmed text, and its
1289
+ * `lastIndex` is reset first so a `/g/` pattern cannot skip sections. A
1290
+ * predicate receives the raw {@link DocumentSection.text} plus the section
1291
+ * itself and decides freely.
1292
+ *
1293
+ * @param match - A string, a RegExp, or a predicate.
1294
+ * @param options - Depth restriction, applied before matching.
1295
+ * @returns The first matching section, or `undefined`.
1296
+ */
1297
+ sectionByHeading(match: SectionHeadingMatch, options?: SectionQueryOptions): DocumentSection | undefined;
1157
1298
  /**
1158
1299
  * Every link-bearing node in the document, in document order: links,
1159
1300
  * images, definitions, and the reference forms resolved through the
@@ -1335,18 +1476,46 @@ interface FrontmatterCodec {
1335
1476
  /** Serialize data into a frontmatter body, without fences. */
1336
1477
  readonly encode: (data: unknown) => Effect.Effect<string, FrontmatterEncodeError>;
1337
1478
  }
1338
- declare const FrontmatterMissingError_base: Schema.Class<FrontmatterMissingError, Schema.TaggedStruct<"FrontmatterMissingError", {}>, import("effect/Cause").YieldableError>;
1479
+ /**
1480
+ * Why a frontmatter decoder found no capture on a document.
1481
+ *
1482
+ * @remarks
1483
+ * `"absent"` — the source genuinely has no frontmatter block.
1484
+ * `"captureDisabled"` — the source opens with a well-formed frontmatter
1485
+ * block, but the document carries no capture node: it was parsed without
1486
+ * `frontmatter: true` (the toggle defaults off), so spec-correct CommonMark
1487
+ * read the fences as ordinary content.
1488
+ *
1489
+ * @public
1490
+ */
1491
+ declare const FrontmatterMissingReason: Schema.Literals<readonly ["absent", "captureDisabled"]>;
1492
+ /**
1493
+ * Why a frontmatter decoder found no capture on a document.
1494
+ *
1495
+ * @public
1496
+ */
1497
+ type FrontmatterMissingReason = typeof FrontmatterMissingReason.Type;
1498
+ declare const FrontmatterMissingError_base: Schema.Class<FrontmatterMissingError, Schema.TaggedStruct<"FrontmatterMissingError", {
1499
+ /** Why there is no capture: no block at all, or capture left off. */
1500
+ readonly reason: Schema.Literals<readonly ["absent", "captureDisabled"]>;
1501
+ }>, import("effect/Cause").YieldableError>;
1339
1502
  /**
1340
1503
  * Indicates that a document handed to a frontmatter decoder carries no
1341
1504
  * frontmatter capture.
1342
1505
  *
1343
1506
  * @remarks
1344
- * Raised when the document genuinely has no frontmatter block — including
1345
- * when it has one in the source but was parsed with the capture toggle off
1346
- * (`MarkdownParseOptions.frontmatter` defaults to `false`). Cause-free: there
1347
- * is nothing to diagnose beyond the absence itself. Consumers wanting
1348
- * optional semantics can `Effect.catchTag("FrontmatterMissingError", ...)`
1349
- * to a default.
1507
+ * `reason` distinguishes the two ways this happens: `"absent"` when the
1508
+ * source genuinely has no frontmatter block, `"captureDisabled"` when the
1509
+ * source opens with one but the document was parsed with the capture toggle
1510
+ * off (`MarkdownParseOptions.frontmatter` defaults to `false`) — the fix
1511
+ * there is parsing with `frontmatter: true`, not editing the document. The
1512
+ * discriminant is computed from the same structural signal
1513
+ * `MarkdownDocument.hasFrontmatterBlock` exposes, so the error and the
1514
+ * accessor can never disagree.
1515
+ *
1516
+ * Cause-free: there is nothing to diagnose beyond the absence itself.
1517
+ * Consumers wanting optional semantics can
1518
+ * `Effect.catchTag("FrontmatterMissingError", ...)` to a default.
1350
1519
  *
1351
1520
  * @public
1352
1521
  */
@@ -1410,7 +1579,8 @@ declare class MarkdownFrontmatter {
1410
1579
  * The decoder reads the document's frontmatter capture (parse with
1411
1580
  * `frontmatter: true` — the toggle defaults off), decodes its raw value
1412
1581
  * through the codec, then validates the data against `schema`. Each stage
1413
- * fails typed: no capture is {@link FrontmatterMissingError}, a
1582
+ * fails typed: no capture is {@link FrontmatterMissingError} (its `reason`
1583
+ * says whether the block is genuinely absent or capture was left off), a
1414
1584
  * wrong-format codec is {@link FrontmatterFormatMismatchError}, unparseable
1415
1585
  * content is {@link FrontmatterDecodeError}, and schema-invalid data is
1416
1586
  * {@link FrontmatterValidationError} carrying the structured issue.
@@ -1760,6 +1930,25 @@ type MarkdownRangeLike = MarkdownRange | {
1760
1930
  readonly offset: number;
1761
1931
  readonly length: number;
1762
1932
  };
1933
+ /**
1934
+ * The two ways CommonMark spells a code block: `fenced` (a backtick or tilde
1935
+ * fence) and `indented` (four-space indentation).
1936
+ *
1937
+ * @remarks
1938
+ * A formatting target for {@link MarkdownFormattingOptions}, not a node
1939
+ * fidelity field: on a {@link Code} node the two spellings are told apart by
1940
+ * the presence or absence of `fenceChar`/`fenceLength`, and a language-less
1941
+ * node with neither serializes as an **indented** block by default.
1942
+ *
1943
+ * @public
1944
+ */
1945
+ declare const CodeBlockStyle: Schema.Literals<readonly ["fenced", "indented"]>;
1946
+ /**
1947
+ * The union of all code-block-style string literals.
1948
+ *
1949
+ * @public
1950
+ */
1951
+ type CodeBlockStyle = typeof CodeBlockStyle.Type;
1763
1952
  declare const MarkdownFormattingOptions_base: Schema.Class<MarkdownFormattingOptions, Schema.Struct<{
1764
1953
  readonly dialect: Schema.optionalKey<Schema.Literals<readonly ["commonmark", "gfm"]>>;
1765
1954
  readonly frontmatter: Schema.optionalKey<Schema.Boolean>;
@@ -1768,6 +1957,7 @@ declare const MarkdownFormattingOptions_base: Schema.Class<MarkdownFormattingOpt
1768
1957
  readonly emphasisChar: Schema.optionalKey<Schema.Literals<readonly ["*", "_"]>>;
1769
1958
  readonly fenceChar: Schema.optionalKey<Schema.Literals<readonly ["`", "~"]>>;
1770
1959
  readonly thematicBreakChar: Schema.optionalKey<Schema.Literals<readonly ["-", "_", "*"]>>;
1960
+ readonly codeBlockStyle: Schema.optionalKey<Schema.Literals<readonly ["fenced", "indented"]>>;
1771
1961
  }>, {}>;
1772
1962
  /**
1773
1963
  * Options controlling formatting: which concrete-syntax markers to normalize,
@@ -1777,7 +1967,25 @@ declare const MarkdownFormattingOptions_base: Schema.Class<MarkdownFormattingOpt
1777
1967
  *
1778
1968
  * Day-one scope is marker normalization only — heading style, bullet
1779
1969
  * character, emphasis/strong marker, fence character, thematic-break
1780
- * character. Content is never rewritten, rewrapped or reflowed.
1970
+ * character, code-block style. Content is never rewritten, rewrapped or
1971
+ * reflowed.
1972
+ *
1973
+ * @remarks
1974
+ * `codeBlockStyle` is the opt-in that converts between the two code-block
1975
+ * spellings for **language-less** blocks (a block with a `lang` has no
1976
+ * indented spelling and is never touched). It exists because the default
1977
+ * surprises: absent this option, a language-less code block keeps its
1978
+ * spelling — and a language-less {@link Code} node with no explicit
1979
+ * `fenceChar` **serializes as an indented block** through
1980
+ * `Markdown.stringify`. `"fenced"` rewrites indented blocks to fences (the
1981
+ * fence character follows `fenceChar` when also set, backtick otherwise);
1982
+ * `"indented"` rewrites language-less fences to indented form where provably
1983
+ * safe. Like every other option, hazardous conversions are skipped, never
1984
+ * attempted: both directions apply to root-level, flush-left blocks only,
1985
+ * and the `"indented"` direction additionally skips blocks whose indented
1986
+ * spelling would lazily continue a paragraph, be absorbed into a preceding
1987
+ * list or footnote definition, merge with an adjacent code block, or fail
1988
+ * representability (empty, or blank first/last lines).
1781
1989
  *
1782
1990
  * @public
1783
1991
  */
@@ -1835,12 +2043,18 @@ declare class MarkdownFormat {
1835
2043
  /**
1836
2044
  * Compute marker-normalization edits per the requested
1837
2045
  * {@link MarkdownFormattingOptions}: heading style, bullet character,
1838
- * emphasis/strong marker, fence character and thematic-break character,
1839
- * each only where the node's concrete syntax differs from the target and
1840
- * the rewrite is provably safe. Content is never touched. `range`
1841
- * restricts edits to the nodes intersecting it (the owning-node
1842
- * intersection posture, matching toml). Non-mutating — apply with
1843
- * `MarkdownEdit.applyAll` (or use {@link MarkdownFormat.formatToString}).
2046
+ * emphasis/strong marker, fence character, thematic-break character and
2047
+ * code-block style, each only where the node's concrete syntax differs
2048
+ * from the target and the rewrite is provably safe. Content is never
2049
+ * touched. `range` restricts edits to the nodes intersecting it (the
2050
+ * owning-node intersection posture, matching toml). Non-mutating — apply
2051
+ * with `MarkdownEdit.applyAll` (or use
2052
+ * {@link MarkdownFormat.formatToString}).
2053
+ *
2054
+ * Note the code-block default: absent `codeBlockStyle`, a language-less
2055
+ * code block keeps whichever spelling it has — `fenceChar` alone
2056
+ * normalizes existing fences and deliberately leaves indented blocks
2057
+ * indented.
1844
2058
  */
1845
2059
  static format(text: string, range?: MarkdownRangeLike, options?: MarkdownFormattingOptions): ReadonlyArray<MarkdownEdit>;
1846
2060
  /**
@@ -2186,5 +2400,5 @@ declare const TomlFrontmatter: FrontmatterCodec;
2186
2400
  */
2187
2401
  declare const YamlFrontmatter: FrontmatterCodec;
2188
2402
  //#endregion
2189
- export { Blockquote, Break, BreakStyle, BulletChar, Code, Definition, Delete, type DocumentHeading, type DocumentLink, type DocumentSection, Emphasis, EmphasisChar, FenceChar, FlowContent, FootnoteDefinition, FootnoteReference, Frontmatter, type FrontmatterCodec, FrontmatterContent, FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormat, FrontmatterFormatMismatchError, FrontmatterMissingError, type FrontmatterResolveError, type FrontmatterSchemaError, type FrontmatterSchemaResolver, FrontmatterValidationError, type FrontmatterWriteError, Heading, HeadingDepth, HeadingStyle, Html, Image, ImageReference, InlineCode, JsonFrontmatter, Link, type LinkBearingNode, LinkReference, List, ListContent, ListDelimiter, ListItem, Markdown, MarkdownDiagnostic, MarkdownDialect, MarkdownDocument, MarkdownEdit, MarkdownFormat, MarkdownFormattingOptions, MarkdownFrontmatter, MarkdownModificationError, MarkdownModificationErrorCode, MarkdownNode, type MarkdownNodeOfType, type MarkdownNodeType, MarkdownParseError, MarkdownParseErrorCode, MarkdownParseOptions, type MarkdownPath, MarkdownRange, type MarkdownRangeLike, type MarkdownSegment, MarkdownStringifyError, MarkdownVisitor, MarkdownVisitorEvent, Mdast, MdastDecodeError, type MdastNode, Paragraph, PhrasingContent, Point, Position, ReferenceType, Root, RowContent, SchemaDeclaration, SchemaDeclarationByName, SchemaDeclarationByPath, SchemaDeclarationByUrl, SchemaDeclarationInline, SchemaDeclarationInvalidError, SchemaDeclarationMissingError, SchemaNameUnknownError, SchemaResolver, SchemaVersionUnresolvableError, Strong, Table, TableAlign, TableCell, TableContent, TableRow, Text, ThematicBreak, ThematicBreakChar, TomlFrontmatter, YamlFrontmatter };
2403
+ export { Blockquote, Break, BreakStyle, BulletChar, Code, CodeBlockStyle, Definition, Delete, type DocumentHeading, type DocumentLink, DocumentSection, Emphasis, EmphasisChar, FenceChar, FlowContent, FootnoteDefinition, FootnoteReference, Frontmatter, type FrontmatterCodec, FrontmatterContent, FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormat, FrontmatterFormatMismatchError, FrontmatterMissingError, FrontmatterMissingReason, type FrontmatterResolveError, type FrontmatterSchemaError, type FrontmatterSchemaResolver, FrontmatterValidationError, type FrontmatterWriteError, Heading, HeadingDepth, HeadingStyle, Html, Image, ImageReference, InlineCode, JsonFrontmatter, Link, type LinkBearingNode, LinkReference, List, ListContent, ListDelimiter, ListItem, Markdown, MarkdownDiagnostic, MarkdownDialect, MarkdownDocument, MarkdownEdit, MarkdownFormat, MarkdownFormattingOptions, MarkdownFrontmatter, MarkdownModificationError, MarkdownModificationErrorCode, MarkdownNode, type MarkdownNodeOfType, type MarkdownNodeType, MarkdownParseError, MarkdownParseErrorCode, MarkdownParseOptions, type MarkdownPath, MarkdownRange, type MarkdownRangeLike, type MarkdownSegment, MarkdownStringifyError, MarkdownVisitor, MarkdownVisitorEvent, Mdast, MdastDecodeError, type MdastNode, Paragraph, PhrasingContent, Point, Position, ReferenceType, Root, RowContent, SchemaDeclaration, SchemaDeclarationByName, SchemaDeclarationByPath, SchemaDeclarationByUrl, SchemaDeclarationInline, SchemaDeclarationInvalidError, SchemaDeclarationMissingError, SchemaNameUnknownError, SchemaResolver, SchemaVersionUnresolvableError, type SectionHeadingMatch, type SectionQueryOptions, Strong, Table, TableAlign, TableCell, TableContent, TableRow, Text, ThematicBreak, ThematicBreakChar, TomlFrontmatter, YamlFrontmatter };
2190
2404
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,15 +1,15 @@
1
1
  import { MarkdownEdit, MarkdownRange } from "./MarkdownEdit.js";
2
2
  import { Blockquote, Break, BreakStyle, BulletChar, Code, Definition, Delete, Emphasis, EmphasisChar, FenceChar, FlowContent, FootnoteDefinition, FootnoteReference, Frontmatter, FrontmatterContent, FrontmatterFormat, Heading, HeadingDepth, HeadingStyle, Html, Image, ImageReference, InlineCode, Link, LinkReference, List, ListContent, ListDelimiter, ListItem, MarkdownNode, Paragraph, PhrasingContent, Point, Position, ReferenceType, Root, RowContent, Strong, Table, TableAlign, TableCell, TableContent, TableRow, Text, ThematicBreak, ThematicBreakChar } from "./MarkdownNode.js";
3
- import { FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormatMismatchError, FrontmatterMissingError, FrontmatterValidationError, MarkdownFrontmatter } from "./Frontmatter.js";
3
+ import { FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormatMismatchError, FrontmatterMissingError, FrontmatterMissingReason, FrontmatterValidationError, MarkdownFrontmatter } from "./Frontmatter.js";
4
4
  import { SchemaDeclaration, SchemaDeclarationByName, SchemaDeclarationByPath, SchemaDeclarationByUrl, SchemaDeclarationInline, SchemaDeclarationInvalidError, SchemaDeclarationMissingError, SchemaNameUnknownError, SchemaResolver, SchemaVersionUnresolvableError } from "./FrontmatterResolver.js";
5
5
  import { JsonFrontmatter } from "./JsonFrontmatter.js";
6
6
  import { MarkdownDiagnostic, MarkdownParseErrorCode } from "./MarkdownDiagnostic.js";
7
7
  import { Markdown, MarkdownDialect, MarkdownParseError, MarkdownParseOptions, MarkdownStringifyError } from "./Markdown.js";
8
- import { MarkdownDocument } from "./MarkdownDocument.js";
9
- import { MarkdownFormat, MarkdownFormattingOptions, MarkdownModificationError, MarkdownModificationErrorCode } from "./MarkdownFormat.js";
8
+ import { DocumentSection, MarkdownDocument } from "./MarkdownDocument.js";
9
+ import { CodeBlockStyle, MarkdownFormat, MarkdownFormattingOptions, MarkdownModificationError, MarkdownModificationErrorCode } from "./MarkdownFormat.js";
10
10
  import { MarkdownVisitor, MarkdownVisitorEvent } from "./MarkdownVisitor.js";
11
11
  import { Mdast, MdastDecodeError } from "./Mdast.js";
12
12
  import { TomlFrontmatter } from "./TomlFrontmatter.js";
13
13
  import { YamlFrontmatter } from "./YamlFrontmatter.js";
14
14
 
15
- export { Blockquote, Break, BreakStyle, BulletChar, Code, Definition, Delete, Emphasis, EmphasisChar, FenceChar, FlowContent, FootnoteDefinition, FootnoteReference, Frontmatter, FrontmatterContent, FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormat, FrontmatterFormatMismatchError, FrontmatterMissingError, FrontmatterValidationError, Heading, HeadingDepth, HeadingStyle, Html, Image, ImageReference, InlineCode, JsonFrontmatter, Link, LinkReference, List, ListContent, ListDelimiter, ListItem, Markdown, MarkdownDiagnostic, MarkdownDialect, MarkdownDocument, MarkdownEdit, MarkdownFormat, MarkdownFormattingOptions, MarkdownFrontmatter, MarkdownModificationError, MarkdownModificationErrorCode, MarkdownNode, MarkdownParseError, MarkdownParseErrorCode, MarkdownParseOptions, MarkdownRange, MarkdownStringifyError, MarkdownVisitor, MarkdownVisitorEvent, Mdast, MdastDecodeError, Paragraph, PhrasingContent, Point, Position, ReferenceType, Root, RowContent, SchemaDeclaration, SchemaDeclarationByName, SchemaDeclarationByPath, SchemaDeclarationByUrl, SchemaDeclarationInline, SchemaDeclarationInvalidError, SchemaDeclarationMissingError, SchemaNameUnknownError, SchemaResolver, SchemaVersionUnresolvableError, Strong, Table, TableAlign, TableCell, TableContent, TableRow, Text, ThematicBreak, ThematicBreakChar, TomlFrontmatter, YamlFrontmatter };
15
+ export { Blockquote, Break, BreakStyle, BulletChar, Code, CodeBlockStyle, Definition, Delete, DocumentSection, Emphasis, EmphasisChar, FenceChar, FlowContent, FootnoteDefinition, FootnoteReference, Frontmatter, FrontmatterContent, FrontmatterDecodeError, FrontmatterEncodeError, FrontmatterFormat, FrontmatterFormatMismatchError, FrontmatterMissingError, FrontmatterMissingReason, FrontmatterValidationError, Heading, HeadingDepth, HeadingStyle, Html, Image, ImageReference, InlineCode, JsonFrontmatter, Link, LinkReference, List, ListContent, ListDelimiter, ListItem, Markdown, MarkdownDiagnostic, MarkdownDialect, MarkdownDocument, MarkdownEdit, MarkdownFormat, MarkdownFormattingOptions, MarkdownFrontmatter, MarkdownModificationError, MarkdownModificationErrorCode, MarkdownNode, MarkdownParseError, MarkdownParseErrorCode, MarkdownParseOptions, MarkdownRange, MarkdownStringifyError, MarkdownVisitor, MarkdownVisitorEvent, Mdast, MdastDecodeError, Paragraph, PhrasingContent, Point, Position, ReferenceType, Root, RowContent, SchemaDeclaration, SchemaDeclarationByName, SchemaDeclarationByPath, SchemaDeclarationByUrl, SchemaDeclarationInline, SchemaDeclarationInvalidError, SchemaDeclarationMissingError, SchemaNameUnknownError, SchemaResolver, SchemaVersionUnresolvableError, Strong, Table, TableAlign, TableCell, TableContent, TableRow, Text, ThematicBreak, ThematicBreakChar, TomlFrontmatter, YamlFrontmatter };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effected/markdown",
3
- "version": "0.2.5",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "CommonMark and GFM markdown parse, edit and transform schemas for Effect",
6
6
  "keywords": [