Initial project import

This commit is contained in:
Agila Forge Importer committed 2026-07-13 15:20:32 +00:00
commit 23f2d29e32
11957 files changed
+1754577

No files matched your search

+304
View File
@@ -0,0 +1,304 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
# vlib/x/markdown - Markdown Parser and Renderers
A CommonMark-compliant Markdown parser for V, with HTML and console-friendly
plain-text renderers, plus support for GitHub Flavored Markdown (GFM)
extensions. Designed for feature parity with
[github.com/yuin/goldmark](https://github.com/yuin/goldmark).
## Features
### CommonMark Support
- **Block-level elements**: headings (ATX and setext), paragraphs, blockquotes,
lists (bullet and ordered), code blocks (indented and fenced), HTML blocks,
thematic breaks
- **Inline elements**: emphasis (*em* and **strong**), code spans, links
(inline and reference), images, autolinks, hard/soft line breaks, HTML
entities, raw HTML
- **Link reference definitions** for DRY Markdown
### GFM Extensions (via `.gfm()` helper or individual extensions)
- **Tables**: `| col | col |` with alignment (`:--`, `:--:`, `--:`)
- **Strikethrough**: `~~text~~`
- **Task lists**: `- [ ] todo` and `- [x] done`
- **Linkify**: bare URLs become links
### Additional Extensions
- **Footnotes**: `[^1]` references and `[^1]: footnote text` definitions
- **Typographer**: smart punctuation (`--` → en-dash, `---` → em-dash,
`...` → ellipsis, smart quotes)
- **Auto-heading IDs**: automatic `id` attributes on headings from text content
- **Definition lists**: Pandoc-style (requires extension)
## Quick Start
### Basic Usage
```v
import x.markdown
fn main() {
html := markdown.to_html('# Hello\n\nWorld')
text := markdown.to_plaintext('# Hello\n\nWorld')
println(html)
println(text)
}
```
### With Extensions
```v oksyntax
import x.markdown
mut md := markdown.Markdown.new(markdown.Options{
extensions: markdown.gfm()
})
html := md.convert('| Name |\n|------|\n| Alice |')
println(html) // Renders as HTML table
```
### Fine-Grained Configuration
```v
import x.markdown
fn main() {
mut md := markdown.Markdown.new(markdown.Options{
extensions: [markdown.Extension(markdown.footnote()), markdown.typographer()]
parser_opts: markdown.ParserOptions{
auto_heading_id: true
}
renderer_opts: markdown.RendererOptions{
unsafe_: true
xhtml: true
}
})
source := '# Title'
html := md.convert(source)
println(html)
}
```
### Parse to AST and Walk
```v
import x.markdown
fn main() {
mut md := markdown.Markdown.new(markdown.Options{})
source := '# Hello\n\n`x`'
doc := md.parse(source)
doc.walk(fn (node &markdown.Node) bool {
match node.kind {
.heading {
println('Heading level ${node.level}')
}
.code_span {
println('Code: ${node.literal}')
}
else {}
}
return true
})
}
```
## API Overview
### Top-Level Functions
- `to_html(src: string) string` - Convert Markdown to HTML with default settings
- `to_html(src: string, opts: Options) string` - Convert with custom options
- `to_plaintext(src: string) string` - Convert Markdown to UTF-8 plain text
- `to_plaintext(src: string, opts: Options) string` - Convert plain text with options
- `parse_inline(src: string, opts: Options, ref_map: map) []&Node` - Parse inline content only
### Main Structs
#### `Markdown`
The main processor. Create with `Markdown.new()`, reuse across multiple calls
to share link references.
Methods:
- `convert(src: string) string` - Parse and render to HTML in one call
- `convert_plaintext(src: string) string` - Parse and render to plain text
- `parse(src: string) &Node` - Parse to AST only
#### `Options` (@[params])
```v oksyntax
pub struct Options {
pub mut:
extensions []Extension
parser_opts ParserOptions
renderer_opts RendererOptions
// Extension feature flags (set by extensions)
tables bool
strikethrough bool
linkify bool
task_list bool
footnotes bool
typographer bool
definition_list bool
}
```
#### `ParserOptions` (@[params])
```v oksyntax
pub struct ParserOptions {
pub mut:
auto_heading_id bool // Generate id from heading text
}
```
#### `RendererOptions` (@[params])
```v oksyntax
pub struct RendererOptions {
pub mut:
unsafe_ bool // Allow raw HTML (default: false)
hard_wraps bool // Convert all \n to <br> (default: false)
xhtml bool // Output XHTML self-closing tags (default: false)
}
```
#### `Node`
An AST node. Navigate with `.children`, inspect with `.kind`, `.literal`, `.level`, etc.
Methods:
- `text_content() string` - Extract plain text from this node and descendants
- `walk(f: fn(&Node) bool) bool` - Traverse AST pre-order; return false from callback to stop
### Extensions
Available as functions returning extension structs:
- `table()` - GFM tables
- `strikethrough()` - GFM strikethrough
- `linkify()` - Bare URL autolinks
- `task_list()` - GFM task lists
- `footnote()` - Footnote references and definitions
- `typographer()` - Smart punctuation
- `definition_list()` - Pandoc-style definition lists
- `gfm()` - Convenience helper returning `[table(), strikethrough(), linkify(), task_list()]`
## Examples
### Simple Emphasis
```v oksyntax
assert markdown.to_html('*em*').contains('<em>em</em>')
assert markdown.to_html('**strong**').contains('<strong>strong</strong>')
```
### Links and Images
```v oksyntax
// Inline link
html := markdown.to_html('[click](https://example.com)')
// Reference link
html = markdown.to_html('[click][ref]\n\n[ref]: https://example.com')
// Image
html = markdown.to_html('![alt](image.png "title")')
```
### Code Blocks
```v oksyntax
// Indented code
html := markdown.to_html(' code')
// Fenced code
html = markdown.to_html('```v\nfn main() {}\n```')
```
### Lists
```v oksyntax
import x.markdown
// Bullet list
html := markdown.to_html('- item 1\n- item 2')
// Ordered list
html = markdown.to_html('1. first\n2. second')
// Task list (enable via extension or task_list option)
html = markdown.to_html('- [x] done', markdown.Options{ task_list: true })
```
### Tables (GFM)
```v oksyntax
import x.markdown
src := '| Left | Center | Right |\n|:--|:--:|--:|\n| A | B | C |'
html := markdown.to_html(src, markdown.Options{ tables: true })
```
### Footnotes
```v oksyntax
import x.markdown
src := 'Text[^1]\n\n[^1]: Footnote body.'
html := markdown.to_html(src, markdown.Options{ footnotes: true })
// Renders with <sup> reference and footnote section at bottom
```
## Design Notes
### Block Parsing
- Reads source line-by-line, building a block-level AST
- Handles lazy continuation lines for blockquotes and lists
- Collects link reference definitions for inline resolution
### Inline Parsing
- Parses raw text from paragraph/heading/cell nodes using a simple state machine
- Emphasis/strong uses a delimiter-run resolution pass aligned with CommonMark rules
- Backticks, brackets, and HTML are handled specially
### Rendering
- Tree walk via `render_node()` dispatch on `NodeKind`
- Inline nodes parsed on-demand during rendering
- Link references cached in `Markdown` for reuse across multiple convert calls
## Limitations and Known Issues
- Definition list syntax is Pandoc-style; CommonMark does not define this
**Status**: All core features (headings, emphasis, links, code, lists,
blockquotes, task lists, tables, HTML escaping) work reliably without crashes.
## Testing
Run the test suite:
```bash
v -silent test vlib/x/markdown/markdown_test.v
```
Or write your own:
```v oksyntax
import x.markdown
fn test_my_markdown() {
html := markdown.to_html('# Test')
assert html == '<h1>Test</h1>\n'
}
```
## Contributing
- Follow V style guidelines (use `v fmt -w` on edits)
- Add tests for new features
- Update documentation for public API changes
- Keep CommonMark compliance as the baseline
## License
MIT, same as V.
## References
- [CommonMark Specification](https://spec.commonmark.org/)
- [GitHub Flavored Markdown](https://github.github.com/gfm/)
- [goldmark (Go implementation)](https://github.com/yuin/goldmark)
+115
View File
@@ -0,0 +1,115 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module markdown
// Extension is the interface implemented by markdown extensions.
// An extension configures the Markdown processor by enabling parser and
// renderer features.
pub interface Extension {
// extend is called once when the extension is registered with a Markdown processor.
extend(mut m Markdown)
}
// TableExt adds GitHub Flavored Markdown table support (| col | col |).
pub struct TableExt {}
// extend implements Extension for TableExt.
pub fn (_ TableExt) extend(mut m Markdown) {
m.opts.tables = true
}
// StrikethroughExt adds GFM strikethrough support (~~text~~).
pub struct StrikethroughExt {}
// extend implements Extension for StrikethroughExt.
pub fn (_ StrikethroughExt) extend(mut m Markdown) {
m.opts.strikethrough = true
}
// LinkifyExt adds autolink support for bare URLs and email addresses.
pub struct LinkifyExt {}
// extend implements Extension for LinkifyExt.
pub fn (_ LinkifyExt) extend(mut m Markdown) {
m.opts.linkify = true
}
// TaskListExt adds GFM task list item support (- [ ] / - [x]).
pub struct TaskListExt {}
// extend implements Extension for TaskListExt.
pub fn (_ TaskListExt) extend(mut m Markdown) {
m.opts.task_list = true
}
// FootnoteExt adds footnote support ([^label] references and [^label]: definitions).
pub struct FootnoteExt {}
// extend implements Extension for FootnoteExt.
pub fn (_ FootnoteExt) extend(mut m Markdown) {
m.opts.footnotes = true
}
// TypographerExt replaces ASCII punctuation sequences with Unicode typographic
// equivalents: -- en dash, --- em dash, ... ellipsis, and smart quotes.
pub struct TypographerExt {}
// extend implements Extension for TypographerExt.
pub fn (_ TypographerExt) extend(mut m Markdown) {
m.opts.typographer = true
}
// DefinitionListExt adds Pandoc-style definition list support.
pub struct DefinitionListExt {}
// extend implements Extension for DefinitionListExt.
pub fn (_ DefinitionListExt) extend(mut m Markdown) {
m.opts.definition_list = true
}
// table returns a TableExt extension value.
pub fn table() TableExt {
return TableExt{}
}
// strikethrough returns a StrikethroughExt extension value.
pub fn strikethrough() StrikethroughExt {
return StrikethroughExt{}
}
// linkify returns a LinkifyExt extension value.
pub fn linkify() LinkifyExt {
return LinkifyExt{}
}
// task_list returns a TaskListExt extension value.
pub fn task_list() TaskListExt {
return TaskListExt{}
}
// footnote returns a FootnoteExt extension value.
pub fn footnote() FootnoteExt {
return FootnoteExt{}
}
// typographer returns a TypographerExt extension value.
pub fn typographer() TypographerExt {
return TypographerExt{}
}
// definition_list returns a DefinitionListExt extension value.
pub fn definition_list() DefinitionListExt {
return DefinitionListExt{}
}
// gfm returns the core GitHub Flavored Markdown extensions:
// TableExt, StrikethroughExt, LinkifyExt, and TaskListExt.
pub fn gfm() []Extension {
return [
Extension(TableExt{}),
StrikethroughExt{},
LinkifyExt{},
TaskListExt{},
]
}
+394
View File
@@ -0,0 +1,394 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module markdown
import strings
// HTMLRenderer renders a parsed markdown AST to an HTML string.
struct HTMLRenderer {
opts Options
ref_map map[string]LinkRef
mut:
sb strings.Builder
// footnote tracking
fn_order []string // ordered list of encountered fn labels
fn_nodes map[string]&Node // label → footnote_def node
tight_list bool // whether we're inside a tight list
in_table_head bool
}
// render renders the document node to an HTML string.
pub fn (mut r HTMLRenderer) render(doc &Node) string {
r.sb = strings.new_builder(1024)
// Pre-collect footnote definitions if extension is enabled.
if r.opts.footnotes {
for child in doc.children {
if child.kind == .footnote_def {
r.fn_nodes[child.fn_label] = child
}
}
}
r.render_children(doc)
// Append footnotes section if any refs were used.
if r.opts.footnotes && r.fn_order.len > 0 {
r.render_footnotes_section()
}
return r.sb.str()
}
// render_node dispatches rendering to the appropriate method.
fn (mut r HTMLRenderer) render_node(node &Node) {
match node.kind {
.document { r.render_children(node) }
.heading { r.render_heading(node) }
.paragraph { r.render_paragraph(node) }
.blockquote { r.render_blockquote(node) }
.list { r.render_list(node) }
.list_item { r.render_list_item(node) }
.code_block { r.render_code_block(node) }
.fenced_code { r.render_fenced_code(node) }
.thematic_break { r.render_thematic_break() }
.html_block { r.render_html_block(node) }
.link_ref_def {} // already collected, nothing to render
.table { r.render_table(node) }
.table_head { r.render_table_section(node, 'thead') }
.table_body { r.render_table_section(node, 'tbody') }
.table_row { r.render_table_row(node) }
.table_cell { r.render_table_cell(node) }
.definition_list { r.render_definition_list(node) }
.definition_term { r.render_definition_term(node) }
.definition_desc { r.render_definition_desc(node) }
.footnote_def {} // rendered in the footnote section
.text { r.render_text(node) }
.emphasis { r.render_emphasis(node) }
.strong { r.render_strong(node) }
.code_span { r.render_code_span(node) }
.link { r.render_link(node) }
.image { r.render_image(node) }
.autolink { r.render_autolink(node) }
.raw_html { r.render_raw_html(node) }
.hard_break { r.render_hard_break() }
.soft_break { r.render_soft_break() }
.strikethrough { r.render_strikethrough(node) }
.task_checkbox { r.render_task_checkbox(node) }
.footnote_ref { r.render_footnote_ref(node) }
}
}
// render_children renders all children of node.
fn (mut r HTMLRenderer) render_children(node &Node) {
for child in node.children {
r.render_node(child)
}
}
// render_inline parses and renders inline content from a literal string.
fn (mut r HTMLRenderer) render_inline(src string) {
nodes := parse_inline(src, r.opts, r.ref_map)
for node in nodes {
r.render_node(node)
}
}
fn (mut r HTMLRenderer) render_heading(node &Node) {
tag := 'h${node.level}'
if node.id.len > 0 {
r.sb.write_string('<${tag} id="${html_escape(node.id)}">')
} else {
r.sb.write_string('<${tag}>')
}
if node.children.len > 0 {
r.render_children(node)
} else {
r.render_inline(node.literal)
}
r.sb.write_string('</${tag}>\n')
}
fn (mut r HTMLRenderer) render_paragraph(node &Node) {
if r.tight_list {
// In a tight list, paragraph content is rendered directly without <p> tags.
if node.children.len > 0 {
r.render_children(node)
} else {
r.render_inline(node.literal)
}
return
}
r.sb.write_string('<p>')
if node.children.len > 0 {
r.render_children(node)
} else {
r.render_inline(node.literal)
}
r.sb.write_string('</p>\n')
}
fn (mut r HTMLRenderer) render_blockquote(node &Node) {
r.sb.write_string('<blockquote>\n')
r.render_children(node)
r.sb.write_string('</blockquote>\n')
}
fn (mut r HTMLRenderer) render_list(node &Node) {
tag := if node.is_ordered { 'ol' } else { 'ul' }
if node.is_ordered && node.list_start != 1 {
r.sb.write_string('<${tag} start="${node.list_start}">\n')
} else {
r.sb.write_string('<${tag}>\n')
}
prev_tight := r.tight_list
r.tight_list = node.is_tight
r.render_children(node)
r.tight_list = prev_tight
r.sb.write_string('</${tag}>\n')
}
fn (mut r HTMLRenderer) render_list_item(node &Node) {
// Check if this is a task list item (first child is task_checkbox).
if r.opts.task_list && node.children.len > 0 && node.children[0].kind == .task_checkbox {
chk := node.children[0]
checked_attr := if chk.checked { ' checked=""' } else { '' }
if r.opts.renderer_opts.xhtml {
r.sb.write_string('<li><input type="checkbox" disabled=""${checked_attr} /> ')
} else {
r.sb.write_string('<li><input type="checkbox" disabled=""${checked_attr}> ')
}
for i := 1; i < node.children.len; i++ {
r.render_node(node.children[i])
}
r.sb.write_string('</li>\n')
return
}
r.sb.write_string('<li>')
r.render_children(node)
r.sb.write_string('</li>\n')
}
fn (mut r HTMLRenderer) render_code_block(node &Node) {
r.sb.write_string('<pre><code>')
r.sb.write_string(html_escape(node.literal))
r.sb.write_string('</code></pre>\n')
}
fn (mut r HTMLRenderer) render_fenced_code(node &Node) {
if node.fence_info.len > 0 {
// Use only the first word of the info string as the language class.
lang := node.fence_info.split(' ')[0].split('\t')[0]
r.sb.write_string('<pre><code class="language-${html_escape(lang)}">')
} else {
r.sb.write_string('<pre><code>')
}
r.sb.write_string(html_escape(node.literal))
r.sb.write_string('</code></pre>\n')
}
fn (mut r HTMLRenderer) render_thematic_break() {
if r.opts.renderer_opts.xhtml {
r.sb.write_string('<hr />\n')
} else {
r.sb.write_string('<hr>\n')
}
}
fn (mut r HTMLRenderer) render_html_block(node &Node) {
if r.opts.renderer_opts.unsafe_ {
r.sb.write_string(node.literal)
} else {
r.sb.write_string('<!-- raw HTML omitted -->\n')
}
}
fn (mut r HTMLRenderer) render_table(node &Node) {
r.sb.write_string('<table>\n')
r.render_children(node)
r.sb.write_string('</table>\n')
}
fn (mut r HTMLRenderer) render_table_section(node &Node, tag string) {
prev_in_table_head := r.in_table_head
r.in_table_head = tag == 'thead'
r.sb.write_string('<${tag}>\n')
r.render_children(node)
r.sb.write_string('</${tag}>\n')
r.in_table_head = prev_in_table_head
}
fn (mut r HTMLRenderer) render_table_row(node &Node) {
// Determine cell tag based on parent kind (table_head uses th).
// We pass the context via a field or inspect the row context.
// Since we don't have parent pointer, check if this is a header row via the
// node's parent tracking. We'll check node.children[0].align as a proxy.
// Instead, use a simple flag: if any sibling is a table_head, use <th>.
// For simplicity, we use <td> always and let render_table_cell decide.
r.sb.write_string('<tr>\n')
r.render_children(node)
r.sb.write_string('</tr>\n')
}
fn (mut r HTMLRenderer) render_table_cell(node &Node) {
// We use a flag in the renderer to know if we're in the head.
// Simple approach: the cell tag is set by the surrounding context.
// We'll use <td> and trust the renderer state.
align_attr := match node.align {
.left { ' align="left"' }
.center { ' align="center"' }
.right { ' align="right"' }
else { '' }
}
cell_tag := if r.in_table_head { 'th' } else { 'td' }
r.sb.write_string('<${cell_tag}${align_attr}>')
r.render_inline(node.literal)
r.sb.write_string('</${cell_tag}>\n')
}
fn (mut r HTMLRenderer) render_definition_list(node &Node) {
r.sb.write_string('<dl>\n')
r.render_children(node)
r.sb.write_string('</dl>\n')
}
fn (mut r HTMLRenderer) render_definition_term(node &Node) {
r.sb.write_string('<dt>')
r.render_inline(node.literal)
r.sb.write_string('</dt>\n')
for child in node.children {
r.render_node(child)
}
}
fn (mut r HTMLRenderer) render_definition_desc(node &Node) {
r.sb.write_string('<dd>')
r.render_inline(node.literal)
r.sb.write_string('</dd>\n')
}
fn (mut r HTMLRenderer) render_footnote_ref(node &Node) {
label := node.fn_label
// Assign an ordinal on first encounter.
mut idx := 0
for i, l in r.fn_order {
if l == label {
idx = i + 1
break
}
}
if idx == 0 {
r.fn_order << label
idx = r.fn_order.len
}
r.sb.write_string('<sup><a href="#fn-${html_escape(label)}" id="fnref-${html_escape(label)}">${idx}</a></sup>')
}
fn (mut r HTMLRenderer) render_footnotes_section() {
r.sb.write_string('<section class="footnotes">\n<ol>\n')
for label in r.fn_order {
fn_node := r.fn_nodes[label] or { continue }
r.sb.write_string('<li id="fn-${html_escape(label)}">')
r.render_inline(fn_node.literal)
r.sb.write_string(' <a href="#fnref-${html_escape(label)}">&#x21A9;</a></li>\n')
}
r.sb.write_string('</ol>\n</section>\n')
}
fn (mut r HTMLRenderer) render_text(node &Node) {
content := if r.opts.typographer {
smart_punctuate(node.literal)
} else {
node.literal
}
r.sb.write_string(html_escape(content))
}
fn (mut r HTMLRenderer) render_emphasis(node &Node) {
r.sb.write_string('<em>')
r.render_children(node)
r.sb.write_string('</em>')
}
fn (mut r HTMLRenderer) render_strong(node &Node) {
r.sb.write_string('<strong>')
r.render_children(node)
r.sb.write_string('</strong>')
}
fn (mut r HTMLRenderer) render_code_span(node &Node) {
r.sb.write_string('<code>')
r.sb.write_string(html_escape(node.literal))
r.sb.write_string('</code>')
}
fn (mut r HTMLRenderer) render_link(node &Node) {
r.sb.write_string('<a href="${html_escape(url_encode(node.dest))}"')
if node.title.len > 0 {
r.sb.write_string(' title="${html_escape(node.title)}"')
}
r.sb.write_string('>')
r.render_children(node)
r.sb.write_string('</a>')
}
fn (mut r HTMLRenderer) render_image(node &Node) {
alt := node.text_content()
r.sb.write_string('<img src="${html_escape(url_encode(node.dest))}" alt="${html_escape(alt)}"')
if node.title.len > 0 {
r.sb.write_string(' title="${html_escape(node.title)}"')
}
if r.opts.renderer_opts.xhtml {
r.sb.write_string(' />')
} else {
r.sb.write_string('>')
}
}
fn (mut r HTMLRenderer) render_autolink(node &Node) {
r.sb.write_string('<a href="${html_escape(url_encode(node.dest))}">')
r.sb.write_string(html_escape(node.literal))
r.sb.write_string('</a>')
}
fn (mut r HTMLRenderer) render_raw_html(node &Node) {
if r.opts.renderer_opts.unsafe_ {
r.sb.write_string(node.literal)
} else {
r.sb.write_string('<!-- raw HTML omitted -->')
}
}
fn (mut r HTMLRenderer) render_hard_break() {
if r.opts.renderer_opts.xhtml {
r.sb.write_string('<br />\n')
} else {
r.sb.write_string('<br>\n')
}
}
fn (mut r HTMLRenderer) render_soft_break() {
if r.opts.renderer_opts.hard_wraps {
if r.opts.renderer_opts.xhtml {
r.sb.write_string('<br />\n')
} else {
r.sb.write_string('<br>\n')
}
} else {
r.sb.write_string('\n')
}
}
fn (mut r HTMLRenderer) render_strikethrough(node &Node) {
r.sb.write_string('<del>')
r.render_children(node)
r.sb.write_string('</del>')
}
fn (mut r HTMLRenderer) render_task_checkbox(node &Node) {
// Rendered inline in render_list_item; standalone fallback:
checked := if node.checked { ' checked=""' } else { '' }
if r.opts.renderer_opts.xhtml {
r.sb.write_string('<input type="checkbox" disabled=""${checked} />')
} else {
r.sb.write_string('<input type="checkbox" disabled=""${checked}>')
}
}
+909
View File
@@ -0,0 +1,909 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module markdown
import encoding.html as ehtml
// parse_inline parses src as inline content and returns a slice of inline nodes.
pub fn parse_inline(src string, opts Options, ref_map map[string]LinkRef) []&Node {
mut p := InlineParser{
src: src
opts: opts
ref_map: ref_map
}
return p.parse()
}
// InlineParser parses inline markdown content.
struct InlineParser {
opts Options
ref_map map[string]LinkRef
mut:
src string
pos int
}
struct EmphDelim {
mut:
node_idx int
ch u8
length int
orig_len int
can_open bool
can_close bool
active bool = true
}
@[inline]
fn rune_at_or_space(s string, pos int) rune {
if pos < 0 || pos >= s.len {
return ` `
}
char_len := utf8_char_len(s[pos])
if char_len <= 0 || pos + char_len > s.len {
return rune(s[pos])
}
return s[pos..pos + char_len].runes()[0]
}
@[inline]
fn prev_rune_or_space(s string, pos int) rune {
if pos <= 0 {
return ` `
}
mut i := pos - 1
for i > 0 && (s[i] & u8(0xC0)) == u8(0x80) {
i--
}
return rune_at_or_space(s, i)
}
// parse parses the full inline source and returns a node slice.
fn (mut p InlineParser) parse() []&Node {
mut result := []&Node{}
mut delims := []EmphDelim{}
for p.pos < p.src.len {
if p.src[p.pos] == `*` || p.src[p.pos] == `_` {
start := p.pos
ch := p.src[p.pos]
for p.pos < p.src.len && p.src[p.pos] == ch {
p.pos++
}
run := p.src[start..p.pos]
before := prev_rune_or_space(p.src, start)
after := rune_at_or_space(p.src, p.pos)
can_open := can_open_emphasis(ch, before, after)
can_close := can_close_emphasis(ch, before, after)
result << text_node(run)
if can_open || can_close {
delims << EmphDelim{
node_idx: result.len - 1
ch: ch
length: run.len
orig_len: run.len
can_open: can_open
can_close: can_close
}
}
continue
}
nodes := p.parse_one()
for n in nodes {
result << n
}
}
if delims.len > 0 {
resolve_emphasis(mut result, mut delims)
result = compact_empty_text_nodes(result)
}
return merge_text_nodes(result)
}
fn resolve_emphasis(mut nodes []&Node, mut delims []EmphDelim) {
mut i := 0
for i < delims.len {
if !delims[i].active || !delims[i].can_close || delims[i].length == 0 {
i++
continue
}
mut opener := i - 1
for opener >= 0 {
if !delims[opener].active || !delims[opener].can_open || delims[opener].length == 0 {
opener--
continue
}
if delims[opener].ch != delims[i].ch {
opener--
continue
}
if (delims[i].can_open || delims[opener].can_close)
&& (delims[opener].orig_len + delims[i].orig_len) % 3 == 0
&& (delims[opener].orig_len % 3 != 0 || delims[i].orig_len % 3 != 0) {
opener--
continue
}
if delims[opener].node_idx + 1 >= delims[i].node_idx {
opener--
continue
}
break
}
if opener < 0 {
i++
continue
}
use_len := if delims[opener].length >= 2 && delims[i].length >= 2 { 2 } else { 1 }
opener_idx := delims[opener].node_idx
closer_idx := delims[i].node_idx
if opener_idx < 0 || closer_idx < 0 || opener_idx >= nodes.len || closer_idx >= nodes.len
|| opener_idx >= closer_idx {
delims[i].active = false
i++
continue
}
if nodes[opener_idx].literal.len < use_len || nodes[closer_idx].literal.len < use_len {
delims[i].active = false
i++
continue
}
nodes[opener_idx].literal = nodes[opener_idx].literal[..nodes[opener_idx].literal.len - use_len]
nodes[closer_idx].literal = nodes[closer_idx].literal[use_len..]
delims[opener].length -= use_len
delims[i].length -= use_len
mut emph := new_node(if use_len == 2 { .strong } else { .emphasis })
for child_idx in opener_idx + 1 .. closer_idx {
emph.append_child(nodes[child_idx])
}
n_inner := closer_idx - opener_idx - 1
if n_inner > 0 {
nodes.delete_many(opener_idx + 1, n_inner)
nodes.insert(opener_idx + 1, emph)
}
mut delta := n_inner - 1
if delta < 0 {
delta = 0
}
for j := 0; j < delims.len; j++ {
if !delims[j].active {
continue
}
if delims[j].node_idx > opener_idx && delims[j].node_idx < closer_idx {
delims[j].active = false
continue
}
if delta > 0 && delims[j].node_idx >= closer_idx {
delims[j].node_idx -= delta
}
}
if delims[opener].length == 0 {
delims[opener].can_open = false
}
if delims[i].length == 0 {
delims[i].can_close = false
}
i = opener + 1
}
}
fn compact_empty_text_nodes(nodes []&Node) []&Node {
mut out := []&Node{}
for n in nodes {
if n.kind == .text && n.literal.len == 0 {
continue
}
out << n
}
return out
}
// parse_one parses one or more inline elements at the current position.
fn (mut p InlineParser) parse_one() []&Node {
if p.pos >= p.src.len {
return []
}
c := p.src[p.pos]
match c {
`\\` {
return [p.parse_backslash()]
}
96 { // backtick
if node := p.try_code_span() {
return [node]
}
p.pos++
return [text_node('`')]
}
`*`, `_` {
p.pos++
return [text_node(c.ascii_str())]
}
`~` {
if p.opts.strikethrough {
if node := p.try_strikethrough() {
return [node]
}
}
p.pos++
return [text_node('~')]
}
`[` {
if nodes := p.try_link_or_footnote() {
return nodes
}
p.pos++
return [text_node('[')]
}
`!` {
if p.pos + 1 < p.src.len && p.src[p.pos + 1] == `[` {
saved := p.pos
p.pos += 2
if nodes := p.try_image_after_bang() {
return nodes
}
p.pos = saved
p.pos++
return [text_node('!')]
}
p.pos++
return [text_node('!')]
}
`<` {
if node := p.try_autolink_or_html() {
return [node]
}
p.pos++
return [text_node('<')]
}
`&` {
if node := p.try_entity() {
return [node]
}
p.pos++
return [text_node('&')]
}
`\n` {
return [p.parse_newline()]
}
else {
r := rune_at_or_space(p.src, p.pos)
step := utf8_char_len(p.src[p.pos])
if p.opts.linkify {
if node := p.try_linkify() {
return [node]
}
}
p.pos += step
return [text_node(r.str())]
}
}
}
// text_node creates a text node with the given literal string.
fn text_node(s string) &Node {
mut n := new_node(.text)
n.literal = s
return n
}
// merge_text_nodes merges consecutive text nodes into one.
fn merge_text_nodes(nodes []&Node) []&Node {
if nodes.len <= 1 {
return nodes
}
mut result := []&Node{}
for node in nodes {
if result.len > 0 && result[result.len - 1].kind == .text && node.kind == .text {
result[result.len - 1].literal += node.literal
} else {
result << node
}
}
return result
}
// can_open_emphasis reports whether a delimiter run can open emphasis.
fn can_open_emphasis(delim u8, before rune, after rune) bool {
left_flanking := !is_unicode_space(after) && (!is_ascii_punct(after) || is_unicode_space(before)
|| is_ascii_punct(before))
right_flanking := !is_unicode_space(before)
&& (!is_ascii_punct(before) || is_unicode_space(after)
|| is_ascii_punct(after))
if delim == `*` {
return left_flanking
}
if delim == `_` {
return left_flanking && (!right_flanking || is_ascii_punct(before))
}
return false
}
// can_close_emphasis reports whether a delimiter run can close emphasis.
fn can_close_emphasis(delim u8, before rune, after rune) bool {
left_flanking := !is_unicode_space(after) && (!is_ascii_punct(after) || is_unicode_space(before)
|| is_ascii_punct(before))
right_flanking := !is_unicode_space(before)
&& (!is_ascii_punct(before) || is_unicode_space(after)
|| is_ascii_punct(after))
if delim == `*` {
return right_flanking
}
if delim == `_` {
return right_flanking && (!left_flanking || is_ascii_punct(after))
}
return false
}
// parse_backslash handles backslash escapes and hard line breaks.
fn (mut p InlineParser) parse_backslash() &Node {
p.pos++ // consume '\'
if p.pos >= p.src.len {
return text_node('\\')
}
ch := p.src[p.pos]
if ch == `\n` {
p.pos++
return new_node(.hard_break)
}
if is_ascii_punct(ch) {
p.pos++
return text_node(ch.ascii_str())
}
return text_node('\\')
}
// try_code_span attempts to parse a backtick code span.
fn (mut p InlineParser) try_code_span() ?&Node {
start := p.pos
mut n := 0
for p.pos < p.src.len && p.src[p.pos] == 96 {
n++
p.pos++
}
content_start := p.pos
mut search := content_start
for search < p.src.len {
if p.src[search] == 96 {
close_start := search
mut close_n := 0
for search < p.src.len && p.src[search] == 96 {
close_n++
search++
}
if close_n == n {
code_raw := p.src[content_start..close_start]
mut code := code_raw.replace('\n', ' ')
if code.len >= 2 && code[0] == ` ` && code[code.len - 1] == ` `
&& code.trim_space().len > 0 {
code = code[1..code.len - 1]
}
mut node := new_node(.code_span)
node.literal = code
p.pos = search
return node
}
} else {
search++
}
}
p.pos = start
return none
}
// try_emphasis attempts to parse *em*, **strong**, _em_, __strong__.
fn (mut p InlineParser) try_emphasis(c u8) ?&Node {
start := p.pos
mut run := 0
for p.pos < p.src.len && p.src[p.pos] == c {
run++
p.pos++
}
// Prevent splitting an intraword __ run into a synthetic single-underscore opener.
if c == `_` && run == 1 && start > 1 && p.src[start - 1] == `_` {
before2 := prev_rune_or_space(p.src, start - 1)
after1 := rune_at_or_space(p.src, start + run)
if is_wordish(before2) && is_wordish(after1) {
p.pos = start
return none
}
}
before := prev_rune_or_space(p.src, start)
after := rune_at_or_space(p.src, start + run)
opener_can_open := can_open_emphasis(c, before, after)
opener_can_close := can_close_emphasis(c, before, after)
if !opener_can_open {
p.pos = start
return none
}
// Prefer em first for odd runs (e.g. ***foo*** -> <em><strong>foo</strong></em>).
if run % 2 == 1 {
p.pos = start + 1
if node := p.match_close_delim(c, 1, run, opener_can_close) {
return node
}
if run >= 2 {
p.pos = start + 2
if node := p.match_close_delim(c, 2, run, opener_can_close) {
return node
}
}
} else {
if run >= 2 {
p.pos = start + 2
if node := p.match_close_delim(c, 2, run, opener_can_close) {
return node
}
}
p.pos = start + 1
if node := p.match_close_delim(c, 1, run, opener_can_close) {
return node
}
}
p.pos = start
return none
}
// is_wordish reports whether c behaves like a word character for emphasis
// boundary checks (includes non-ASCII bytes used in UTF-8 sequences).
@[inline]
fn is_wordish(c rune) bool {
return !is_unicode_space(c) && !is_ascii_punct(c)
}
// match_close_delim parses content after the opening delimiter run and finds
// a matching closing delimiter of exactly `count` characters.
fn (mut p InlineParser) match_close_delim(c u8, count int, opener_run int, opener_can_close bool) ?&Node {
content_start := p.pos
mut content_nodes := []&Node{}
for p.pos < p.src.len {
loop_start_pos := p.pos
ch := p.src[p.pos]
// Check for closing delimiter.
if ch == c {
close_pos := p.pos
mut close_run := 0
for p.pos < p.src.len && p.src[p.pos] == c {
close_run++
p.pos++
}
if close_run >= count {
// Verify right-flanking.
before_close := prev_rune_or_space(p.src, close_pos)
after_close := rune_at_or_space(p.src, p.pos)
closer_can_close := can_close_emphasis(c, before_close, after_close)
closer_can_open := can_open_emphasis(c, before_close, after_close)
if closer_can_close {
if count == 1 && opener_run == 1 && close_run > 1 && closer_can_open {
p.pos = close_pos
} else if count == 1 && opener_run > 1 && close_run > 1 && closer_can_open {
// Keep extra delimiters inside the emphasis span so nested
// strong parsing can consume them (e.g. foo***bar***baz).
inner_end := close_pos + (close_run - count)
if inner_end > content_start && inner_end <= p.src.len {
inner_nodes := parse_inline(p.src[content_start..inner_end], p.opts,
p.ref_map)
mut node := new_node(.emphasis)
for child in inner_nodes {
node.append_child(child)
}
p.pos = close_pos + close_run
return node
}
} else {
if opener_can_close && closer_can_open && (opener_run + close_run) % 3 == 0
&& (opener_run % 3 != 0 || close_run % 3 != 0) {
p.pos = close_pos
} else {
if content_nodes.len == 0 {
content_nodes << text_node(c.ascii_str())
p.pos = close_pos + 1
continue
}
// Rewind extra closing chars beyond `count`.
p.pos = close_pos + count
kind := if count == 2 { NodeKind.strong } else { NodeKind.emphasis }
mut node := new_node(kind)
for child in content_nodes {
node.append_child(child)
}
return node
}
}
}
}
p.pos = close_pos
if count == 1 && opener_run > 1 && opener_can_close {
content_nodes << text_node(c.ascii_str())
p.pos++
continue
}
}
if ch == `\n` {
// Newlines stop emphasis search.
break
}
inner := p.parse_one()
if p.pos <= loop_start_pos {
// Safety net: force progress to avoid recursive delimiter stalls.
fallback := rune_at_or_space(p.src, loop_start_pos)
content_nodes << text_node(fallback.str())
p.pos = loop_start_pos + utf8_char_len(p.src[loop_start_pos])
continue
}
content_nodes << inner
}
// Not found; reset.
p.pos = content_start
return none
}
// try_strikethrough parses ~~text~~.
fn (mut p InlineParser) try_strikethrough() ?&Node {
if p.pos + 1 >= p.src.len || p.src[p.pos + 1] != `~` {
return none
}
p.pos += 2
close := p.src.index_after_('~~', p.pos)
if close < 0 {
p.pos -= 2
return none
}
inner := p.src[p.pos..close]
inner_nodes := parse_inline(inner, p.opts, p.ref_map)
mut node := new_node(.strikethrough)
for child in inner_nodes {
node.append_child(child)
}
p.pos = close + 2
return node
}
// try_link_or_footnote handles [ and attempts to parse a link or footnote ref.
fn (mut p InlineParser) try_link_or_footnote() ?[]&Node {
saved := p.pos
p.pos++ // consume '['
// Footnote reference [^label].
if p.opts.footnotes && p.pos < p.src.len && p.src[p.pos] == `^` {
fn_start := p.pos + 1
fn_close := p.src.index_after_(']', fn_start)
if fn_close > fn_start {
label := p.src[fn_start..fn_close]
mut fn_ref := new_node(.footnote_ref)
fn_ref.fn_label = label
p.pos = fn_close + 1
return [fn_ref]
}
}
text_start := p.pos
close := find_bracket_close(p.src, p.pos)
if close < 0 {
p.pos = saved
return none
}
link_text := p.src[text_start..close]
p.pos = close + 1
// Inline link (url).
if p.pos < p.src.len && p.src[p.pos] == `(` {
dest, title, end := parse_inline_link_dest_from(p.src, p.pos + 1)
if end >= 0 {
inner_nodes := parse_inline(link_text, p.opts, p.ref_map)
mut node := new_node(.link)
node.dest = unescape_string(dest)
node.title = unescape_string(title)
for child in inner_nodes {
node.append_child(child)
}
p.pos = end + 1
return [node]
}
}
// Full reference [text][label].
if p.pos < p.src.len && p.src[p.pos] == `[` {
ref_start := p.pos + 1
ref_close := p.src.index_after_(']', ref_start)
if ref_close >= 0 {
raw_label := p.src[ref_start..ref_close]
label := normalize_label(if raw_label.len > 0 { raw_label } else { link_text })
if label in p.ref_map {
ref := p.ref_map[label]
mut node := new_node(.link)
node.dest = ref.dest
node.title = ref.title
node.label = label
inner_nodes := parse_inline(link_text, p.opts, p.ref_map)
for child in inner_nodes {
node.append_child(child)
}
p.pos = ref_close + 1
return [node]
}
if raw_label.len > 0 {
// Do not downgrade explicit [text][label] to shortcut [text].
p.pos = saved
return none
}
}
}
// Shortcut reference [label].
label := normalize_label(link_text)
if label in p.ref_map {
ref := p.ref_map[label]
mut node := new_node(.link)
node.dest = ref.dest
node.title = ref.title
node.label = label
inner_nodes := parse_inline(link_text, p.opts, p.ref_map)
for child in inner_nodes {
node.append_child(child)
}
return [node]
}
p.pos = saved
return none
}
// try_image_after_bang parses the [alt](url) part of an image after '![' was consumed.
fn (mut p InlineParser) try_image_after_bang() ?[]&Node {
text_start := p.pos
close := find_bracket_close(p.src, p.pos)
if close < 0 {
return none
}
alt_text := p.src[text_start..close]
p.pos = close + 1
if p.pos < p.src.len && p.src[p.pos] == `(` {
dest, title, end := parse_inline_link_dest_from(p.src, p.pos + 1)
if end >= 0 {
mut node := new_node(.image)
node.dest = unescape_string(dest)
node.title = unescape_string(title)
inner_nodes := parse_inline(alt_text, p.opts, p.ref_map)
for child in inner_nodes {
node.append_child(child)
}
p.pos = end + 1
return [node]
}
}
if p.pos < p.src.len && p.src[p.pos] == `[` {
ref_start := p.pos + 1
ref_close := p.src.index_after_(']', ref_start)
if ref_close >= 0 {
raw_label := p.src[ref_start..ref_close]
label := normalize_label(if raw_label.len > 0 { raw_label } else { alt_text })
if label in p.ref_map {
ref := p.ref_map[label]
mut node := new_node(.image)
node.dest = ref.dest
node.title = ref.title
inner_nodes := parse_inline(alt_text, p.opts, p.ref_map)
for child in inner_nodes {
node.append_child(child)
}
p.pos = ref_close + 1
return [node]
}
}
}
return none
}
// find_bracket_close finds the ] matching the [ at start, handling nesting and escapes.
fn find_bracket_close(s string, start int) int {
mut depth := 1
mut i := start
for i < s.len {
if s[i] == `\\` && i + 1 < s.len {
i += 2
continue
}
if s[i] == `[` {
depth++
} else if s[i] == `]` {
depth--
if depth == 0 {
return i
}
}
i++
}
return -1
}
// parse_inline_link_dest_from parses (url) or (url "title") starting at s[start]
// (start is after the opening paren). Returns (dest, title, end_paren_pos) or ("","", -1).
fn parse_inline_link_dest_from(s string, start int) (string, string, int) {
i := skip_ws(s, start)
if i >= s.len {
return '', '', -1
}
dest, after_dest := parse_link_dest(s[i..])
j := i + (s[i..].len - after_dest.len)
k := skip_ws(s, j)
if k < s.len && s[k] == `)` {
return dest, '', k
}
title, after_title := parse_link_title(s[k..])
l := k + (s[k..].len - after_title.len)
m := skip_ws(s, l)
if m < s.len && s[m] == `)` {
return dest, title, m
}
return '', '', -1
}
// skip_ws returns the position in s after skipping whitespace from start.
fn skip_ws(s string, start int) int {
mut i := start
for i < s.len {
ch := rune_at_or_space(s, i)
if ch !in unicode_space {
break
}
i += utf8_char_len(s[i])
}
return i
}
// try_autolink_or_html handles <...> for autolinks and raw HTML.
fn (mut p InlineParser) try_autolink_or_html() ?&Node {
rest := p.src[p.pos..]
auto_end := try_autolink(rest)
if auto_end >= 0 {
content := rest[1..auto_end]
mut node := new_node(.autolink)
node.literal = content
if content.contains('@') && !content.contains('://') {
node.dest = 'mailto:' + content
} else {
node.dest = content
}
p.pos += auto_end + 1
return node
}
raw_end := try_raw_html_tag(rest)
if raw_end >= 0 {
mut node := new_node(.raw_html)
node.literal = rest[..raw_end + 1]
p.pos += raw_end + 1
return node
}
return none
}
// try_autolink matches an <autolink> returning the position of '>' or -1.
fn try_autolink(s string) int {
if s.len < 3 || s[0] != `<` {
return -1
}
end := s.index_after_('>', 1)
if end < 0 {
return -1
}
inner := s[1..end]
if inner.contains(' ') || inner.contains('<') {
return -1
}
if inner.contains('://') {
return end
}
if inner.contains('@') && !inner.starts_with('@') {
return end
}
return -1
}
// try_raw_html_tag matches a raw HTML tag starting with '<' and returns the '>' position.
fn try_raw_html_tag(s string) int {
if s.len < 3 || s[0] != `<` {
return -1
}
if s.starts_with('<!--') {
end := s.index_after_('-->', 4)
if end >= 0 {
return end + 2
}
return -1
}
if s.starts_with('<?') {
end := s.index_after_('?>', 2)
if end >= 0 {
return end + 1
}
return -1
}
low := s.to_lower()
if low.starts_with('<![cdata[') {
end := s.index_after_(']]>', 9)
if end >= 0 {
return end + 2
}
return -1
}
end := s.index_after_('>', 1)
if end < 0 {
return -1
}
inner := s[1..end]
if inner.len == 0 {
return -1
}
if !is_alpha(inner[0]) && inner[0] != `/` && inner[0] != `!` {
return -1
}
return end
}
// try_entity parses an HTML entity reference &name; or &#n; or &#xn'
fn (mut p InlineParser) try_entity() ?&Node {
rest := p.src[p.pos..]
semi := rest.index(';') or { return none }
if semi > 32 || semi < 2 {
return none
}
candidate := rest[..semi + 1]
decoded := ehtml.unescape(candidate, all: true)
if decoded == candidate {
return none
}
p.pos += semi + 1
return text_node(decoded)
}
// parse_newline handles a newline character.
fn (mut p InlineParser) parse_newline() &Node {
// Hard break if preceded by two or more spaces.
if p.pos >= 2 && p.src[p.pos - 1] == ` ` && p.src[p.pos - 2] == ` ` {
p.pos++
return new_node(.hard_break)
}
p.pos++
return new_node(.soft_break)
}
// try_linkify matches a bare URL (linkify extension).
fn (mut p InlineParser) try_linkify() ?&Node {
rest := p.src[p.pos..]
for _, scheme in ['https://', 'http://', 'ftp://', 'mailto:'] {
if rest.starts_with(scheme) {
mut end := scheme.len
for end < rest.len {
ch := rest[end]
if ch == ` ` || ch == `<` || ch == `>` || ch == `"` || ch == `\n` || ch == `\t` {
break
}
end++
}
for end > scheme.len {
last := rest[end - 1]
if last == `.` || last == `,` || last == `;` || last == `!` || last == `?` {
end--
} else {
break
}
}
url := rest[..end]
mut node := new_node(.autolink)
node.literal = url
node.dest = url
p.pos += end
return node
}
}
return none
}
+151
View File
@@ -0,0 +1,151 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
//
// Module markdown provides CommonMark-compliant markdown parsing and HTML
// rendering with support for GitHub Flavored Markdown and additional
// extensions. It is designed for feature parity with github.com/yuin/goldmark.
//
// Basic usage:
//
// import x.markdown
//
// html := markdown.to_html('# Hello\n\nWorld')
//
// With GFM extensions:
//
// md := markdown.new(extensions: markdown.gfm())
// html := md.convert('| a | b |\n|---|---|\n| 1 | 2 |')
//
// With fine-grained options:
//
// md := markdown.new(
// extensions: [markdown.Extension(markdown.footnote()), markdown.typographer()],
// parser_opts: markdown.ParserOptions{ auto_heading_id: true },
// renderer_opts: markdown.RendererOptions{ unsafe_: true, xhtml: true },
// )
// html := md.convert(source)
//
// Parse to AST and walk:
//
// doc := md.parse(source)
// doc.walk(fn (node &markdown.Node) bool {
// println(node.kind)
// return true
// })
module markdown
// ParserOptions configures parser behaviour.
@[params]
pub struct ParserOptions {
pub mut:
// auto_heading_id generates an id attribute for every heading node
// derived from the heading text content (goldmark WithAutoHeadingID).
auto_heading_id bool
}
// RendererOptions configures HTML renderer behaviour.
@[params]
pub struct RendererOptions {
pub mut:
// unsafe_ allows raw HTML from the source to be included in the output.
// When false (the default) raw HTML is replaced with an HTML comment.
unsafe_ bool
// hard_wraps converts every newline inside a paragraph to a <br> tag.
hard_wraps bool
// xhtml outputs XHTML-style self-closing tags (e.g. <br />).
xhtml bool
}
// Options configures a Markdown processor.
// Extension flags in the mut section are normally set by calling new() with
// an extensions slice; they can also be set directly.
@[params]
pub struct Options {
pub mut:
// extensions is the list of extensions applied when new() is called.
extensions []Extension
// parser_opts configures the parser.
parser_opts ParserOptions
// renderer_opts configures the renderer.
renderer_opts RendererOptions
// --- feature flags set by extensions ---
tables bool
strikethrough bool
linkify bool
task_list bool
footnotes bool
typographer bool
definition_list bool
}
// LinkRef holds a collected link reference definition (url + optional title).
struct LinkRef {
dest string
title string
}
// Markdown is the main markdown processor. Create one with new() and reuse it
// across multiple convert/parse calls; link reference definitions are cached.
pub struct Markdown {
pub mut:
opts Options
ref_map map[string]LinkRef
}
// Markdown.new creates a Markdown processor with the given options.
// All extensions in opts.extensions are applied immediately.
pub fn Markdown.new(opts Options) Markdown {
mut m := Markdown{
opts: opts
ref_map: map[string]LinkRef{}
}
for ext in opts.extensions {
ext.extend(mut m)
}
return m
}
// to_html converts the markdown source to HTML with the given options.
pub fn to_html(src string, opts Options) string {
mut md := Markdown.new(opts)
return md.convert(src)
}
// to_plaintext converts the markdown source to UTF-8 plain text with the given options.
pub fn to_plaintext(src string, opts Options) string {
mut md := Markdown.new(opts)
return md.convert_plaintext(src)
}
// convert parses the markdown source and renders it to an HTML string.
pub fn (mut m Markdown) convert(src string) string {
doc := m.parse(src)
mut r := HTMLRenderer{
opts: m.opts
ref_map: m.ref_map // Use the updated ref_map after parse()
}
return r.render(doc)
}
// convert_plaintext parses the markdown source and renders it to UTF-8 plain text.
pub fn (mut m Markdown) convert_plaintext(src string) string {
doc := m.parse(src)
mut r := PlainTextRenderer{
opts: m.opts
ref_map: m.ref_map
}
return r.render(doc)
}
// parse parses the markdown source into an AST and returns the document root.
// Link reference definitions collected during parsing are cached so that
// subsequent parse/convert calls on the same Markdown instance share them.
pub fn (mut m Markdown) parse(src string) &Node {
mut p := BlockParser.new(src, m.opts, m.ref_map)
doc := p.parse()
for k, v in p.ref_map {
m.ref_map[k] = v
}
return doc
}
+361
View File
@@ -0,0 +1,361 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module markdown
fn test_to_html_heading() {
assert to_html('# Hello') == '<h1>Hello</h1>\n'
assert to_html('## World') == '<h2>World</h2>\n'
}
fn test_to_html_paragraph() {
assert to_html('Hello world') == '<p>Hello world</p>\n'
}
fn test_to_html_thematic_break() {
assert to_html('---') == '<hr>\n'
}
fn test_to_html_emphasis() {
html := to_html('*em*')
assert html.contains('<em>')
}
fn test_to_html_strong() {
html := to_html('**bold**')
assert html.contains('<strong>')
}
fn test_to_html_code_span() {
html := to_html('`code`')
assert html.contains('<code>')
assert html.contains('code')
}
fn test_to_html_link() {
html := to_html('[link](https://example.com)')
assert html.contains('<a href="https://example.com">')
assert html.contains('link')
}
fn test_html_escape_in_text() {
html := to_html('A < B')
assert html.contains('&lt;')
}
fn test_named_entities_are_decoded_before_render() {
assert to_html('&copy;') == '<p>©</p>\n'
assert to_html('&amp;') == '<p>&amp;</p>\n'
}
fn test_unknown_named_entity_is_left_as_literal_text() {
assert to_html('&not_a_real_entity;') == '<p>&amp;not_a_real_entity;</p>\n'
}
fn test_numeric_entities_are_decoded() {
assert to_html('&#169; &#xA9;') == '<p>© ©</p>\n'
}
fn test_empty_input() {
assert to_html('') == ''
}
fn test_multiline_paragraph() {
html := to_html('line one\nline two')
assert html.contains('<p>')
assert html.contains('line one')
}
fn test_fenced_code() {
html := to_html('```go\nfn main() {}\n```')
assert html.contains('<code')
assert html.contains('fn main')
}
fn test_list() {
html := to_html('- item')
assert html.contains('<ul>')
assert html.contains('<li>')
assert html.contains('item')
}
fn test_ordered_list() {
html := to_html('1. first')
assert html.contains('<ol>')
assert html.contains('<li>')
assert html.contains('first')
}
fn test_ordered_list_marker_requires_whitespace_or_eol() {
assert to_html('1.test') == '<p>1.test</p>\n'
assert to_html('1)test') == '<p>1)test</p>\n'
}
fn test_ordered_list_marker_allows_space_tab_or_eol() {
assert to_html('1. item') == '<ol>\n<li>item</li>\n</ol>\n'
assert to_html('1)\titem') == '<ol>\n<li>item</li>\n</ol>\n'
assert to_html('1.') == '<ol>\n<li></li>\n</ol>\n'
}
fn test_blockquote() {
html := to_html('> quote')
assert html.contains('<blockquote>')
assert html.contains('quote')
}
fn test_list_multiple_items() {
html := to_html('- item 1\n- item 2')
assert html.contains('<ul>')
assert html.contains('item 1')
assert html.contains('item 2')
}
fn test_invalid_link_ref_def_does_not_create_reference() {
src := '[bad]: <https://example.com\n\n[bad]'
html := to_html(src)
assert !html.contains('<a href=')
assert html.contains('[bad]')
}
fn test_valid_link_ref_def_is_resolved() {
src := '[ok]: <https://example.com>\n\n[ok]'
html := to_html(src)
assert html.contains('<a href="https://example.com">ok</a>')
}
fn test_full_reference_does_not_fallback_to_shortcut_when_label_is_undefined() {
src := '[text]: https://example.com/text\n\n[text][missing]'
html := to_html(src)
assert html == '<p>[text][missing]</p>\n'
}
fn test_shortcut_reference_still_resolves_normally() {
src := '[text]: https://example.com/text\n\n[text]'
html := to_html(src)
assert html == '<p><a href="https://example.com/text">text</a></p>\n'
}
fn test_gfm_table_header_uses_th_cells() {
src := '| a | b |\n| --- | --- |\n| 1 | 2 |'
html := to_html(src, extensions: gfm())
assert html.contains('<thead>')
assert html.contains('<th>a</th>')
assert html.contains('<th>b</th>')
}
fn test_emphasis_underscore_intraword_does_not_emphasize() {
assert to_html('foo_bar_baz') == '<p>foo_bar_baz</p>\n'
assert to_html('foo_bar_') == '<p>foo_bar_</p>\n'
assert to_html('_foo_bar') == '<p>_foo_bar</p>\n'
}
fn test_emphasis_star_delimiters_still_emphasize() {
assert to_html('a*b*c') == '<p>a<em>b</em>c</p>\n'
}
fn test_emphasis_triple_delimiters() {
assert to_html('***foo***') == '<p><em><strong>foo</strong></em></p>\n'
assert to_html('___foo___') == '<p><em><strong>foo</strong></em></p>\n'
assert to_html('foo***bar***baz') == '<p>foo<em><strong>bar</strong></em>baz</p>\n'
}
fn test_emphasis_nested_mixed_runs() {
assert to_html('**foo *bar***') == '<p><strong>foo <em>bar</em></strong></p>\n'
assert to_html('*foo **bar***') == '<p><em>foo <strong>bar</strong></em></p>\n'
assert to_html('*foo**bar**baz*') == '<p><em>foo<strong>bar</strong>baz</em></p>\n'
assert to_html('*foo **bar** baz*') == '<p><em>foo <strong>bar</strong> baz</em></p>\n'
assert to_html('**foo *bar* baz**') == '<p><strong>foo <em>bar</em> baz</strong></p>\n'
}
fn test_emphasis_multiple_of_three_resolution() {
assert to_html('***foo** bar*') == '<p><em><strong>foo</strong> bar</em></p>\n'
assert to_html('***foo* bar**') == '<p><strong><em>foo</em> bar</strong></p>\n'
assert to_html('***foo**bar*') == '<p><em><strong>foo</strong>bar</em></p>\n'
}
fn test_emphasis_underscore_punctuation_flanking() {
assert to_html('foo-_(bar)_') == '<p>foo-<em>(bar)</em></p>\n'
assert to_html('foo__bar__baz') == '<p>foo__bar__baz</p>\n'
assert to_html('foo__bar__') == '<p>foo__bar__</p>\n'
assert to_html('__foo__bar') == '<p>__foo__bar</p>\n'
}
fn test_setext_heading_leading_spaces() {
// CommonMark allows 0-3 leading spaces on the setext underline.
assert to_html('Foo\n ===') == '<h1>Foo</h1>\n'
assert to_html('Foo\n ---') == '<h2>Foo</h2>\n'
assert to_html('Foo\n ===') == '<h1>Foo</h1>\n'
}
fn test_emphasis_leftover_delimiters_are_literal() {
// Unmatched delimiters become literal text.
assert to_html('*a**b**') == '<p>*a<strong>b</strong></p>\n'
assert to_html('**a**b*') == '<p><strong>a</strong>b*</p>\n'
assert to_html('*foo bar') == '<p>*foo bar</p>\n'
}
fn test_emphasis_mixed_star_underscore() {
// * and _ delimiters do not pair with each other.
assert to_html('*foo _bar_ baz*') == '<p><em>foo <em>bar</em> baz</em></p>\n'
assert to_html('__foo *bar* baz__') == '<p><strong>foo <em>bar</em> baz</strong></p>\n'
}
fn test_link_ref_def_with_leading_spaces() {
// CommonMark allows 0-3 leading spaces before a link ref def.
assert to_html(' [foo]: https://example.com\n\n[foo]') == '<p><a href="https://example.com">foo</a></p>\n'
assert to_html(' [bar]: https://example.org\n\n[bar]') == '<p><a href="https://example.org">bar</a></p>\n'
assert to_html(' [baz]: https://v-lang.io\n\n[baz]') == '<p><a href="https://v-lang.io">baz</a></p>\n'
}
fn test_link_ref_def_with_four_leading_spaces_is_not_a_ref() {
// Four leading spaces start an indented code block, not a reference definition.
src := ' [foo]: https://example.com\n\n[foo]'
html := to_html(src)
assert !html.contains('<a href=')
assert html.contains('[foo]: https://example.com')
}
fn test_setext_heading_multiline_text() {
// Multi-line setext heading text should preserve soft breaks.
html := to_html('Foo\nbar\n===')
assert html == '<h1>Foo\nbar</h1>\n'
}
fn test_task_list() {
src := '- [ ] unchecked\n- [x] checked\n- [X] also checked'
html := to_html(src, task_list: true)
assert html.contains('<input type="checkbox" disabled="">')
assert html.contains('<input type="checkbox" disabled="" checked="">')
assert html.contains('unchecked')
assert html.contains('checked')
}
fn test_task_list_not_applied_without_extension() {
// Without the extension, task markers are rendered as plain text.
html := to_html('- [ ] item')
assert !html.contains('<input')
assert html.contains('[ ] item')
}
fn test_task_list_marker_requires_space_after_closing_bracket() {
// GFM task markers are [ ]/[x]/[X] followed by whitespace or end of item.
src := '- [x]ok\n- [ ]todo'
html := to_html(src, task_list: true)
assert !html.contains('<input')
assert html.contains('[x]ok')
assert html.contains('[ ]todo')
}
fn test_task_list_xhtml_checkbox_self_closing() {
html := to_html('- [x] done',
task_list: true
renderer_opts: RendererOptions{
xhtml: true
}
)
assert html.contains('<input type="checkbox" disabled="" checked="" />')
}
fn test_footnote_definition_inside_list_item_is_preserved() {
src := '- item[^note]\n\n [^note]: footnote in list\n\noutside[^note]'
html := to_html(src, footnotes: true)
assert html.contains('item<sup><a href="#fn-note" id="fnref-note">1</a></sup>')
assert html.contains('outside<sup><a href="#fn-note" id="fnref-note">1</a></sup>')
assert html.contains('<li id="fn-note">footnote in list')
assert html.contains('<a href="#fnref-note">&#x21A9;</a></li>')
}
fn test_footnote_definition_inside_blockquote_is_preserved() {
src := '> quote[^q]\n>\n> [^q]: footnote in quote'
html := to_html(src, footnotes: true)
assert html.contains('quote<sup><a href="#fn-q" id="fnref-q">1</a></sup>')
assert html.contains('<li id="fn-q">footnote in quote')
assert html.contains('<a href="#fnref-q">&#x21A9;</a></li>')
}
fn test_link_ref_def_multiline_title() {
// CommonMark allows the title on the next line when the destination is alone.
src := '[foo]: /url\n"a title"\n\n[foo]'
html := to_html(src)
assert html.contains('<a href="/url"')
assert html.contains('title="a title"')
assert html.contains('>foo</a>')
}
fn test_link_ref_def_multiline_title_single_quotes() {
src := "[bar]: /path\n'my title'\n\n[bar]"
html := to_html(src)
assert html.contains('<a href="/path"')
assert html.contains('title="my title"')
}
fn test_link_ref_def_multiline_no_title_next_line_is_content() {
// If the next line is not a title, it becomes normal content.
src := '[baz]: /url\n\nsome text\n\n[baz]'
html := to_html(src)
assert html.contains('<a href="/url">baz</a>')
assert html.contains('some text')
}
fn test_gfm_helper_sets_core_extension_flags() {
md := Markdown.new(extensions: gfm())
assert md.opts.tables
assert md.opts.strikethrough
assert md.opts.linkify
assert md.opts.task_list
}
fn test_individual_extension_helpers_set_flags() {
md_footnote := Markdown.new(extensions: [Extension(footnote())])
assert md_footnote.opts.footnotes
md_typographer := Markdown.new(extensions: [Extension(typographer())])
assert md_typographer.opts.typographer
md_definition_list := Markdown.new(extensions: [Extension(definition_list())])
assert md_definition_list.opts.definition_list
}
fn test_emphasis_goldmark_parity_edge_cases() {
assert to_html('_a* __*_* b b') == '<p><em>a* __*</em>* b b</p>\n'
assert to_html('* bb _ *__*a* a_') == '<ul>\n<li>bb _ *__<em>a</em> a_</li>\n</ul>\n'
assert to_html('baa _ a*aba**_ba') == '<p>baa _ a*aba**_ba</p>\n'
assert to_html('_a_*_b**_aba*') == '<p><em>a</em><em>_b**_aba</em></p>\n'
assert to_html('x_ ***b*ab*bb_a*a a') == '<p>x_ <em><em><em>b</em>ab</em>bb_a</em>a a</p>\n'
}
fn test_to_plaintext_basic_blocks_and_inlines() {
text := to_plaintext('# Héllo\n\nA *b* [site](https://example.com)')
assert text.contains('# Héllo')
assert text.contains('A *b* site (https://example.com)')
}
fn test_to_plaintext_task_list() {
text := to_plaintext('- [ ] todo\n- [x] done', task_list: true)
assert text.contains('☐')
assert text.contains('☑')
assert text.contains('todo')
assert text.contains('done')
}
fn test_to_plaintext_footnotes() {
text := to_plaintext('Text[^n]\n\n[^n]: note body', footnotes: true)
assert text.contains('Text[1]')
assert text.contains('Footnotes:')
assert text.contains('[1] note body')
}
fn test_to_plaintext_table_rows_are_separated() {
text := to_plaintext('| a | b |\n|---|---|\n| 1 | 2 |', extensions: gfm())
assert text.contains('a | b |')
assert text.contains('1 | 2 |')
assert text.contains('a | b | \n1 | 2 |')
assert !text.contains('a | b | 1 | 2 |')
}
fn test_to_plaintext_blockquote_footnotes_share_global_order() {
text := to_plaintext('> quote[^q]\n\n[^q]: note body', footnotes: true)
assert text.contains('> quote[1]')
assert text.contains('Footnotes:')
assert text.contains('[1] note body')
}
+118
View File
@@ -0,0 +1,118 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module markdown
import strings
// NodeKind identifies what kind of AST node a Node represents.
pub enum NodeKind {
document
heading
paragraph
blockquote
list
list_item
code_block
fenced_code
thematic_break
html_block
link_ref_def
table
table_head
table_body
table_row
table_cell
definition_list
definition_term
definition_desc
footnote_def
text
emphasis
strong
code_span
link
image
autolink
raw_html
hard_break
soft_break
strikethrough
footnote_ref
task_checkbox
}
// Alignment is the text alignment of a table cell column.
pub enum Alignment {
none_
left
center
right
}
// Node is a node in the parsed markdown AST.
// A document is a tree of Nodes with .document as the root.
@[heap]
pub struct Node {
pub mut:
kind NodeKind
level int
is_tight bool
is_ordered bool
list_start int = 1
fence_info string
literal string
dest string
title string
label string
checked bool
align Alignment
id string
fn_label string
fn_index int
children []&Node
}
// new_node allocates and returns a new Node of the given kind.
pub fn new_node(kind NodeKind) &Node {
return &Node{
kind: kind
}
}
// append_child appends child as the last child of n.
pub fn (mut n Node) append_child(child &Node) {
n.children << child
}
// text_content returns the plain-text content of this node and all descendants,
// concatenated in document order.
pub fn (n &Node) text_content() string {
match n.kind {
.text, .code_span, .raw_html {
return n.literal
}
else {
mut sb := strings.new_builder(64)
for child in n.children {
sb.write_string(child.text_content())
}
return sb.str()
}
}
}
// walk traverses n and all its descendants in pre-order (root before children).
// The callback f receives each node; return false from f to stop traversal early.
// walk itself returns false if traversal was stopped, true otherwise.
pub fn (n &Node) walk(f fn (&Node) bool) bool {
if !f(n) {
return false
}
for child in n.children {
if !child.walk(f) {
return false
}
}
return true
}
File diff suppressed because it is too large. Load diff
+287
View File
@@ -0,0 +1,287 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module markdown
import strings
// PlainTextRenderer renders a parsed markdown AST to UTF-8 console-friendly text.
struct PlainTextRenderer {
opts Options
ref_map map[string]LinkRef
mut:
sb strings.Builder
// footnote tracking
fn_order []string
fn_nodes map[string]&Node
// list tracking
list_depth int
list_nums []int
}
// render renders the document node to plain text.
pub fn (mut r PlainTextRenderer) render(doc &Node) string {
r.sb = strings.new_builder(1024)
if r.opts.footnotes {
for child in doc.children {
if child.kind == .footnote_def {
r.fn_nodes[child.fn_label] = child
}
}
}
r.render_children(doc)
if r.opts.footnotes && r.fn_order.len > 0 {
r.render_footnotes_section()
}
return r.sb.str().trim_right('\n') + '\n'
}
fn (mut r PlainTextRenderer) render_node(node &Node) {
match node.kind {
.document { r.render_children(node) }
.heading { r.render_heading(node) }
.paragraph { r.render_paragraph(node) }
.blockquote { r.render_blockquote(node) }
.list { r.render_list(node) }
.list_item { r.render_list_item(node) }
.code_block, .fenced_code { r.render_code_block(node) }
.thematic_break { r.sb.write_string('---\n') }
.html_block { r.render_html_block(node) }
.link_ref_def, .footnote_def {}
.table { r.render_table(node) }
.table_head, .table_body { r.render_children(node) }
.table_row { r.render_table_row(node) }
.table_cell { r.render_table_cell(node) }
.definition_list { r.render_children(node) }
.definition_term { r.render_definition_term(node) }
.definition_desc { r.render_definition_desc(node) }
.text { r.render_text(node) }
.emphasis { r.render_wrapped(node, '*') }
.strong { r.render_wrapped(node, '**') }
.code_span { r.sb.write_string('`' + node.literal + '`') }
.link { r.render_link(node) }
.image { r.render_image(node) }
.autolink { r.sb.write_string(node.literal) }
.raw_html { r.render_raw_html(node) }
.hard_break, .soft_break { r.sb.write_string('\n') }
.strikethrough { r.render_wrapped(node, '~~') }
.task_checkbox { r.render_task_checkbox(node) }
.footnote_ref { r.render_footnote_ref(node) }
}
}
fn (mut r PlainTextRenderer) render_children(node &Node) {
for child in node.children {
r.render_node(child)
}
}
fn (mut r PlainTextRenderer) render_inline(src string) {
nodes := parse_inline(src, r.opts, r.ref_map)
for node in nodes {
r.render_node(node)
}
}
fn (mut r PlainTextRenderer) render_heading(node &Node) {
r.sb.write_string('${'#'.repeat(node.level)} ')
if node.children.len > 0 {
r.render_children(node)
} else {
r.render_inline(node.literal)
}
r.sb.write_string('\n\n')
}
fn (mut r PlainTextRenderer) render_paragraph(node &Node) {
if node.children.len > 0 {
r.render_children(node)
} else {
r.render_inline(node.literal)
}
r.sb.write_string('\n')
}
fn (mut r PlainTextRenderer) render_blockquote(node &Node) {
mut inner := strings.new_builder(128)
mut rr := PlainTextRenderer{
opts: r.opts
ref_map: r.ref_map
fn_order: r.fn_order.clone()
fn_nodes: r.fn_nodes
}
rr.sb = inner
rr.render_children(node)
for line in rr.sb.str().trim_right('\n').split('\n') {
r.sb.write_string('> ${line}\n')
}
// Keep footnote reference order discovered inside the blockquote.
r.fn_order = rr.fn_order
r.sb.write_string('\n')
}
fn (mut r PlainTextRenderer) render_list(node &Node) {
r.list_depth++
if node.is_ordered {
r.list_nums << node.list_start
} else {
r.list_nums << 0
}
r.render_children(node)
r.list_nums.delete_last()
r.list_depth--
if r.list_depth == 0 {
r.sb.write_string('\n')
}
}
fn (mut r PlainTextRenderer) render_list_item(node &Node) {
indent := ' '.repeat(if r.list_depth > 0 { r.list_depth - 1 } else { 0 })
idx := r.list_nums.len - 1
marker := if idx >= 0 && r.list_nums[idx] > 0 {
m := '${r.list_nums[idx]}. '
r.list_nums[idx]++
m
} else {
'- '
}
r.sb.write_string(indent + marker)
for i, child in node.children {
if i > 0 {
r.sb.write_string(' ')
}
if child.kind == .paragraph {
if child.children.len > 0 {
r.render_children(child)
} else {
r.render_inline(child.literal)
}
} else if child.kind == .list {
r.sb.write_string('\n')
r.render_node(child)
} else {
r.render_node(child)
}
}
r.sb.write_string('\n')
}
fn (mut r PlainTextRenderer) render_code_block(node &Node) {
r.sb.write_string('```\n')
r.sb.write_string(node.literal.trim_right('\n'))
r.sb.write_string('\n```\n\n')
}
fn (mut r PlainTextRenderer) render_html_block(node &Node) {
if r.opts.renderer_opts.unsafe_ {
r.sb.write_string(node.literal)
} else {
r.sb.write_string('[raw HTML omitted]\n')
}
}
fn (mut r PlainTextRenderer) render_table(node &Node) {
r.render_children(node)
r.sb.write_string('\n')
}
fn (mut r PlainTextRenderer) render_table_row(node &Node) {
r.render_children(node)
r.sb.write_string('\n')
}
fn (mut r PlainTextRenderer) render_table_cell(node &Node) {
r.sb.write_string(node.literal.trim_space())
r.sb.write_string(' | ')
}
fn (mut r PlainTextRenderer) render_definition_term(node &Node) {
r.render_inline(node.literal)
r.sb.write_string('\n')
for child in node.children {
r.render_node(child)
}
}
fn (mut r PlainTextRenderer) render_definition_desc(node &Node) {
r.sb.write_string(' - ')
r.render_inline(node.literal)
r.sb.write_string('\n')
}
fn (mut r PlainTextRenderer) render_text(node &Node) {
content := if r.opts.typographer {
smart_punctuate(node.literal)
} else {
node.literal
}
r.sb.write_string(content)
}
fn (mut r PlainTextRenderer) render_wrapped(node &Node, marker string) {
r.sb.write_string(marker)
r.render_children(node)
r.sb.write_string(marker)
}
fn (mut r PlainTextRenderer) render_link(node &Node) {
r.render_children(node)
if node.dest.len > 0 {
r.sb.write_string(' (${node.dest})')
}
}
fn (mut r PlainTextRenderer) render_image(node &Node) {
alt := node.text_content().trim_space()
if alt.len == 0 {
r.sb.write_string('[image]')
} else {
r.sb.write_string('[image: ${alt}]')
}
if node.dest.len > 0 {
r.sb.write_string(' (${node.dest})')
}
}
fn (mut r PlainTextRenderer) render_raw_html(node &Node) {
if r.opts.renderer_opts.unsafe_ {
r.sb.write_string(node.literal)
}
}
fn (mut r PlainTextRenderer) render_task_checkbox(node &Node) {
r.sb.write_string(if node.checked { '☑' } else { '☐' })
}
fn (mut r PlainTextRenderer) render_footnote_ref(node &Node) {
label := node.fn_label
mut idx := 0
for i, l in r.fn_order {
if l == label {
idx = i + 1
break
}
}
if idx == 0 {
r.fn_order << label
idx = r.fn_order.len
}
r.sb.write_string('[${idx}]')
}
fn (mut r PlainTextRenderer) render_footnotes_section() {
r.sb.write_string('\nFootnotes:\n')
for label in r.fn_order {
fn_node := r.fn_nodes[label] or { continue }
mut idx := 0
for i, l in r.fn_order {
if l == label {
idx = i + 1
break
}
}
r.sb.write_string('[${idx}] ')
r.render_inline(fn_node.literal)
r.sb.write_string('\n')
}
}
+297
View File
@@ -0,0 +1,297 @@
// Copyright 2026 The V Language. All rights reserved.
// Use of this source code is governed by an MIT license
// that can be found in the LICENSE file.
module markdown
import strings
// unicode_space lists Unicode code points considered whitespace
// vfmt off
const unicode_space = [
` `, // space
`\t`, // tab
0x0a, // LF
0x0b, // Vertical Tab
0x0c, // FF
0x0d, // CR
0x0085, // next line
0x00A0, // no-break space
0x1680, // ogham space mark
0x180E, // mongolian vowel separator
0x2000, // en quad
0x2001, // em quad
0x2002, // en space
0x2003, // em space
0x2004, // three-per-em space
0x2005, // four-per-em space
0x2006, // six-per-em space
0x2007, // figure space
0x2008, // punctuation space
0x2009, // thin space
0x200A, // hair space
0x200B, // zero width space
0x200C, // zero width non-joiner
0x200D, // zero width joiner
0x2028, // line separator
0x2029, // paragraph separator
0x202F, // narrow no-break space
0x205F, // medium mathematical space
0x2060, // word joiner
0x3000, // ideographic space
0xFEFF, // zero width non-breaking space
]!
// ascii_punct lists ASCII punctuation characters
const ascii_punct = [
`!`, `"`, `#`, `$`, `%`, `&`, `'`, `(`, `)`, `*`, `+`, `,`, `-`, `.`, `/`, `:`,
`;`, `<`, `=`, `>`, `?`, `@`, `[`, `\\`, `]`, `^`, `_`, `\``, `{`, `|`, `}`, `~`,
]!
// alpha lists ASCII letters a-z and A-Z
const alpha = [
`a`, `b`, `c`, `d`, `e`, `f`, `g`, `h`, `i`, `j`, `k`, `l`, `m`,
`n`, `o`, `p`, `q`, `r`, `s`, `t`, `u`, `v`, `w`, `x`, `y`, `z`,
`A`, `B`, `C`, `D`, `E`, `F`, `G`, `H`, `I`, `J`, `K`, `L`, `M`,
`N`, `O`, `P`, `Q`, `R`, `S`, `T`, `U`, `V`, `W`, `X`, `Y`, `Z`,
]!
// vfmt on
// html_escape replaces HTML special characters in s with their entity equivalents.
fn html_escape(s string) string {
if s.index_any('&<>"') == -1 {
return s
}
mut sb := strings.new_builder(s.len + 8)
for i := 0; i < s.len; i++ {
match s[i] {
`&` { sb.write_string('&amp;') }
`<` { sb.write_string('&lt;') }
`>` { sb.write_string('&gt;') }
`"` { sb.write_string('&quot;') }
else { sb.write_u8(s[i]) }
}
}
return sb.str()
}
// url_safe_chars contains URL characters that do not need percent-encoding.
const url_safe_chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~:/?#[]@!$&()*+,;=%'
// url_encode percent-encodes characters in a URL that need encoding,
// while leaving valid URL characters (including already-encoded sequences) intact.
fn url_encode(s string) string {
mut sb := strings.new_builder(s.len)
for i := 0; i < s.len; i++ {
c := s[i]
if url_safe_chars.index_u8(c) >= 0 {
sb.write_u8(c)
} else {
sb.write_string('%${c:02X}')
}
}
return sb.str()
}
// normalize_label normalises a link reference label per CommonMark spec:
// strip leading/trailing Unicode whitespace, collapse internal whitespace runs
// to a single space, and fold to lower case.
fn normalize_label(s string) string {
mut out := strings.new_builder(s.len)
mut in_space := true // start true so we trim leading space
for ch in s.runes() {
if ch in unicode_space {
if !in_space {
out.write_u8(` `)
in_space = true
}
} else {
if ch >= `A` && ch <= `Z` {
out.write_u8(u8(ch + 32))
} else {
out.write_string(ch.str())
}
in_space = false
}
}
result := out.str()
// Trim potential trailing space.
if result.ends_with(' ') {
return result[..result.len - 1]
}
return result
}
// ascii_lower converts an ASCII upper-case letter to lower case.
@[inline]
fn ascii_lower(c u8) u8 {
if c >= `A` && c <= `Z` {
return c + 32
}
return c
}
// is_unicode_space returns true for CommonMark Unicode whitespace.
@[inline]
fn is_unicode_space(c rune) bool {
return c in unicode_space
}
// is_ascii_punct returns true if c is an ASCII punctuation character.
@[inline]
fn is_ascii_punct(c rune) bool {
return c <= 0x7f && u8(c) in ascii_punct
}
const digits = [`0`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`]!
// is_digit returns true if c is an ASCII decimal digit.
@[inline]
fn is_digit(c u8) bool {
return c in digits
}
// is_alpha returns true if c is an ASCII letter.
@[inline]
fn is_alpha(c u8) bool {
return c in alpha
}
// is_alnum returns true if c is an ASCII letter or digit.
@[inline]
fn is_alnum(c u8) bool {
return is_alpha(c) || is_digit(c)
}
// heading_id_from_text generates a slug-style id attribute from plain text,
// matching goldmark's AutoHeadingID output.
fn heading_id_from_text(text string) string {
mut sb := strings.new_builder(text.len)
mut prev_dash := true // start true so we trim leading dashes
for ch in text.runes() {
if ch <= 0x7f && is_alnum(u8(ch)) {
sb.write_u8(ascii_lower(u8(ch)))
prev_dash = false
} else if ch == `-` || ch == `_` || ch in unicode_space {
if !prev_dash {
sb.write_u8(`-`)
prev_dash = true
}
}
// other characters (punctuation) are dropped
}
s := sb.str()
// Trim trailing dash.
return s.trim_right('-')
}
// count_leading counts how many consecutive occurrences of c appear at the
// start of s.
@[inline]
fn count_leading(s string, c u8) int {
mut n := 0
for n < s.len && s[n] == c {
n++
}
return n
}
// expand_tabs converts leading tabs in s to spaces (tab stop = 4 columns).
fn expand_tabs(s string) string {
if !s.contains('\t') {
return s
}
mut sb := strings.new_builder(s.len)
mut col := 0
for i := 0; i < s.len; i++ {
if s[i] == `\t` {
spaces := 4 - (col % 4)
for _ in 0 .. spaces {
sb.write_u8(` `)
}
col += spaces
} else {
sb.write_u8(s[i])
col++
}
}
return sb.str()
}
// trim_indent removes up to n leading spaces from s.
@[inline]
fn trim_indent(s string, n int) string {
mut i := 0
for i < n && i < s.len && s[i] == ` ` {
i++
}
return s[i..]
}
// is_blank returns true if s contains only whitespace.
@[inline]
fn is_blank(s string) bool {
for i := 0; i < s.len; i++ {
if s[i] != ` ` && s[i] != `\t` {
return false
}
}
return true
}
// leading_spaces returns the number of leading spaces (not tabs) in s.
@[inline]
fn leading_spaces(s string) int {
mut n := 0
for n < s.len && s[n] == ` ` {
n++
}
return n
}
// smart_punctuate applies typographic substitutions to s:
// -- → en dash, --- → em dash, ... → ellipsis, smart quotes.
fn smart_punctuate(s string) string {
mut out := strings.new_builder(s.len)
i := 0
mut j := i
src := s.bytes()
for j < src.len {
c := src[j]
if c == `-` {
if j + 2 < src.len && src[j + 1] == `-` && src[j + 2] == `-` {
out.write_string('\u2014') // em dash
j += 3
continue
} else if j + 1 < src.len && src[j + 1] == `-` {
out.write_string('\u2013') // en dash
j += 2
continue
}
} else if c == `.` {
if j + 2 < src.len && src[j + 1] == `.` && src[j + 2] == `.` {
out.write_string('\u2026') // ellipsis
j += 3
continue
}
} else if c == `'` {
// Simple heuristic: opening after space/start, closing otherwise.
if j == 0 || is_unicode_space(src[j - 1]) || is_ascii_punct(src[j - 1]) {
out.write_string('\u2018') // left single quote
} else {
out.write_string('\u2019') // right single quote
}
j++
continue
} else if c == `"` {
if j == 0 || is_unicode_space(src[j - 1]) || is_ascii_punct(src[j - 1]) {
out.write_string('\u201C') // left double quote
} else {
out.write_string('\u201D') // right double quote
}
j++
continue
}
out.write_u8(c)
j++
}
return out.str()
}