Ronbun/cmd/web/kaku/parser.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}
}