226 lines
6.2 KiB
Go
226 lines
6.2 KiB
Go
package kaku
|
|
|
|
import "strings"
|
|
|
|
// parser holds the lines being parsed and the current position, so each parse function can advance it without passing an index around.
|
|
type parser struct {
|
|
lines []string
|
|
pos int
|
|
}
|
|
|
|
// Checks if at the end of lines
|
|
func (p *parser) done() bool {
|
|
return p.pos >= len(p.lines)
|
|
}
|
|
|
|
// Returns the current line position
|
|
func (p *parser) current() string {
|
|
return p.lines[p.pos]
|
|
}
|
|
|
|
// Moves the parser to the next line
|
|
func (p *parser) advance() {
|
|
p.pos++
|
|
}
|
|
|
|
// 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 {
|
|
doc = &Document{Children: []Node{&Paragraph{Children: []Node{&Text{Value: input}}}}}
|
|
}
|
|
}()
|
|
|
|
p := &parser{lines: strings.Split(input, "\n")}
|
|
var children []Node
|
|
|
|
for !p.done() {
|
|
switch classifyLine(p.current()) {
|
|
case lineBlank:
|
|
p.advance()
|
|
case lineHR:
|
|
children = append(children, &HR{})
|
|
p.advance()
|
|
case lineHeading:
|
|
children = append(children, parseHeading(p.current()))
|
|
p.advance()
|
|
case lineCodeFence:
|
|
children = append(children, parseCodeBlock(p))
|
|
case lineBullet:
|
|
children = append(children, parseList(p, "ul", "- "))
|
|
case lineOrdered:
|
|
children = append(children, parseList(p, "ol", "+ "))
|
|
case lineDef:
|
|
children = append(children, parseDefList(p))
|
|
case lineKeyedTag:
|
|
children = append(children, parseKeyedTagLine(p.current()))
|
|
p.advance()
|
|
case lineAside:
|
|
children = append(children, parseAside(p.current()))
|
|
p.advance()
|
|
case lineTodo:
|
|
children = append(children, parseTodoList(p))
|
|
case lineHTML:
|
|
children = append(children, &RawHTML{Content: p.current()})
|
|
p.advance()
|
|
default:
|
|
children = append(children, parseParagraph(p))
|
|
}
|
|
}
|
|
|
|
return &Document{Children: children}
|
|
}
|
|
|
|
// Returns Heading node
|
|
func parseHeading(line string) *Heading {
|
|
trimmed := strings.TrimSpace(line)
|
|
level := len(trimmed) - len(strings.TrimLeft(trimmed, "#"))
|
|
text := strings.TrimSpace(trimmed[level:])
|
|
return &Heading{Level: level, Children: parseInline(text)}
|
|
}
|
|
|
|
// 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)
|
|
p.advance() // skip opening fence
|
|
|
|
var content []string
|
|
for !p.done() {
|
|
line := p.current()
|
|
if strings.HasPrefix(strings.TrimSpace(line), fence) {
|
|
break
|
|
}
|
|
content = append(content, line)
|
|
p.advance()
|
|
}
|
|
if !p.done() {
|
|
p.advance() // skip closing fence
|
|
}
|
|
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.
|
|
func parseParagraph(p *parser) *Paragraph {
|
|
var text []string
|
|
for !p.done() {
|
|
if classifyLine(p.current()) != lineParagraph {
|
|
break
|
|
}
|
|
text = append(text, strings.TrimSpace(p.current()))
|
|
p.advance()
|
|
}
|
|
return &Paragraph{Children: parseInline(strings.Join(text, " "))}
|
|
}
|
|
|
|
// 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() {
|
|
line := strings.TrimSpace(p.current())
|
|
if !strings.HasPrefix(line, prefix) {
|
|
break
|
|
}
|
|
text := strings.TrimPrefix(line, prefix)
|
|
items = append(items, &ListItem{Children: parseInline(text)})
|
|
p.advance()
|
|
}
|
|
return &List{Kind: kind, Items: items}
|
|
}
|
|
|
|
// Groups consecutive "? term : definition" lines into a DefList node. Run content through parseInline.
|
|
func parseDefList(p *parser) *DefList {
|
|
const prefix = "? "
|
|
var items []*DefItem
|
|
for !p.done() {
|
|
line := strings.TrimSpace(p.current())
|
|
if !strings.HasPrefix(line, prefix) {
|
|
break
|
|
}
|
|
term, def, _ := strings.Cut(strings.TrimPrefix(line, prefix), ":")
|
|
items = append(items, &DefItem{
|
|
Term: parseInline(strings.TrimSpace(term)),
|
|
Definition: parseInline(strings.TrimSpace(def)),
|
|
})
|
|
p.advance()
|
|
}
|
|
return &DefList{Items: items}
|
|
}
|
|
|
|
// 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, "("), ")")
|
|
|
|
switch {
|
|
case strings.HasPrefix(inner, "quote:"):
|
|
f := parseKeyedTag(inner, []string{"quote", "author", "source", "link"})
|
|
return &Quote{
|
|
Text: parseInline(f["quote"]),
|
|
Author: f["author"],
|
|
Source: f["source"],
|
|
Link: f["link"],
|
|
}
|
|
case strings.HasPrefix(inner, "link:"):
|
|
f := parseKeyedTag(inner, []string{"link", "text", "label", "title"})
|
|
return &Link{
|
|
URL: f["link"],
|
|
Text: parseInline(f["text"]),
|
|
Label: f["label"],
|
|
Title: f["title"]}
|
|
|
|
case strings.HasPrefix(inner, "image:"):
|
|
f := parseKeyedTag(inner, []string{"image", "alt", "figcaption"})
|
|
return &Image{
|
|
URL: f["image"],
|
|
Alt: f["alt"],
|
|
Caption: parseInline(f["figcaption"])}
|
|
|
|
case strings.HasPrefix(inner, "video:"):
|
|
autoplay := strings.Contains(inner, "autoplay")
|
|
cleaned := strings.Replace(inner, "autoplay", "", 1)
|
|
f := parseKeyedTag(cleaned, []string{"video", "figcaption"})
|
|
return &Video{
|
|
URL: strings.TrimSpace(f["video"]),
|
|
Caption: parseInline(f["figcaption"]),
|
|
Autoplay: autoplay,
|
|
}
|
|
|
|
case strings.HasPrefix(inner, "audio:"):
|
|
f := parseKeyedTag(inner, []string{"audio"})
|
|
return &Audio{
|
|
URL: f["audio"]}
|
|
|
|
case strings.HasPrefix(inner, "details:"):
|
|
f := parseKeyedTag(inner, []string{"details", "summary"})
|
|
return &Details{
|
|
Summary: parseInline(f["summary"]),
|
|
Content: parseInline(f["details"]),
|
|
}
|
|
}
|
|
return &Paragraph{Children: parseInline(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)}
|
|
}
|
|
|
|
// Groups consecutive "[X] text" lines into one TodoList node.
|
|
func parseTodoList(p *parser) *TodoList {
|
|
var items []*TodoItem
|
|
for !p.done() {
|
|
line := strings.TrimSpace(p.current())
|
|
if classifyLine(line) != lineTodo {
|
|
break
|
|
}
|
|
items = append(items, &TodoItem{
|
|
State: line[1],
|
|
Children: parseInline(strings.TrimSpace(line[3:])),
|
|
})
|
|
p.advance()
|
|
}
|
|
return &TodoList{Items: items}
|
|
}
|