Initial project import
This commit is contained in:
commit
23f2d29e32
11957 files changed
+1754577
No files matched your search
@@ -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('')
|
||||
```
|
||||
|
||||
### 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)
|
||||
@@ -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{},
|
||||
]
|
||||
}
|
||||
@@ -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)}">↩</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}>')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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('<')
|
||||
}
|
||||
|
||||
fn test_named_entities_are_decoded_before_render() {
|
||||
assert to_html('©') == '<p>©</p>\n'
|
||||
assert to_html('&') == '<p>&</p>\n'
|
||||
}
|
||||
|
||||
fn test_unknown_named_entity_is_left_as_literal_text() {
|
||||
assert to_html('¬_a_real_entity;') == '<p>&not_a_real_entity;</p>\n'
|
||||
}
|
||||
|
||||
fn test_numeric_entities_are_decoded() {
|
||||
assert to_html('© ©') == '<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">↩</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">↩</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')
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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('&') }
|
||||
`<` { sb.write_string('<') }
|
||||
`>` { sb.write_string('>') }
|
||||
`"` { sb.write_string('"') }
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user