Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
3.2 KiB
Crate: crates/markdown (notescraft-markdown, layer L0). SPEC sections: 4.3, 5, 5.1. Allowed new dependencies: pulldown-cmark, thiserror ({ workspace = true }). No serde needed.
Design (mandatory, it guarantees losslessness): the document keeps the ORIGINAL source string and a list of byte-range items found in it. write is the source itself; edits are done by Document::replace_range(range, text) -> String which returns new source. Never reconstruct text from a tree.
Implement:
parse(src: &str) -> Document.Document { source, frontmatter: Option<Frontmatter>, items: Vec<Item> }whereItem { kind: ItemKind, range: Range<usize> }and ItemKind covers: Heading{level,text,id anchor}, WikiLink{target,subpath,alias}, Embed{target,subpath,size}, Link{text,dest,internal}, Tag{name}, BlockId{id}, Highlight, Comment, Callout{kind,title,fold: None|Open|Collapsed}, Math{display}, TaskItem{checked}. Use pulldown-cmark (offset iterator, GFM tables/tasks/strikethrough/footnotes enabled) for CommonMark structure and your own scanner for Obsidian extensions. Extensions are NOT recognised inside code spans, code fences, math or comments. Ranges must always land on char boundaries.- Frontmatter:
---block at byte 0 only. Own minimal YAML-subset parser (no yaml crate): scalars, booleans, numbers, dates kept as strings with a type hint, block and inline lists, quoted strings; anything else is kept verbatim asValue::Raw.Frontmatter::get(key),keys(), andedit_key(source: &str, key, new_value) -> String/remove_keywhich change ONLY the lines of that key and preserve every other byte (comments, order, unknown YAML). - Link helpers:
parse_link_target("folder/Note#Heading|alias"), heading anchor normalisation per SPEC (strip markdown, collapse whitespace, case-insensitive compare helperanchors_match), Markdown-link dest URL-decoding and<angle bracket>dests. - Callouts: all 13 types plus aliases from SPEC 5.1 as
CalloutKind::from_str-> canonical kind (Unknown(String)for others), fold markers+/-, nested callouts. - Tag grammar:
#+ letters/digits/_/-// with at least one non-digit, not preceded by a word char; plus frontmattertags/tag(string or list) viaDocument::all_tags(). - Block IDs:
^id([A-Za-z0-9-]+) at end of a paragraph/list item line. - Public helpers:
Document::wikilinks(),headings(),tags(),embeds(),callouts(),block_ids()iterators. - Robustness: documents up to 10 MiB parse in linear time; nesting depth capped (e.g. 64 levels of blockquote/list) without panic; invalid UTF-8 is not a concern (input is &str).
Tests: byte-exact round trip (replace_range of an empty range returns source unchanged; parse never mutates) on a fixture corpus you create in tests/fixtures (at least 12 realistic notes: nested callouts, tables, code fences containing x and #tag, math, comments, CRLF files, no trailing newline, frontmatter with comments/odd YAML, unicode, emoji, a 1 MB generated file) AND a proptest-free property test using a small deterministic generator (no new dependency) of 2000 random strings from an adversarial alphabet ([ ] ! # ^ = % $ > - * \ ``` ` and letters) asserting no panic and that every item range is valid and on char boundaries and in-bounds. Plus per-construct unit tests.