Compare commits

..
7 changed files with 504 additions and 21 deletions

21
cmd/web/kaku/LICENSE Normal file
View file

@ -0,0 +1,21 @@
ANTI-CAPITALIST SOFTWARE LICENSE (v 1.4)
Copyright © 2026 Thomasorus
This is anti-capitalist software, released for free use by individuals and organizations that do not operate by capitalist principles.
Permission is hereby granted, free of charge, to any person or organization (the "User") obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, merge, distribute, and/or sell copies of the Software, subject to the following conditions:
1. The above copyright notice and this permission notice shall be included in all copies or modified versions of the Software.
2. The User is one of the following:
a. An individual person, laboring for themselves
b. A non-profit organization
c. An educational institution
d. An organization that seeks shared profit for all of its members, and allows non-members to set the cost of their labor
3. If the User is an organization with owners, then all owners are workers and all workers are owners with equal equity and/or equal vote.
4. If the User is an organization, then the User is not law enforcement or military, or working for or under either.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT EXPRESS OR IMPLIED WARRANTY OF ANY KIND, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

234
cmd/web/kaku/README.md Normal file
View file

@ -0,0 +1,234 @@
# Kaku 書く
Kaku (write) is my own markup language. It's inspired by Markdown with a few modification for quotes, links, images and lists handling, and more. It was created to fit my needs. This repositoty contains a parser for Kaku.
## License
Kaku 書く is a free to use by individuals and organizations that do not operate by capitalist principles. For more information see the [license](LICENSE) file.
---
## Headings
`#` through `######` for heading levels 1 through 6. Each heading gets an
auto-generated `id` and a clickable permalink anchor.
```
# Heading level 1
## Heading level 2
###### Heading level 6
```
Renders as:
```html
<h1 id="heading-level-1">Heading level 1<a href="#heading-level-1" ...>#</a></h1>
```
---
## Basic inline formatting
| Syntax | Result |
|---|---|
| `*bold*` | **bold** → `<strong>` |
| `_emphasis_` | *emphasis* → `<em>` |
| `~strike~` | ~~strike~~ → `<del>` |
| `` `code` `` | `code` → `<code>` |
```
This is *bold*, this is _emphasis_, this is ~strikethrough~, and this is `inline code`.
```
Inline formatting can be combined with links and images (see below).
---
## Horizontal rule
Four dashes on their own line:
```
----
```
Renders as `<hr>`.
---
## Code blocks
Triple backticks, with an optional language right after the opening fence:
````
```go
func main() {
fmt.Println("hello")
}
```
````
Renders as:
```html
<pre><code class="language-go">func main() {
fmt.Println("hello")
}</code></pre>
```
Content inside a code block is never parsed for other Kaku syntax — everything
stays literal.
---
## Lists
### Bullet list
```
- First item
- Second item
- Third item
```
### Ordered list
```
+ First item
+ Second item
+ Third item
```
### Definition list
```
? Term one : Its definition
? Term two : Its definition
```
List item text supports inline formatting (bold, links, etc.):
```
- Check out (link: https://example.com text: this link)
```
---
## Links
```
(link: https://example.com text: Click here)
(link: https://example.com text: Click here title: Hover tooltip)
(link: https://example.com text: Click here label: Accessible label)
```
Fields: `link` (required), `text`, `label`, `title` — order doesn't matter,
all except `link` are optional.
Links also work **inline**, inside a paragraph or list item:
```
Check out (link: https://example.com text: this) for more info.
```
---
## Images
```
(image: photo.jpg alt: A description of the photo)
(image: photo.jpg alt: A description figcaption: An optional caption)
```
Fields: `image` (required), `alt`, `figcaption`.
Images work inline too, and can be made clickable by nesting inside a link:
```
(link: https://example.com text: (image: photo.jpg alt: A photo))
```
---
## Video
```
(video: clip.mp4)
(video: clip.mp4 autoplay)
```
Plain video gets standard playback controls. Adding `autoplay` makes it behave
like a looping, muted, GIF-style clip instead (`autoplay loop muted playsinline`).
---
## Audio
```
(audio: track.mp3)
```
Renders as an `<audio>` element with standard controls.
---
## Quotes
```
(quote: A quote can stand on its own.)
(quote: With an author. author: Jane Doe)
(quote: With a source too. author: Jane Doe source: Her Book)
(quote: With a link as well. author: Jane Doe source: Her Book link: https://example.com)
```
Fields: `quote` (required), `author`, `source`, `link` — all optional except
`quote`. Renders as a `<figure>` with a `<blockquote>` and, if any of
`author`/`source`/`link` are given, a `<figcaption>`.
---
## Asides
A line wrapped entirely in curly braces becomes a side note:
```
{This is a side note, separate from the main flow of the text.}
```
Renders as:
```html
<aside><p>Side note</p>This is a side note, separate from the main flow of the text.</aside>
```
---
## Details / collapsible sections
```
(details: The hidden content goes here. summary: Click to expand)
```
Fields: `details` (the hidden content), `summary` (the always-visible label).
Renders as a native `<details>`/`<summary>` block.
---
## Todo lists
```
[ ] Not started
[@] In progress
[~] Paused or abandoned
[x] Completed
```
Consecutive todo lines are grouped into a single list. Each state renders
with a visually-hidden accessible label (`Opened task`, `Ongoing task`,
`Paused or abandonned task`, `Completed task`).
---
## Paragraphs
Any line that isn't one of the above is treated as regular paragraph text.
Consecutive lines are joined into a single paragraph; a blank line starts a
new one.
```
This is the first line of a paragraph.
This is the second line, joined to the first.
This is a new paragraph.
```

View file

@ -31,6 +31,7 @@ func parseInline(text string) []Node {
for i := 0; i < len(runes); { for i := 0; i < len(runes); {
switch runes[i] { switch runes[i] {
case '`': case '`':
// Look ahead for matching backtick and if it exists creates a code node
if end := findNextRune(runes, i+1, '`'); end != -1 { if end := findNextRune(runes, i+1, '`'); end != -1 {
flush() flush()
nodes = append(nodes, &Code{Value: string(runes[i+1 : end])}) nodes = append(nodes, &Code{Value: string(runes[i+1 : end])})
@ -50,7 +51,7 @@ func parseInline(text string) []Node {
} }
default: default:
// Checks if the rune is part of delimeters, if yes look ahead for matching tag and if it exists creates a node // Checks if the rune is part of delimeters, if yes look ahead for matching tag and if it exists creates a code node
if d, ok := checkDelimeter(runes[i]); ok { if d, ok := checkDelimeter(runes[i]); ok {
if end := findNextRune(runes, i+1, d.rune); end != -1 { if end := findNextRune(runes, i+1, d.rune); end != -1 {
flush() flush()
@ -79,7 +80,7 @@ func checkDelimeter(r rune) (delimiter, bool) {
return delimiter{}, false return delimiter{}, false
} }
// Find the position of the next occurrence of a given rune, or -1 if none. // Finds the position of the next occurrence of a given rune, or -1 if none.
func findNextRune(runes []rune, from int, delim rune) int { func findNextRune(runes []rune, from int, delim rune) int {
for i := from; i < len(runes); i++ { for i := from; i < len(runes); i++ {
if runes[i] == delim { if runes[i] == delim {

View file

@ -63,7 +63,8 @@ func hasParenthesisPrefix(text string, prefixes []string) bool {
return false return false
} }
// isTodoLine reports whether the line matches "[X] text" where X is one of the known todo state characters. // isTodoLine reports whether the line matches "[X] text" where X is one
// of the known todo state characters.
func isTodoLine(trimmed string) bool { func isTodoLine(trimmed string) bool {
if len(trimmed) < 3 || trimmed[0] != '[' || trimmed[2] != ']' { if len(trimmed) < 3 || trimmed[0] != '[' || trimmed[2] != ']' {
return false return false

View file

@ -23,7 +23,7 @@ func (p *parser) advance() {
p.pos++ p.pos++
} }
// Parse turns raw Kaku source into a Document, which has Node as children. // Parse turns raw Kaku source into a Document.
func Parse(input string) (doc *Document) { func Parse(input string) (doc *Document) {
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
@ -71,7 +71,7 @@ func Parse(input string) (doc *Document) {
return &Document{Children: children} return &Document{Children: children}
} }
// Returns Heading node // Returns Heading struct
func parseHeading(line string) *Heading { func parseHeading(line string) *Heading {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
level := len(trimmed) - len(strings.TrimLeft(trimmed, "#")) level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
@ -79,7 +79,9 @@ func parseHeading(line string) *Heading {
return &Heading{Level: level, Children: parseInline(text)} return &Heading{Level: level, Children: parseInline(text)}
} }
// Returns CodeBlock node and doesn't parse text between delimeters // parseCodeBlock consumes lines from the opening ``` fence to the closing
// ``` fence, and returns a CodeBlock with the language (if given on the
// opening fence) and the raw, un-parsed content between them.
func parseCodeBlock(p *parser) *CodeBlock { func parseCodeBlock(p *parser) *CodeBlock {
const fence = "```" const fence = "```"
lang := strings.TrimPrefix(strings.TrimSpace(p.current()), fence) lang := strings.TrimPrefix(strings.TrimSpace(p.current()), fence)
@ -100,7 +102,8 @@ func parseCodeBlock(p *parser) *CodeBlock {
return &CodeBlock{Language: lang, Content: strings.Join(content, "\n")} return &CodeBlock{Language: lang, Content: strings.Join(content, "\n")}
} }
// Joins consecutive plain lines into one paragraph node until a blank line or a different kind of block starts. // parseParagraph joins consecutive plain lines into one Paragraph, until
// a blank line or a different kind of block starts.
func parseParagraph(p *parser) *Paragraph { func parseParagraph(p *parser) *Paragraph {
var text []string var text []string
for !p.done() { for !p.done() {
@ -113,7 +116,9 @@ func parseParagraph(p *parser) *Paragraph {
return &Paragraph{Children: parseInline(strings.Join(text, " "))} return &Paragraph{Children: parseInline(strings.Join(text, " "))}
} }
// Groups consecutive lines starting with - or + as List node and run content through parseInline // parseList groups consecutive lines starting with prefix into a List of
// the given kind ("ul" or "ol"), running each item's text through
// parseInline so bold/links etc. work inside list items.
func parseList(p *parser, kind, prefix string) *List { func parseList(p *parser, kind, prefix string) *List {
var items []*ListItem var items []*ListItem
for !p.done() { for !p.done() {
@ -128,7 +133,9 @@ func parseList(p *parser, kind, prefix string) *List {
return &List{Kind: kind, Items: items} return &List{Kind: kind, Items: items}
} }
// Groups consecutive "? term : definition" lines into a DefList node. Run content through parseInline. // parseDefList groups consecutive "? term : definition" lines into a
// DefList. Each line is split on the first ":" into term and definition,
// both run through parseInline.
func parseDefList(p *parser) *DefList { func parseDefList(p *parser) *DefList {
const prefix = "? " const prefix = "? "
var items []*DefItem var items []*DefItem
@ -147,7 +154,8 @@ func parseDefList(p *parser) *DefList {
return &DefList{Items: items} return &DefList{Items: items}
} }
// Builds a Quote, Link, Image, Video, or Audio node from a single "(keyword: ...)" line. // parseKeyedTagLine builds a Quote, Link, Image, Video, or Audio node
// from a single "(keyword: ...)" line.
func parseKeyedTagLine(line string) Node { func parseKeyedTagLine(line string) Node {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "("), ")") inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "("), ")")
@ -201,14 +209,14 @@ func parseKeyedTagLine(line string) Node {
return &Paragraph{Children: parseInline(line)} return &Paragraph{Children: parseInline(line)}
} }
// Builds an Aside node from a single "{ ... }" line. // parseAside builds an Aside from a single "{ ... }" line.
func parseAside(line string) *Aside { func parseAside(line string) *Aside {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "{"), "}") inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "{"), "}")
return &Aside{Children: parseInline(inner)} return &Aside{Children: parseInline(inner)}
} }
// Groups consecutive "[X] text" lines into one TodoList node. // parseTodoList groups consecutive "[X] text" lines into one TodoList.
func parseTodoList(p *parser) *TodoList { func parseTodoList(p *parser) *TodoList {
var items []*TodoItem var items []*TodoItem
for !p.done() { for !p.done() {

View file

@ -13,7 +13,7 @@ func (document *Document) Render() template.HTML {
return template.HTML(b.String()) return template.HTML(b.String())
} }
// Loops over nodes of the document's children to detect node types and render them in HTML // Loops over nodes of the document's children to detect node types
func render(b *strings.Builder, nodes []Node) { func render(b *strings.Builder, nodes []Node) {
for _, n := range nodes { for _, n := range nodes {
switch v := n.(type) { switch v := n.(type) {
@ -111,8 +111,8 @@ func render(b *strings.Builder, nodes []Node) {
} }
} }
b.WriteString("</figcaption>") b.WriteString("</figcaption>")
b.WriteString("</figure>")
} }
b.WriteString("</figure>")
case *Link: case *Link:
b.WriteString(`<a href="`) b.WriteString(`<a href="`)
b.WriteString(html.EscapeString(safeUrl(v.URL))) b.WriteString(html.EscapeString(safeUrl(v.URL)))
@ -141,9 +141,8 @@ func render(b *strings.Builder, nodes []Node) {
b.WriteString("<figcaption>") b.WriteString("<figcaption>")
if len(v.Caption) > 0 { if len(v.Caption) > 0 {
render(b, v.Caption) render(b, v.Caption)
b.WriteString(` | `)
} }
b.WriteString(`<small><a href="/uploaded/`) b.WriteString(` | <small><a href="/uploaded/`)
b.WriteString(html.EscapeString(safeUrl(v.URL))) b.WriteString(html.EscapeString(safeUrl(v.URL)))
b.WriteString(`">Full size</a></small>`) b.WriteString(`">Full size</a></small>`)
@ -222,7 +221,6 @@ func wrap(b *strings.Builder, tag string, children []Node) {
b.WriteString(">") b.WriteString(">")
} }
// Sanitizes urls for href and src attributes
func safeUrl(url string) string { func safeUrl(url string) string {
trimmed := strings.TrimSpace(url) trimmed := strings.TrimSpace(url)
@ -239,7 +237,8 @@ func safeUrl(url string) string {
} }
} }
// Flattens inline nodes down to their visible text, ignoring markup like Bold/Em/Strike/Code. Used to create IDs or Aria Label text // plainText flattens inline nodes down to their visible text, ignoring
// markup like Bold/Em/Strike/Code — just the words themselves.
func plainText(nodes []Node) string { func plainText(nodes []Node) string {
var b strings.Builder var b strings.Builder
for _, n := range nodes { for _, n := range nodes {
@ -261,7 +260,8 @@ func plainText(nodes []Node) string {
return b.String() return b.String()
} }
// Converts text into a lowercase hyphen-separated slug // toKebabCase converts text into a lowercase, hyphen-separated slug
// suitable for an HTML id (e.g. "Hello, World!" -> "hello-world").
func toKebabCase(text string) string { func toKebabCase(text string) string {
var b strings.Builder var b strings.Builder
lastWasHyphen := true // avoid a leading hyphen lastWasHyphen := true // avoid a leading hyphen
@ -281,8 +281,10 @@ func toKebabCase(text string) string {
return strings.TrimRight(b.String(), "-") return strings.TrimRight(b.String(), "-")
} }
// Allows prefixing local/relative image paths and normalizes ".jpeg" extension to ".jpg" // imageURL prefixes local/relative image paths with "/processed/" so
// Disabled in this version // they're served from the processed-images folder, and normalizes a
// local ".jpeg" extension to ".jpg". Full http(s) URLs are left
// untouched, since they already point somewhere external.
func imageURL(url string) string { func imageURL(url string) string {
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") { if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return url return url

216
cmd/web/kaku/render_test.go Normal file
View file

@ -0,0 +1,216 @@
package kaku
import "testing"
func TestRender(t *testing.T) {
tests := map[string]struct {
doc *Document
want string
}{
"heading with anchor": {
&Document{Children: []Node{
&Heading{Level: 2, Children: []Node{&Text{Value: "Hello World!"}}},
}},
`<h2 id="hello-world">Hello World!<a href="#hello-world" aria-label="Hello World! permalink" class="heading-anchor" title="Link to this heading">#</a></h2>`,
},
"paragraph + bold + em + strike + code": {
&Document{Children: []Node{
&Paragraph{Children: []Node{
&Bold{Children: []Node{&Text{Value: "bold"}}},
&Text{Value: " and "},
&Em{Children: []Node{&Text{Value: "emphasis"}}},
&Text{Value: " and "},
&Strike{Children: []Node{&Text{Value: "strike"}}},
&Text{Value: " and "},
&Code{Value: "<style>"},
}},
}},
"<p><strong>bold</strong> and <em>emphasis</em> and <del>strike</del> and <code>&lt;style&gt;</code></p>",
},
"hr": {
&Document{Children: []Node{&HR{}}},
"<hr>",
},
"escaping": {
&Document{Children: []Node{
&Paragraph{Children: []Node{&Text{Value: "<script>"}}},
}},
"<p>&lt;script&gt;</p>",
},
"codeBlock": {
&Document{Children: []Node{
&CodeBlock{Content: "<script>const hello = Hello</script>"},
}},
"<pre><code>&lt;script&gt;const hello = Hello&lt;/script&gt;</code></pre>",
},
"bullet list": {
&Document{Children: []Node{
&List{Kind: "ul", Items: []*ListItem{
{Children: []Node{&Text{Value: "AAA"}}},
{Children: []Node{&Text{Value: "BBB"}}},
{Children: []Node{&Text{Value: "CCC"}}},
}},
}},
"<ul><li>AAA</li><li>BBB</li><li>CCC</li></ul>",
},
"list with bold": {
&Document{Children: []Node{
&List{Kind: "ul", Items: []*ListItem{
{Children: []Node{
&Bold{Children: []Node{&Text{Value: "AAA"}}},
}},
{Children: []Node{&Text{Value: "BBB"}}},
}},
}},
"<ul><li><strong>AAA</strong></li><li>BBB</li></ul>",
},
"ordered list": {
&Document{Children: []Node{
&List{Kind: "ol", Items: []*ListItem{
{Children: []Node{&Text{Value: "Number 1"}}},
{Children: []Node{&Text{Value: "Number 2"}}},
{Children: []Node{&Text{Value: "Number 3"}}},
}},
}},
"<ol><li>Number 1</li><li>Number 2</li><li>Number 3</li></ol>",
},
"definition list": {
&Document{Children: []Node{
&DefList{Items: []*DefItem{
{
Term: []Node{&Text{Value: "Term 1"}},
Definition: []Node{&Text{Value: "definition 1"}},
},
{
Term: []Node{&Text{Value: "Term 2"}},
Definition: []Node{&Text{Value: "definition 2"}},
},
}},
}},
"<dl><dt>Term 1</dt><dd>definition 1</dd><dt>Term 2</dt><dd>definition 2</dd></dl>",
},
"quote full": {
&Document{Children: []Node{
&Quote{
Text: []Node{&Text{Value: "And even, quotes!"}},
Author: "Author",
Source: "Source of quote",
Link: "url_of_quote",
},
}},
`<figure><blockquote cite="url_of_quote">And even, quotes!</blockquote><figcaption>—Author, <a href="url_of_quote">Source of quote</a></figcaption></figure>`,
},
"link full": {
&Document{Children: []Node{
&Link{
URL: "https://git.merveilles.town/thomasorus/Kaku",
Text: []Node{&Text{Value: "Check this link to the repo!"}},
Label: "Link to Kaku's repo",
Title: "Hover title",
},
}},
`<a href="https://git.merveilles.town/thomasorus/Kaku" aria-label="Link to Kaku&#39;s repo" title="Hover title">Check this link to the repo!</a>`,
},
"link minimal": {
&Document{Children: []Node{
&Link{URL: "https://example.com", Text: []Node{&Text{Value: "Example"}}},
}},
`<a href="https://example.com">Example</a>`,
},
"image with alt": {
&Document{Children: []Node{
&Image{URL: "img_url", Alt: "the alternate text"},
}},
`<img src="img_url" alt="the alternate text">`,
},
"image with caption": {
&Document{Children: []Node{
&Image{
URL: "img_url",
Alt: "the alternate text",
Caption: []Node{&Text{Value: "the text under the image"}},
},
}},
`<figure><img src="img_url" alt="the alternate text"><figcaption>the text under the image</figcaption></figure>`,
},
"paragraph with inline link": {
&Document{Children: []Node{
&Paragraph{Children: []Node{
&Text{Value: "Check out "},
&Link{URL: "https://example.com", Text: []Node{&Text{Value: "Example"}}},
&Text{Value: " for more."},
}},
}},
`<p>Check out <a href="https://example.com">Example</a> for more.</p>`,
},
"paragraph with inline image": {
&Document{Children: []Node{
&Paragraph{Children: []Node{
&Text{Value: "Look: "},
&Image{URL: "img_url", Alt: "alt text"},
}},
}},
`<p>Look: <img src="img_url" alt="alt text"></p>`,
},
"video plain": {
&Document{Children: []Node{&Video{URL: "videoUrl"}}},
`<video controls="" preload="metadata" src="videoUrl" type="video/mp4"></video>`,
},
"video autoplay/gif": {
&Document{Children: []Node{&Video{URL: "videoUrl", Autoplay: true}}},
`<video autoplay="true" playsinline="true" loop="true" muted="true" preload="metadata" src="videoUrl" type="video/mp4"></video>`,
},
"video with autoplay and figcaption": {
&Document{Children: []Node{
&Video{
URL: "aesthetics010.mp4",
Caption: []Node{&Text{Value: "Art by "}, &Link{URL: "https://twitter.com/lvl374", Text: []Node{&Text{Value: "lvl374"}}}},
Autoplay: true,
},
}},
`<figure><video autoplay="true" playsinline="true" loop="true" muted="true" preload="metadata" src="aesthetics010.mp4" type="video/mp4"></video><figcaption>Art by <a href="https://twitter.com/lvl374">lvl374</a></figcaption></figure>`,
},
"audio": {
&Document{Children: []Node{&Audio{URL: "audioUrl"}}},
`<audio controls="" preload="metadata" src="audioUrl" type="audio/mpeg"></audio>`,
},
"aside": {
&Document{Children: []Node{
&Aside{Children: []Node{&Text{Value: "A side note"}}},
}},
"<aside><p>Side note</p>A side note</aside>",
},
"details": {
&Document{Children: []Node{
&Details{
Summary: []Node{&Text{Value: "Click to expand"}},
Content: []Node{&Text{Value: "Hidden content here"}},
},
}},
"<details><summary>Click to expand</summary>Hidden content here</details>",
},
"todo list": {
&Document{Children: []Node{
&TodoList{Items: []*TodoItem{
{State: ' ', Children: []Node{&Text{Value: "Not started"}}},
{State: '@', Children: []Node{&Text{Value: "In progress"}}},
{State: '~', Children: []Node{&Text{Value: "Paused"}}},
{State: 'x', Children: []Node{&Text{Value: "Done"}}},
}},
}},
`<ul><li><code aria-hidden="true">[ ]</code><span class="visually-hidden">Opened task</span> Not started</li>` +
`<li><code aria-hidden="true">[@]</code><span class="visually-hidden">Ongoing task</span> In progress</li>` +
`<li><code aria-hidden="true">[~]</code><span class="visually-hidden">Paused or abandonned task</span> Paused</li>` +
`<li><code aria-hidden="true">[x]</code><span class="visually-hidden">Completed task</span> Done</li></ul>`,
},
}
for name, c := range tests {
t.Run(name, func(t *testing.T) {
got := c.doc.Render()
if string(got) != c.want {
t.Errorf("got:\n %s\nwant:\n %s", got, c.want)
}
})
}
}