diff --git a/cmd/web/kaku/LICENSE b/cmd/web/kaku/LICENSE
new file mode 100644
index 0000000..8da4a12
--- /dev/null
+++ b/cmd/web/kaku/LICENSE
@@ -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.
diff --git a/cmd/web/kaku/README.md b/cmd/web/kaku/README.md
new file mode 100644
index 0000000..eb1cd90
--- /dev/null
+++ b/cmd/web/kaku/README.md
@@ -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
+
Heading level 1#
+```
+
+---
+
+## Basic inline formatting
+
+| Syntax | Result |
+|---|---|
+| `*bold*` | **bold** → `` |
+| `_emphasis_` | *emphasis* → `` |
+| `~strike~` | ~~strike~~ → `` |
+| `` `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 ` `.
+
+---
+
+## Code blocks
+
+Triple backticks, with an optional language right after the opening fence:
+
+````
+```go
+func main() {
+ fmt.Println("hello")
+}
+```
+````
+
+Renders as:
+```html
+func main() {
+ fmt.Println("hello")
+}
+```
+
+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 `` 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 `` with a `` and, if any of
+`author`/`source`/`link` are given, a ``.
+
+---
+
+## 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
+Side note
This is a side note, separate from the main flow of the text.
+```
+
+---
+
+## 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 ``/`` 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.
+```
diff --git a/cmd/web/kaku/inline.go b/cmd/web/kaku/inline.go
index 3981933..78aef76 100644
--- a/cmd/web/kaku/inline.go
+++ b/cmd/web/kaku/inline.go
@@ -31,6 +31,7 @@ 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])})
@@ -50,7 +51,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 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 end := findNextRune(runes, i+1, d.rune); end != -1 {
flush()
@@ -79,7 +80,7 @@ func checkDelimeter(r rune) (delimiter, bool) {
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 {
for i := from; i < len(runes); i++ {
if runes[i] == delim {
diff --git a/cmd/web/kaku/lineclassifier.go b/cmd/web/kaku/lineclassifier.go
index 1c04b0d..39eddb0 100644
--- a/cmd/web/kaku/lineclassifier.go
+++ b/cmd/web/kaku/lineclassifier.go
@@ -63,7 +63,8 @@ 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
diff --git a/cmd/web/kaku/parser.go b/cmd/web/kaku/parser.go
index 3af7f69..6a4e633 100644
--- a/cmd/web/kaku/parser.go
+++ b/cmd/web/kaku/parser.go
@@ -23,7 +23,7 @@ func (p *parser) advance() {
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) {
defer func() {
if r := recover(); r != nil {
@@ -71,7 +71,7 @@ func Parse(input string) (doc *Document) {
return &Document{Children: children}
}
-// Returns Heading node
+// Returns Heading struct
func parseHeading(line string) *Heading {
trimmed := strings.TrimSpace(line)
level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
@@ -79,7 +79,9 @@ func parseHeading(line string) *Heading {
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 {
const 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")}
}
-// 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 {
var text []string
for !p.done() {
@@ -113,7 +116,9 @@ func parseParagraph(p *parser) *Paragraph {
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 {
var items []*ListItem
for !p.done() {
@@ -128,7 +133,9 @@ func parseList(p *parser, kind, prefix string) *List {
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 {
const prefix = "? "
var items []*DefItem
@@ -147,7 +154,8 @@ func parseDefList(p *parser) *DefList {
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 {
trimmed := strings.TrimSpace(line)
inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "("), ")")
@@ -201,14 +209,14 @@ func parseKeyedTagLine(line string) Node {
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 {
trimmed := strings.TrimSpace(line)
inner := strings.TrimSuffix(strings.TrimPrefix(trimmed, "{"), "}")
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 {
var items []*TodoItem
for !p.done() {
diff --git a/cmd/web/kaku/render.go b/cmd/web/kaku/render.go
index c060efc..4195d1c 100644
--- a/cmd/web/kaku/render.go
+++ b/cmd/web/kaku/render.go
@@ -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 and render them in HTML
+// Loops over nodes of the document's children to detect node types
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(" ")
+ b.WriteString(" ")
}
- b.WriteString("")
case *Link:
b.WriteString(`")
if len(v.Caption) > 0 {
render(b, v.Caption)
- b.WriteString(` | `)
}
- b.WriteString(`Full size `)
@@ -222,7 +221,6 @@ 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)
@@ -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 {
var b strings.Builder
for _, n := range nodes {
@@ -261,7 +260,8 @@ func plainText(nodes []Node) 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 {
var b strings.Builder
lastWasHyphen := true // avoid a leading hyphen
@@ -281,8 +281,10 @@ func toKebabCase(text string) string {
return strings.TrimRight(b.String(), "-")
}
-// Allows prefixing local/relative image paths and normalizes ".jpeg" extension to ".jpg"
-// Disabled in this version
+// 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.
func imageURL(url string) string {
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return url
diff --git a/cmd/web/kaku/render_test.go b/cmd/web/kaku/render_test.go
new file mode 100644
index 0000000..1ee44d3
--- /dev/null
+++ b/cmd/web/kaku/render_test.go
@@ -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!"}}},
+ }},
+ `Hello World!# `,
+ },
+ "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: "