~erock/pico

0e93909d4d244164f7fb6c9342ce7d8a0c956dbd — Eric Bower 4 days ago 880b572
feat(lists): nested lists

Nested lists adds more complexity to our parser but the time complexity
is still `O(n)` where `n = lines in the list`.
4 files changed, 164 insertions(+), 65 deletions(-)

M lists/html/list.partial.tmpl
M lists/html/spec.page.tmpl
M lists/parser.go
M shared/api.go
M lists/html/list.partial.tmpl => lists/html/list.partial.tmpl +21 -5
@@ 1,22 1,38 @@
{{define "list"}}
{{$indent := 0}}
{{$mod := 0}}
<ul style="list-style-type: {{.ListType}};">
    {{range .Items}}
        {{if lt $indent .Indent}}
        <ul style="list-style-type: {{$.ListType}};">
        {{else if gt $indent .Indent}}

        {{$mod = minus $indent .Indent}}
        {{range $y := intRange 1 $mod}}
        </li></ul>
        {{end}}

        {{else}}
        </li>
        {{end}}
        {{$indent = .Indent}}

        {{if .IsText}}
            {{if .Value}}
            <li>{{.Value}}</li>
            <li>{{.Value}}
            {{end}}
        {{end}}

        {{if .IsURL}}
        <li><a href="{{.URL}}">{{.Value}}</a></li>
        <li><a href="{{.URL}}">{{.Value}}</a>
        {{end}}

        {{if .IsImg}}
        <li><img src="{{.URL}}" alt="{{.Value}}" /></li>
        <li><img src="{{.URL}}" alt="{{.Value}}" />
        {{end}}

        {{if .IsBlock}}
        <li><blockquote>{{.Value}}</blockquote></li>
        <li><blockquote>{{.Value}}</blockquote>
        {{end}}

        {{if .IsHeaderOne}}


@@ 28,7 44,7 @@
        {{end}}

        {{if .IsPre}}
        <li><pre>{{.Value}}</pre></li>
        <li><pre>{{.Value}}</pre>
        {{end}}
    {{end}}
</ul>

M lists/html/spec.page.tmpl => lists/html/spec.page.tmpl +57 -15
@@ 12,7 12,7 @@
    <h2 class="text-xl">Speculative specification</h2>
    <dl>
        <dt>Version</dt>
        <dd>2022.05.02.dev</dd>
        <dd>2022.08.05.dev</dd>

        <dt>Status</dt>
        <dd>Draft</dd>


@@ 41,16 41,15 @@

        <p>
            The source code for our parser can be found
            <a href="https://github.com/neurosnap/lists.sh/blob/main/pkg/parser.go">here</a>.
        </p>

        <p>
            The source code for an example list demonstrating all the features can be found
            <a href="https://github.com/neurosnap/lists-official-blog/blob/main/spec-example.txt">here</a>.
            <a href="https://git.sr.ht/~erock/pico/tree/main/item/lists/parser.go">here</a>.
        </p>
    </section>

    <section id="parameters">
        <h2 class="text-xl">
            <a href="#parameters" rel="nofollow noopener">#</a>
            Parameters
        </h2>
        <p>
            As a subtype of the top-level media type "text", "text/plain" inherits the "charset"
            parameter defined in <a href="https://datatracker.ietf.org/doc/html/rfc2046#section-4.1">RFC 2046</a>.


@@ 59,6 58,10 @@
    </section>

    <section id="line-orientation">
        <h2 class="text-xl">
            <a href="#line-orientation" rel="nofollow noopener">#</a>
            Line orientation
        </h2>
        <p>
            As mentioned, the text format is line-oriented. Each line of a document has a single
            "line type". It is possible to unambiguously determine a line's type purely by


@@ 69,7 72,10 @@
    </section>

    <section id="file-extensions">
        <h2 class="text-xl">File extension</h2>
        <h2 class="text-xl">
            <a href="#file-extensions" rel="nofollow noopener">#</a>
            File extension
        </h2>
        <p>
            {{.Site.Domain}} only supports the <code>.txt</code> file extension and will
            ignore all other file extensions.


@@ 77,7 83,10 @@
    </section>

    <section id="list-item">
        <h2 class="text-xl">List item</h2>
        <h2 class="text-xl">
            <a href="#list-item" rel="nofollow noopener">#</a>
            List item
        </h2>
        <p>
            List items are separated by newline characters <code>\n</code>.
            Each list item is on its own line.  A list item does not require any special formatting.


@@ 90,7 99,10 @@
    </section>

    <section id="hyperlinks">
        <h2 class="text-xl">Hyperlinks</h2>
        <h2 class="text-xl">
            <a href="#hyperlinks" rel="nofollow noopener">#</a>
            Hyperlinks
        </h2>
        <p>
            Hyperlinks are denoted by the prefix <code>=></code>.  The following text should then be
            the hyperlink.


@@ 100,8 112,26 @@
        <pre>=> https://{{.Site.Domain}} microblog for lists</pre>
    </section>

    <section id="nested-lists">
        <h2 class="text-xl">
            <a href="#nested-lists" rel="nofollow noopener">#</a>
            Nested lists
        </h2>
        <p>
            Users can create nested lists.  Tabbing a list will nest it under the list item
            directly above it.  Both tab character `\t` or whitespace as tabs are permitted.
        </p>
        <pre>first item
    second item
        third item
last item</pre>
    </section>

    <section id="images">
        <h2 class="text-xl">Images</h2>
        <h2 class="text-xl">
            <a href="#hyperlinks" rel="nofollow noopener">#</a>
            Images
        </h2>
        <p>
            List items can be represented as images by prefixing the line with <code>=<</code>.
        </p>


@@ 111,7 141,10 @@
    </section>

    <section id="headers">
        <h2 class="text-xl">Headers</h2>
        <h2 class="text-xl">
            <a href="#headers" rel="nofollow noopener">#</a>
            Headers
        </h2>
        <p>
            List items can be represented as headers.  We support two headers currently.  Headers
            will end the previous list and then create a new one after it.  This allows a single


@@ 122,7 155,10 @@
    </section>

    <section id="blockquotes">
        <h2 class="text-xl">Blockquotes</h2>
        <h2 class="text-xl">
            <a href="#headers" rel="nofollow noopener">#</a>
            Blockquotes
        </h2>
        <p>
            List items can be represented as blockquotes.
        </p>


@@ 130,7 166,10 @@
    </section>

    <section id="preformatted">
        <h2 class="text-xl">Preformatted</h2>
        <h2 class="text-xl">
            <a href="#preformatted" rel="nofollow noopener">#</a>
            Preformatted
        </h2>
        <p>
            List items can be represented as preformatted text where newline characters are not
            considered part of new list items.  They can be represented by prefixing the line with


@@ 154,7 193,10 @@ echo "This will not render properly"```</pre>
    </section>

    <section id="variables">
        <h2 class="text-xl">Variables</h2>
        <h2 class="text-xl">
            <a href="#variables" rel="nofollow noopener">#</a>
            Variables
        </h2>
        <p>
            Variables allow us to store metadata within our system.  Variables are list items with
            key value pairs denoted by <code>=:</code> followed by the key, a whitespace character,

M lists/parser.go => lists/parser.go +66 -44
@@ 3,15 3,18 @@ package lists
import (
	"fmt"
	"html/template"
	"regexp"
	"strings"
	"time"

	"github.com/araddon/dateparse"
)

var reIndent = regexp.MustCompile(`^[[:blank:]]+`)

type ParsedText struct {
	Items    []*ListItem
	MetaData *MetaData
	Items []*ListItem
	*MetaData
}

type ListItem struct {


@@ 25,6 28,7 @@ type ListItem struct {
	IsHeaderTwo bool
	IsImg       bool
	IsPre       bool
	Indent      int
}

type MetaData struct {


@@ 107,6 111,63 @@ func KeyAsValue(token *SplitToken) string {
	return token.Value
}

func parseItem(meta *MetaData, li *ListItem, prevItem *ListItem, pre bool, mod int) (bool, bool, int) {
	skip := false

	if strings.HasPrefix(li.Value, preToken) {
		pre = !pre
		if pre {
			nextValue := strings.Replace(li.Value, preToken, "", 1)
			li.IsPre = true
			li.Value = nextValue
		} else {
			skip = true
		}
	} else if pre {
		nextValue := strings.Replace(li.Value, preToken, "", 1)
		prevItem.Value = fmt.Sprintf("%s\n%s", prevItem.Value, nextValue)
		skip = true
	} else if strings.HasPrefix(li.Value, urlToken) {
		li.IsURL = true
		split := TextToSplitToken(strings.Replace(li.Value, urlToken, "", 1))
		li.URL = template.URL(split.Key)
		li.Value = KeyAsValue(split)
	} else if strings.HasPrefix(li.Value, blockToken) {
		li.IsBlock = true
		li.Value = strings.Replace(li.Value, blockToken, "", 1)
	} else if strings.HasPrefix(li.Value, imgToken) {
		li.IsImg = true
		split := TextToSplitToken(strings.Replace(li.Value, imgToken, "", 1))
		li.URL = template.URL(split.Key)
		li.Value = KeyAsValue(split)
	} else if strings.HasPrefix(li.Value, varToken) {
		split := TextToSplitToken(strings.Replace(li.Value, varToken, "", 1))
		TokenToMetaField(meta, split)
	} else if strings.HasPrefix(li.Value, headerTwoToken) {
		li.IsHeaderTwo = true
		li.Value = strings.Replace(li.Value, headerTwoToken, "", 1)
	} else if strings.HasPrefix(li.Value, headerOneToken) {
		li.IsHeaderOne = true
		li.Value = strings.Replace(li.Value, headerOneToken, "", 1)
	} else if reIndent.MatchString(li.Value) {
		trim := reIndent.ReplaceAllString(li.Value, "")
		old := len(li.Value)
		li.Value = trim

		pre, skip, _ = parseItem(meta, li, prevItem, pre, mod)
		if prevItem.Indent == 0 {
			mod = old - len(trim)
			li.Indent = 1
		} else {
			li.Indent = (old - len(trim)) / mod
		}
	} else {
		li.IsText = true
	}

	return pre, skip, mod
}

func ParseText(text string) *ParsedText {
	textItems := SplitByNewline(text)
	items := []*ListItem{}


@@ 116,58 177,19 @@ func ParseText(text string) *ParsedText {
	}
	pre := false
	skip := false
	mod := 0
	var prevItem *ListItem

	for _, t := range textItems {
		skip = false

		if len(items) > 0 {
			prevItem = items[len(items)-1]
		}

		li := ListItem{
			Value: strings.Trim(t, " "),
			Value: t,
		}

		if strings.HasPrefix(li.Value, preToken) {
			pre = !pre
			if pre {
				nextValue := strings.Replace(li.Value, preToken, "", 1)
				li.IsPre = true
				li.Value = nextValue
			} else {
				skip = true
			}
		} else if pre {
			nextValue := strings.Replace(li.Value, preToken, "", 1)
			prevItem.Value = fmt.Sprintf("%s\n%s", prevItem.Value, nextValue)
			skip = true
		} else if strings.HasPrefix(li.Value, urlToken) {
			li.IsURL = true
			split := TextToSplitToken(strings.Replace(li.Value, urlToken, "", 1))
			li.URL = template.URL(split.Key)
			li.Value = KeyAsValue(split)
		} else if strings.HasPrefix(li.Value, blockToken) {
			li.IsBlock = true
			li.Value = strings.Replace(li.Value, blockToken, "", 1)
		} else if strings.HasPrefix(li.Value, imgToken) {
			li.IsImg = true
			split := TextToSplitToken(strings.Replace(li.Value, imgToken, "", 1))
			li.URL = template.URL(split.Key)
			li.Value = KeyAsValue(split)
		} else if strings.HasPrefix(li.Value, varToken) {
			split := TextToSplitToken(strings.Replace(li.Value, varToken, "", 1))
			TokenToMetaField(&meta, split)
			continue
		} else if strings.HasPrefix(li.Value, headerTwoToken) {
			li.IsHeaderTwo = true
			li.Value = strings.Replace(li.Value, headerTwoToken, "", 1)
		} else if strings.HasPrefix(li.Value, headerOneToken) {
			li.IsHeaderOne = true
			li.Value = strings.Replace(li.Value, headerOneToken, "", 1)
		} else {
			li.IsText = true
		}
		pre, skip, mod = parseItem(&meta, &li, prevItem, pre, mod)

		if li.IsText && li.Value == "" {
			skip = true

M shared/api.go => shared/api.go +20 -1
@@ 61,6 61,24 @@ func ServeFile(file string, contentType string) http.HandlerFunc {
	}
}

func minus(a, b int) int {
	return a - b
}

func intRange(start, end int) []int {
	n := end - start + 1
	result := make([]int, n)
	for i := 0; i < n; i++ {
		result[i] = start + i
	}
	return result
}

var funcMap = template.FuncMap{
	"minus":    minus,
	"intRange": intRange,
}

func RenderTemplate(cfg *ConfigSite, templates []string) (*template.Template, error) {
	files := make([]string, len(templates))
	copy(files, templates)


@@ 71,7 89,8 @@ func RenderTemplate(cfg *ConfigSite, templates []string) (*template.Template, er
		cfg.StaticPath("html/base.layout.tmpl"),
	)

	ts, err := template.ParseFiles(files...)
	ts, err := template.New("base").Funcs(funcMap).ParseFiles(files...)
	// ts, err := template.ParseFiles(files...)
	if err != nil {
		return nil, err
	}