Skip to content

Commit cdbeecc

Browse files
Unify frontmatter under one AST node. (#25)
1 parent 2aeac1d commit cdbeecc

18 files changed

Lines changed: 200 additions & 273 deletions

‎context/markdown-architecture.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ The Markdown serializer lives in `src/markdown/`. Keep its construct handlers an
1919

2020
Ordinary `Code` nodes retain their opening fence in `fence: Option<CodeFence>` independently of source positions. Indented code and manually constructed or older deserialized code without metadata use `None`. Fence length describes the opening sequence even if the closer is longer or absent. Indentation is measured in columns relative to the container, excluding list and blockquote prefixes; tabs, including partially consumed container tabs, contribute their expanded width. Markdown serialization continues to choose ordinary code formatting from `MarkdownOptions`, so this metadata describes the source rather than the rendered output.
2121

22-
The Rust helper also exposes frontmatter fences: generic nodes use their existing marker and opening length; legacy YAML/TOML use their fixed three-character, unindented delimiter. Inline code and other nodes return `None`. Keep fence information separate from language and info strings.
22+
The Rust helper also exposes frontmatter's stored marker and opening length, with zero indentation. Inline code and other nodes return `None`. Keep fence information separate from language and info strings.
2323

2424
## Frontmatter
2525

26-
`Constructs::frontmatter` recognizes closed fences only at the beginning of a document, outside containers and without opening indentation. Untagged `---` and `+++` retain the inherited `Yaml` and `Toml` nodes. Tagged delimiter forms (including `---yaml`) and language-tagged backtick or tilde fences use `Frontmatter`.
26+
`Constructs::frontmatter` recognizes closed fences only at the beginning of a document, outside containers and without opening indentation. Every form produces `Node::Frontmatter`: untagged `---` and `+++`, tagged delimiter forms (including `---yaml`), and language-tagged backtick or tilde fences.
2727

28-
The generic node follows Markly/cmarkly's format-agnostic model: `info` is the entire opaque info string, trimmed only at its outer spaces and tabs; `value` is the raw body, including its trailing line ending. Neither is decoded or interpreted. `language()` and `Node::code_language()` return the first word, while `Node::code_info()` returns the full info string. Opening and closing fence lengths are retained. Closing code fences follow the usual marker, minimum length, and indentation rules; the Rust extension requires a closing fence for every form.
28+
The node follows Markly/cmarkly's format-agnostic model: `info` is the entire opaque info string, trimmed only at its outer spaces and tabs; `value` is the raw body, including its trailing line ending. `language()` and `Node::code_language()` return the first word of the info string, defaulting to `yaml` for untagged dash delimiters and `toml` for untagged plus delimiters. `Node::code_info()` returns the authored info string, which is empty when the language is implicit. Callers decode the body using the appropriate language parser. Opening and closing fence lengths are retained. Closing code fences follow the usual marker, minimum length, and indentation rules; the Rust extension requires a closing fence for every form.
2929

30-
Both HTML paths omit frontmatter. Markdown serialization preserves info and body while normalizing fence spacing. It lengthens code fences when edited content could close them, and switches conflicting dash/plus delimiters to tildes. Edited bodies without a trailing line ending gain one before the closing fence. Keep these safety checks in the Markdown handler, separate from the public AST type.
30+
Both HTML paths omit frontmatter. All frontmatter uses one Markdown serialization handler, which preserves info and body while normalizing fence spacing. It lengthens code fences when edited content could close them, and switches conflicting dash/plus delimiters to tildes, making an implicit language explicit in the replacement fence. Edited bodies without a trailing line ending gain one before the closing fence. Keep these safety checks in the Markdown handler, separate from the public AST type.
3131

3232
## Tests and generated data
3333

‎readme.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,9 @@ if let Node::Frontmatter(frontmatter) = &document.children().unwrap()[0] {
110110
}
111111
````
112112

113-
Tagged forms such as `--- json` and `---yaml` use the same node. Untagged `---` and `+++` keep their existing `Yaml` and `Toml` nodes. Fences without a language, fences later in a document, and unclosed fences remain ordinary Markdown.
113+
All frontmatter uses `Node::Frontmatter`. Untagged `---` and `+++` imply YAML and TOML respectively, with an empty `info` string. Tagged forms such as `--- json` and `---yaml` supply an explicit language hint. `language()` and `Node::code_language()` return the effective language; `Node::code_info()` returns the authored info string. Backtick/tilde fences without a language, fences later in a document, and unclosed fences remain ordinary Markdown.
114114

115-
Markdown serialization retains the stored info string and body while normalizing spacing around the info string. It can lengthen fences or change their marker when edited content would close them prematurely, and adds a line ending before the closing fence when an edited body needs one. Round trips preserve content and metadata while allowing these formatting adjustments.
115+
Markdown serialization retains the stored info string and body while normalizing spacing around the info string. It can lengthen fences or change their marker when edited content would close them prematurely, and adds a line ending before the closing fence when an edited body needs one. When changing an untagged dash/plus delimiter to a code fence, it writes the implied language explicitly. Round trips preserve the body and effective language while allowing these formatting adjustments.
116116

117117
## Releasing
118118

‎releases.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
## Unreleased
44

5+
- Unify all frontmatter under `Node::Frontmatter`, replacing `Node::Yaml`, `Node::Toml`, and their structs. Update Rust matches and struct literals; serialized ASTs now use `type: "frontmatter"` with info and fence fields.
6+
- Return YAML/TOML defaults from `Frontmatter::language()` and `Node::code_language()` for untagged dash/plus delimiters, while keeping the authored info string empty.
7+
- Retain the trailing body line ending for untagged YAML/TOML, matching tagged frontmatter, and serialize all forms through the same handler. Edited bodies that require a different fence retain their effective language.
58
- Clarify frontmatter placement and serialization, and provide runnable AST renderer examples.
69

710
## v0.6.0

‎src/configuration.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -114,10 +114,11 @@ pub struct Constructs {
114114
/// Frontmatter with an unindented opening fence on the first document line,
115115
/// outside list items and blockquotes, and a matching closing fence.
116116
///
117-
/// Untagged `---` and `+++` produce YAML and TOML nodes. A format hint after
118-
/// either delimiter, or a language-tagged backtick/tilde code fence, produces
119-
/// a language-agnostic [`Frontmatter`][crate::mdast::Frontmatter] node. The
120-
/// parser preserves its raw body and full info string; HTML omits it.
117+
/// All forms produce a [`Frontmatter`][crate::mdast::Frontmatter] node.
118+
/// Untagged `---` and `+++` imply YAML and TOML. A format hint after either
119+
/// delimiter, or a language-tagged backtick/tilde code fence, supplies an
120+
/// explicit language. The parser preserves the raw body and authored info
121+
/// string; HTML omits the frontmatter.
121122
///
122123
/// ````markdown
123124
/// > | ---

‎src/construct/frontmatter.rs‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,11 @@
2525
//!
2626
//! Frontmatter can only occur once and requires a closing fence followed by a
2727
//! line ending or end of file. Untagged code fences remain ordinary code blocks.
28-
//! Untagged `---` and `+++` retain their YAML and TOML AST nodes. Tagged forms,
29-
//! such as `--- json`, `---yaml`, `+++ toml`, or a fenced code block with a
30-
//! language, produce a format-agnostic [`Frontmatter`][crate::mdast::Frontmatter].
31-
//! The parser preserves the opaque info string and raw body without interpreting
32-
//! or decoding them, following Socketry's Markly/cmarkly frontmatter model.
28+
//! All forms produce a format-agnostic [`Frontmatter`][crate::mdast::Frontmatter].
29+
//! Untagged `---` and `+++` imply YAML and TOML. Tagged forms, such as `--- json`,
30+
//! `---yaml`, `+++ toml`, or a fenced code block with a language, supply an
31+
//! explicit format hint. The parser preserves the opaque info string and raw
32+
//! body, following Socketry's Markly/cmarkly frontmatter model.
3333
//!
3434
//! ## Extension
3535
//!

‎src/markdown/handle/frontmatter.rs‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,22 @@ impl Handle for Frontmatter {
2424
_parent: Option<&Node>,
2525
_node: &Node,
2626
) -> Result<String, Message> {
27+
// Validate the fence and the authored format hint.
28+
let mut info = self.info.trim_matches([' ', '\t']);
2729
if !matches!(self.fence, '`' | '~' | '-' | '+')
28-
|| self.info.trim_matches([' ', '\t']).is_empty()
30+
|| (matches!(self.fence, '`' | '~') && info.is_empty())
2931
|| self.info.contains(['\r', '\n'])
3032
|| (self.fence == '`' && self.info.contains('`'))
3133
{
3234
return Err(Message {
3335
place: None,
34-
reason:
35-
"Frontmatter requires a valid fence and a nonempty, single-line info string"
36-
.into(),
36+
reason: "Frontmatter requires a valid fence and a single-line info string; code fences require a language hint".into(),
3737
rule_id: Box::new("invalid-frontmatter".into()),
3838
source: Box::new("mdast-util-to-markdown".into()),
3939
});
4040
}
41+
42+
// Choose fences that cannot be closed by the edited body.
4143
let mut marker = self.fence;
4244
if matches!(marker, '-' | '+') {
4345
let delimiter = marker.to_string().repeat(3);
@@ -58,12 +60,21 @@ impl Handle for Frontmatter {
5860
};
5961
let opening = marker.to_string().repeat(opening_length);
6062
let closing = marker.to_string().repeat(closing_length);
61-
let info = self.info.trim_matches([' ', '\t']);
62-
let separator = if matches!(marker, '-' | '+') || info.starts_with(marker) {
63-
" "
64-
} else {
65-
""
66-
};
63+
64+
// Carry the implicit language into a replacement code fence.
65+
if marker != self.fence && self.info.split_ascii_whitespace().next().is_none() {
66+
info = self
67+
.language()
68+
.expect("dash and plus fences imply a language");
69+
}
70+
71+
// Render the authored body between its opening and closing fences.
72+
let separator =
73+
if !info.is_empty() && (matches!(marker, '-' | '+') || info.starts_with(marker)) {
74+
" "
75+
} else {
76+
""
77+
};
6778
let mut output = format!("{opening}{separator}{}\n", info);
6879
output.push_str(&self.value);
6980
if !self.value.is_empty() && !self.value.ends_with(['\r', '\n']) {

‎src/markdown/state.rs‎

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -370,9 +370,7 @@ impl<'a> State<'a> {
370370
Node::Table(table) => self.render_table(table),
371371
Node::TableRow(row) => self.render_table_row(&row.children),
372372
Node::TableCell(_) => self.container_phrasing(node, info),
373-
Node::Toml(toml) => Ok(render_frontmatter("+++", &toml.value)),
374373
Node::Frontmatter(frontmatter) => frontmatter.handle(self, info, parent, node),
375-
Node::Yaml(yaml) => Ok(render_frontmatter("---", &yaml.value)),
376374
}
377375
}
378376

@@ -700,16 +698,6 @@ pub(crate) fn is_phrasing(node: &Node) -> bool {
700698
)
701699
}
702700

703-
fn render_frontmatter(marker: &str, value: &str) -> String {
704-
let mut output = format!("{}\n", marker);
705-
output.push_str(value);
706-
if !value.ends_with('\n') && !value.ends_with('\r') {
707-
output.push('\n');
708-
}
709-
output.push_str(marker);
710-
output
711-
}
712-
713701
fn render_jsx_attribute(attribute: &AttributeContent) -> String {
714702
match attribute {
715703
AttributeContent::Expression(expression) => format!("{{{}}}", expression.value),

‎src/markdown/state/tests.rs‎

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Released under the MIT License.
22
// Copyright, 2026, by Samuel Williams.
33

4-
use super::{escape_table_pipes, has_matching_ticks, render_frontmatter, Info, Join, State};
4+
use super::{escape_table_pipes, has_matching_ticks, Info, Join, State};
55
use crate::{
66
markdown::{r#unsafe::Unsafe, util::safe::SafeConfig, Options},
77
mdast::{
@@ -223,12 +223,6 @@ fn detects_matching_and_nonmatching_code_fences() {
223223
assert!(!has_matching_ticks(b"``", 0, 1));
224224
}
225225

226-
#[test]
227-
fn preserves_existing_line_endings_in_frontmatter() {
228-
assert_eq!(render_frontmatter("---", "title\n"), "---\ntitle\n---");
229-
assert_eq!(render_frontmatter("+++", "title\r"), "+++\ntitle\r+++");
230-
}
231-
232226
#[test]
233227
fn joins_flow_nodes_with_each_supported_separator() {
234228
let cases = [

‎src/markdown/util/format_heading_as_setext.rs‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,7 @@ fn include_literal_with_line_break(node: &Node, regex: &Regex) -> bool {
4040
Node::MdxTextExpression(x) => regex.is_match(&x.value),
4141
Node::MdxjsEsm(x) => regex.is_match(&x.value),
4242
Node::Text(x) => regex.is_match(&x.value),
43-
Node::Toml(x) => regex.is_match(&x.value),
4443
Node::Frontmatter(x) => regex.is_match(&x.value),
45-
Node::Yaml(x) => regex.is_match(&x.value),
4644
// Anything else.
4745
_ => {
4846
if let Some(children) = node.children() {
@@ -70,7 +68,7 @@ mod tests {
7068
markdown::{state::State, Options},
7169
mdast::{
7270
Break, Code, Frontmatter, Heading, Html, Image, InlineCode, InlineMath, Math,
73-
MdxFlowExpression, MdxTextExpression, MdxjsEsm, Node, Text, Toml, Yaml,
71+
MdxFlowExpression, MdxTextExpression, MdxjsEsm, Node, Text,
7472
},
7573
};
7674
use alloc::string::String;
@@ -155,14 +153,6 @@ mod tests {
155153
closing_fence_length: 3,
156154
position: None,
157155
}),
158-
Node::Toml(Toml {
159-
value: literal("a\nb"),
160-
position: None,
161-
}),
162-
Node::Yaml(Yaml {
163-
value: literal("a\nb"),
164-
position: None,
165-
}),
166156
] {
167157
assert!(
168158
format_heading_as_setext(&heading(1, child.clone()), &state),

0 commit comments

Comments
 (0)