Compare commits

..
7 changed files with 21 additions and 504 deletions

View file

@ -1,21 +0,0 @@
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.

View file

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

View file

@ -63,8 +63,7 @@ func hasParenthesisPrefix(text string, prefixes []string) bool {
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 {
if len(trimmed) < 3 || trimmed[0] != '[' || trimmed[2] != ']' {
return false

View file

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

View file

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

View file

@ -1,216 +0,0 @@
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)
}
})
}
}