pagr

A 'static site generator', built using dati.
Log | Files | Refs | Atom

commit 0a1168d69074d28401aa878482d8d400b2a9c090
parent 3ed2ffc7f90de2af959f599f6e55866c35518ccc
Author: gearsix <gearsix@tuta.io>
Date:   Tue, 27 Jul 2021 17:09:33 +0100

renamed content*.go -> page*.go; special files are now *filename.*

Diffstat:
MMakefile | 2+-
Dcontent.go | 412-------------------------------------------------------------------------------
Dcontent_test.go | 142-------------------------------------------------------------------------------
Apage.go | 410+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apage_test.go | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 555 insertions(+), 555 deletions(-)

diff --git a/Makefile b/Makefile @@ -1,5 +1,5 @@ OUT=./pagr -SRC=pagr.go config.go content.go template.go +SRC=pagr.go config.go page.go template.go all: go clean diff --git a/content.go b/content.go @@ -1,412 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "fmt" - "github.com/yuin/goldmark" - goldmarkext "github.com/yuin/goldmark/extension" - goldmarkparse "github.com/yuin/goldmark/parser" - goldmarkhtml "github.com/yuin/goldmark/renderer/html" - "io" - "io/fs" - "time" - "notabug.org/gearsix/suti" - "os" - "path/filepath" - "strings" - "sort" -) - -// SupportedContent provides a list of supported file extensions for Content -// files. Any file in the Content directory not matching one of these will be -// ignored unless it's a Meta file. -var SupportedContent = [5]string{ - ".txt", // plain-text - ".html", // HTML - ".md", // commonmark + extensions (linkify, auto-heading id, unsafe HTML) - ".gfm", // github-flavoured markdown - ".cm", // commonmark -} - -func titleFromPath(path string) (title string) { - if title = filepath.Base(path); title == "/" { - title = "Home" - } - title = strings.TrimSuffix(title, filepath.Ext(title)) - title = strings.ReplaceAll(title, "-", " ") - title = strings.Title(title) - return -} - -func lastFileMod(fpath string) time.Time { - t := time.Now() // default/error ret - if fd, e := os.Stat(fpath); e != nil { - return t - } else if !fd.IsDir() { - return fd.ModTime() - } else { - t = fd.ModTime() - } - if dir, err := os.ReadDir(fpath); err != nil { - return t - } else { - for _, d := range dir { - if fd, err := d.Info(); err == nil && fd.ModTime().After(t) { - t = fd.ModTime() - } - } - } - return t -} - -// Sitemap parses `pages` to determine the `.Nav` values for each element in `pages` -// based on their `.Path` value. These values will be set in the returned Content -func BuildSitemap(pages []Page) []Page { - var root *Page - for i, p := range pages { - if p.Path == "/" { - root = &pages[i] - break - } - } - - for i, p := range pages { - p.Nav.Root = root - - pdepth := len(strings.Split(p.Path, "/")[1:]) - if p.Path == "/" { - pdepth = 0 - } - - if pdepth == 1 && p.Path != "/" { - p.Nav.Parent = root - } - - for j, pp := range pages { - ppdepth := len(strings.Split(pp.Path, "/")[1:]) - if pp.Path == "/" { - ppdepth = 0 - } - - p.Nav.All = append(p.Nav.All, &pages[j]) - if p.Nav.Parent == nil && ppdepth == pdepth - 1 && strings.Contains(p.Path, pp.Path) { - p.Nav.Parent = &pages[j] - } - if ppdepth == pdepth + 1 && strings.Contains(pp.Path, p.Path) { - p.Nav.Children = append(p.Nav.Children, &pages[j]) - } - } - - var crumb string - for _, c := range strings.Split(p.Path, "/")[1:] { - crumb += "/" + c - for j, pp := range pages { - if pp.Path == crumb { - p.Nav.Crumbs = append(p.Nav.Crumbs, &pages[j]) - break - } - } - } - - pages[i] = p - } - - return pages -} - -// LoadContentDir parses all files/directories in `dir` into a `Content`. -// For each directory, a new `Page` element will be generated, any file with a -// filetype found in `SupportedContent`, will be parsed into a string of HTML -// and appended to the `.Content` of the `Page` generated for it's parent -// directory. -func LoadContentDir(dir string) (p []Page, e error) { - if _, e = os.Stat(dir); e != nil { - return - } - - pages := make(map[string]Page) - defaults := make(map[string]Meta) - if dir[len(dir)-1] != '/' { - dir += "/" - } - e = filepath.Walk(dir, func(fpath string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - - if strings.Contains(fpath, ".ignore") { - return nil - } - - var path string - if info.IsDir() { - path = "/" + strings.TrimPrefix(fpath, dir) - page := NewPage(path) - for i, p := range path { - if p != '/' { - continue - } - dpath := path[:i] - if len(dpath) == 0 { - dpath = "/" - } - if _, ok := defaults[dpath]; ok { - page.Meta.MergeMeta(defaults[dpath], true) - } - } - pages[path] = page - return nil - } - - path, _ = filepath.Split(fpath) - path = strings.TrimPrefix(path, dir) - path = "/" + strings.TrimSuffix(path, "/") - page := pages[path] - - if strings.Contains(fpath, ".page") || strings.Contains(fpath, ".default") { - var m Meta - if err = suti.LoadDataFile(fpath, &m); err != nil { - return err - } - if strings.Contains(fpath, ".page") { - page.Meta.MergeMeta(m, true) - } else if strings.Contains(fpath, ".defaults") { - defaults[path] = m - } - } else if isSupportedContentExt(filepath.Ext(fpath)) > -1 { - err = page.NewContentFromFile(fpath) - } else { - page.Assets = append(page.Assets, fpath) - } - - pages[path] = page - return nil - }) - - for _, page := range pages { - p = append(p, page) - } - sort.SliceStable(p, func(i, j int) bool { return p[i].Updated.Before(p[j].Updated) }) - - p = BuildSitemap(p) - - return -} - -func isSupportedContentExt(ext string) int { - for i, supported := range SupportedContent { - if ext == supported { - return i - } - } - return -1 -} - -// Meta is the structure any metadata is parsed into (_.toml_, _.json_, etc) -type Meta map[string]interface{} - -// MergeMeta merges `meta` into `m`. When there are matching keys in both, -// `overwrite` determines whether the existing value in `m` is overwritten. -func (m Meta) MergeMeta(meta Meta, overwrite bool) { - for k, v := range meta { - if _, ok := m[k]; ok && overwrite { - m[k] = v - } else if !ok { - m[k] = v - } - } -} - -// Page is the data structure loaded from Content files/folders that -// gets passed to templates for execution after Content has been loaded. -// This is the data structure to reference when writing a template! -type Page struct { - Title string - Path string - Nav Nav - Meta Meta - Contents []string - Assets []string - Updated time.Time -} - -// Nav is a struct that provides a set of pointers for navigating a -// across a set of pages. All values are initialised to nil and will only -// be populated manually or by calling `BuildSitemap`. -type Nav struct { - All []*Page - Root *Page - Parent *Page - Children []*Page - Crumbs []*Page -} - -// NewPage returns a Page with init values. `.Title` will be set to the -// value returned by titleFromPath(path), `.Path` will be set to `path`. -// Updated is set to time.Now(). Any other values will simply be initialised. -func NewPage(path string) Page { - return Page{ - Title: titleFromPath(path), - Path: path, - Nav: Nav{}, - Meta: make(Meta), - Contents: make([]string, 0), - Assets: make([]string, 0), - Updated: lastFileMod(path), - } -} - -// GetTemplate will check if `p.Meta` has the key `template` or `Template` -// (in the order) and return the value of the first existing key as a string. -// If `.Meta` neither has the key `template` or `Template`, then it will -// return `DefaultTemplate` from [./template.go]. -func (p *Page) GetTemplate() string { - if v, ok := p.Meta["template"]; ok { - return v.(string) - } else if v, ok = p.Meta["Template"]; ok { - return v.(string) - } else { - return DefaultTemplate - } -} - -// NewContentFromFile loads the file from `fpath` and converts it to HTML -// from the language matching it's file extension (see below). -// - ".txt" = plain-text -// - ".md", ".gfm", ".cm" = various flavours of markdown -// - ".html" = parsed as-is -// Successful conversions are appended to `p.Contents` -func (p *Page) NewContentFromFile(fpath string) (err error) { - var buf []byte - if f, err := os.Open(fpath); err == nil { - buf, err = io.ReadAll(f) - f.Close() - } - if err != nil { - return - } - - var body string - for _, lang := range SupportedContent { - if filepath.Ext(fpath) == lang { - switch lang { - case ".txt": - body = convertTextToHTML(bytes.NewReader(buf)) - case ".md": - fallthrough - case ".gfm": - fallthrough - case ".cm": - body, err = convertMarkdownToHTML(lang, buf) - case ".html": - body = string(buf) - default: - break - } - } - } - if len(body) == 0 { - err = fmt.Errorf("invalid filetype (%s) passed to NewContentFromFile", - filepath.Ext(fpath)) - } - if err == nil { - p.Contents = append(p.Contents, body) - } - - return err -} - -// convertTextToHTML parses textual data from `in` and line-by-line converts -// it to HTML. Conversion rules are as follows: -// - Blank lines (with escape characters trimmed) will close any opon tags -// - If a text line is prefixed with a tab and no tag is open, it will open a <pre> tag -// - Otherwise any line of text will open a <p> tag -func convertTextToHTML(in io.Reader) (html string) { - var tag int - const p = 1 - const pre = 2 - - fscan := bufio.NewScanner(in) - for fscan.Scan() { - line := fscan.Text() - if len(strings.TrimSpace(line)) == 0 { - switch tag { - case p: - html += "</p>\n" - case pre: - html += "</pre>\n" - } - tag = 0 - } else if tag == 0 && line[0] == '\t' { - tag = pre - html += "<pre>" + line[1:] + "\n" - } else if tag == 0 || (tag == pre && line[0] != '\t') { - if tag == pre { - html += "</pre>\n" - } - tag = p - html += "<p>" + line - } else if tag == p { - html += " " + line - } else if tag == pre { - html += line[1:] + "\n" - } - } - if tag == p { - html += "</p>" - } else if tag == pre { - html += "</pre>" - } - - return html -} - -// convertMarkdownToHTML initialises a `goldmark.Markdown` based on `lang` and -// returns values from calling it's `Convert` function on `in`. -// Markdown `lang` options, see the code for specfics: -// - ".gfm" = github-flavoured markdown -// - ".cm" = standard commonmark -// - ".md" (and anything else) = commonmark + extensions (linkify, auto-heading id, unsafe HTML) -func convertMarkdownToHTML(lang string, buf []byte) (string, error) { - var markdown goldmark.Markdown - switch lang { - case ".gfm": - markdown = goldmark.New( - goldmark.WithExtensions( - goldmarkext.GFM, - goldmarkext.Table, - goldmarkext.Strikethrough, - goldmarkext.Linkify, - goldmarkext.TaskList, - ), - goldmark.WithParserOptions( - goldmarkparse.WithAutoHeadingID(), - ), - goldmark.WithRendererOptions( - goldmarkhtml.WithUnsafe(), - goldmarkhtml.WithHardWraps(), - ), - ) - case ".cm": - markdown = goldmark.New() - case ".md": - fallthrough - default: - markdown = goldmark.New( - goldmark.WithExtensions( - goldmarkext.Linkify, - ), - goldmark.WithParserOptions( - goldmarkparse.WithAutoHeadingID(), - ), - goldmark.WithRendererOptions( - goldmarkhtml.WithUnsafe(), - ), - ) - } - - var out bytes.Buffer - err := markdown.Convert(buf, &out) - return out.String(), err -} diff --git a/content_test.go b/content_test.go @@ -1,142 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - "testing" -) - -func TestLoadContentDir(t *testing.T) { - t.Parallel() - - var err error - tdir := t.TempDir() - if err = createProjectContents(tdir); err != nil { - t.Errorf("failed to create test content: %s", err) - } - - var p []Page - if p, err = LoadContentDir(tdir); err != nil { - t.Fatalf("LoadContentDir failed: %s", err) - } - - validateContents(t, p, err) -} - -func validateContents(t *testing.T, pages []Page, e error) { - if len(pages) != len(contents)-1 { - t.Fatalf("invalid number of pages returned (%d should be %d)", - len(pages), len(contents)) - } - - var last time.Time - for i, p := range pages { - if len(p.Title) == 0 { - t.Fatal("empty Title for page:", p) - } - if len(p.Path) == 0 { - t.Fatal("empty Path for page:", p) - } - if _, ok := p.Meta["test"]; !ok || len(p.Meta) == 0 { - t.Fatal("empty Meta for page:", p) - } - if len(p.Contents) == 0 { - t.Fatal("empty Contents for page:", p) - } - if len(p.Assets) == 0 { - t.Fatal("empty Assets for page:", p) - } - - if i == 0 { - last = p.Updated - } else if p.Updated.Before(last) { - for _, pp := range pages { - t.Log(pp.Updated) - } - t.Fatal("Contents Pages returned in wrong order") - } - } -} - -var contents = map[string]string{ - ".txt": `p1 -p2 - - pre1 - pre2 -p3 - -p4 -`, - ".html": `<p>p1<br> -p2</p> -<pre>pre1 -pre2 -</pre> -<p>p3</p> -<p>p4</p>`, - ".md": `p1 -p2 - - pre1 - pre2 - -p3 -`, - ".gfm": `p1 -p2 - - pre1 - pre2 - -p3`, - ".cm": `p1 -p2 - - pre1 - pre2 - -p3`, -} - -var asset = []byte{ // 5x5 black png - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, - 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, - 0x00, 0x05, 0x01, 0x03, 0x00, 0x00, 0x00, 0xb7, 0xa1, 0xb4, 0xa6, - 0x00, 0x00, 0x00, 0x06, 0x50, 0x4c, 0x54, 0x45, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0xa5, 0x67, 0xb9, 0xcf, 0x00, 0x00, 0x00, 0x02, - 0x74, 0x52, 0x4e, 0x53, 0xff, 0x00, 0xe5, 0xb7, 0x30, 0x4a, 0x00, - 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x0e, 0xc4, - 0x00, 0x00, 0x0e, 0xc4, 0x01, 0x95, 0x2b, 0x0e, 0x1b, 0x00, 0x00, - 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x63, 0x60, 0x66, - 0x60, 0x66, 0x60, 0x00, 0x62, 0x76, 0x00, 0x00, 0x4a, 0x00, 0x11, - 0x3a, 0x34, 0x8c, 0xad, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, - 0x44, 0xae, 0x42, 0x60, 0x82, -} - -func createProjectContents(dir string) (err error) { - writef := func(path, data string) { - if err == nil { - err = os.WriteFile(path, []byte(data), 0644) - } - } - - for l, lang := range SupportedContent { - if l == 0 { - writef(fmt.Sprintf("%s/.defaults.json", dir), "{ \"test\": \"data\" }") - writef(fmt.Sprintf("%s/.page.toml", dir), "test = \"data\"") - } else if l > 1 { - dir, err = os.MkdirTemp(dir, "page") - } - - writef(fmt.Sprintf("%s/body%d%s", dir, l, lang), contents[lang]) - writef(fmt.Sprintf("%s/asset.png", dir), string(asset)) - - if err != nil { - break - } - } - - return -} diff --git a/page.go b/page.go @@ -0,0 +1,410 @@ +package main + +import ( + "bufio" + "bytes" + "fmt" + "github.com/yuin/goldmark" + goldmarkext "github.com/yuin/goldmark/extension" + goldmarkparse "github.com/yuin/goldmark/parser" + goldmarkhtml "github.com/yuin/goldmark/renderer/html" + "io" + "io/fs" + "time" + "notabug.org/gearsix/suti" + "os" + "path/filepath" + "strings" + "sort" +) + +func titleFromPath(path string) (title string) { + if title = filepath.Base(path); title == "/" { + title = "Home" + } + title = strings.TrimSuffix(title, filepath.Ext(title)) + title = strings.ReplaceAll(title, "-", " ") + title = strings.Title(title) + return +} + +func lastFileMod(fpath string) time.Time { + t := time.Now() // default/error ret + if fd, e := os.Stat(fpath); e != nil { + return t + } else if !fd.IsDir() { + return fd.ModTime() + } else { + t = fd.ModTime() + } + if dir, err := os.ReadDir(fpath); err != nil { + return t + } else { + for _, d := range dir { + if fd, err := d.Info(); err == nil && fd.ModTime().After(t) { + t = fd.ModTime() + } + } + } + return t +} + +// Sitemap parses `pages` to determine the `.Nav` values for each element in `pages` +// based on their `.Path` value. These values will be set in the returned Content +func BuildSitemap(pages []Page) []Page { + var root *Page + for i, p := range pages { + if p.Path == "/" { + root = &pages[i] + break + } + } + + for i, p := range pages { + p.Nav.Root = root + + pdepth := len(strings.Split(p.Path, "/")[1:]) + if p.Path == "/" { + pdepth = 0 + } + + if pdepth == 1 && p.Path != "/" { + p.Nav.Parent = root + } + + for j, pp := range pages { + ppdepth := len(strings.Split(pp.Path, "/")[1:]) + if pp.Path == "/" { + ppdepth = 0 + } + + p.Nav.All = append(p.Nav.All, &pages[j]) + if p.Nav.Parent == nil && ppdepth == pdepth - 1 && strings.Contains(p.Path, pp.Path) { + p.Nav.Parent = &pages[j] + } + if ppdepth == pdepth + 1 && strings.Contains(pp.Path, p.Path) { + p.Nav.Children = append(p.Nav.Children, &pages[j]) + } + } + + var crumb string + for _, c := range strings.Split(p.Path, "/")[1:] { + crumb += "/" + c + for j, pp := range pages { + if pp.Path == crumb { + p.Nav.Crumbs = append(p.Nav.Crumbs, &pages[j]) + break + } + } + } + + pages[i] = p + } + + return pages +} + +var contentExts = [5]string{ + ".txt", // plain-text + ".html", // HTML + ".md", // commonmark + extensions (linkify, auto-heading id, unsafe HTML) + ".gfm", // github-flavoured markdown + ".cm", // commonmark +} + +func isContentExt(ext string) int { + for i, supported := range contentExts { + if ext == supported { + return i + } + } + return -1 +} + +// LoadContentDir parses all files/directories in `dir` into a `Content`. +// For each directory, a new `Page` element will be generated, any file with a +// filetype found in `contentExts`, will be parsed into a string of HTML +// and appended to the `.Content` of the `Page` generated for it's parent +// directory. +func LoadContentDir(dir string) (p []Page, e error) { + if _, e = os.Stat(dir); e != nil { + return + } + + pages := make(map[string]Page) + defaults := make(map[string]Meta) + if dir[len(dir)-1] != '/' { + dir += "/" + } + e = filepath.Walk(dir, func(fpath string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if strings.Contains(fpath, ".ignore") { + return nil + } + + var path string + if info.IsDir() { + path = "/" + strings.TrimPrefix(fpath, dir) + page := NewPage(path) + for i, p := range path { + if p != '/' { + continue + } + dpath := path[:i] + if len(dpath) == 0 { + dpath = "/" + } + if _, ok := defaults[dpath]; ok { + page.Meta.MergeMeta(defaults[dpath], true) + } + } + pages[path] = page + return nil + } + + path, _ = filepath.Split(fpath) + path = strings.TrimPrefix(path, dir) + path = "/" + strings.TrimSuffix(path, "/") + page := pages[path] + + if strings.Contains(fpath, "page.") || strings.Contains(fpath, "defaults.") { + var m Meta + if err = suti.LoadDataFile(fpath, &m); err != nil { + return err + } + if strings.Contains(fpath, "page.") { + page.Meta.MergeMeta(m, true) + } else if strings.Contains(fpath, "defaults.") { + page.Meta.MergeMeta(m, false) + defaults[path] = m + } + } else if isContentExt(filepath.Ext(fpath)) > -1 { + err = page.NewContentFromFile(fpath) + } else { + page.Assets = append(page.Assets, fpath) + } + + pages[path] = page + return nil + }) + + for _, page := range pages { + p = append(p, page) + } + sort.SliceStable(p, func(i, j int) bool { return p[i].Updated.Before(p[j].Updated) }) + + p = BuildSitemap(p) + + return +} + +// Meta is the structure any metadata is parsed into (_.toml_, _.json_, etc) +type Meta map[string]interface{} + +// MergeMeta merges `meta` into `m`. When there are matching keys in both, +// `overwrite` determines whether the existing value in `m` is overwritten. +func (m Meta) MergeMeta(meta Meta, overwrite bool) { + for k, v := range meta { + if _, ok := m[k]; ok && overwrite { + m[k] = v + } else if !ok { + m[k] = v + } + } +} + +// Page is the data structure loaded from Content files/folders that +// gets passed to templates for execution after Content has been loaded. +// This is the data structure to reference when writing a template! +type Page struct { + Title string + Path string + Nav Nav + Meta Meta + Contents []string + Assets []string + Updated time.Time +} + +// Nav is a struct that provides a set of pointers for navigating a +// across a set of pages. All values are initialised to nil and will only +// be populated manually or by calling `BuildSitemap`. +type Nav struct { + All []*Page + Root *Page + Parent *Page + Children []*Page + Crumbs []*Page +} + +// NewPage returns a Page with init values. `.Title` will be set to the +// value returned by titleFromPath(path), `.Path` will be set to `path`. +// Updated is set to time.Now(). Any other values will simply be initialised. +func NewPage(path string) Page { + return Page{ + Title: titleFromPath(path), + Path: path, + Nav: Nav{}, + Meta: make(Meta), + Contents: make([]string, 0), + Assets: make([]string, 0), + Updated: lastFileMod(path), + } +} + +// GetTemplate will check if `p.Meta` has the key `template` or `Template` +// (in the order) and return the value of the first existing key as a string. +// If `.Meta` neither has the key `template` or `Template`, then it will +// return `DefaultTemplate` from [./template.go]. +func (p *Page) GetTemplate() string { + if v, ok := p.Meta["template"]; ok { + return v.(string) + } else if v, ok = p.Meta["Template"]; ok { + return v.(string) + } else { + return DefaultTemplate + } +} + +// NewContentFromFile loads the file from `fpath` and converts it to HTML +// from the language matching it's file extension (see below). +// - ".txt" = plain-text +// - ".md", ".gfm", ".cm" = various flavours of markdown +// - ".html" = parsed as-is +// Successful conversions are appended to `p.Contents` +func (p *Page) NewContentFromFile(fpath string) (err error) { + var buf []byte + if f, err := os.Open(fpath); err == nil { + buf, err = io.ReadAll(f) + f.Close() + } + if err != nil { + return + } + + var body string + for _, lang := range contentExts { + if filepath.Ext(fpath) == lang { + switch lang { + case ".txt": + body = convertTextToHTML(bytes.NewReader(buf)) + case ".md": + fallthrough + case ".gfm": + fallthrough + case ".cm": + body, err = convertMarkdownToHTML(lang, buf) + case ".html": + body = string(buf) + default: + break + } + } + } + if len(body) == 0 { + err = fmt.Errorf("invalid filetype (%s) passed to NewContentFromFile", + filepath.Ext(fpath)) + } + if err == nil { + p.Contents = append(p.Contents, body) + } + + return err +} + +// convertTextToHTML parses textual data from `in` and line-by-line converts +// it to HTML. Conversion rules are as follows: +// - Blank lines (with escape characters trimmed) will close any opon tags +// - If a text line is prefixed with a tab and no tag is open, it will open a <pre> tag +// - Otherwise any line of text will open a <p> tag +func convertTextToHTML(in io.Reader) (html string) { + var tag int + const p = 1 + const pre = 2 + + fscan := bufio.NewScanner(in) + for fscan.Scan() { + line := fscan.Text() + if len(strings.TrimSpace(line)) == 0 { + switch tag { + case p: + html += "</p>\n" + case pre: + html += "</pre>\n" + } + tag = 0 + } else if tag == 0 && line[0] == '\t' { + tag = pre + html += "<pre>" + line[1:] + "\n" + } else if tag == 0 || (tag == pre && line[0] != '\t') { + if tag == pre { + html += "</pre>\n" + } + tag = p + html += "<p>" + line + } else if tag == p { + html += " " + line + } else if tag == pre { + html += line[1:] + "\n" + } + } + if tag == p { + html += "</p>" + } else if tag == pre { + html += "</pre>" + } + + return html +} + +// convertMarkdownToHTML initialises a `goldmark.Markdown` based on `lang` and +// returns values from calling it's `Convert` function on `in`. +// Markdown `lang` options, see the code for specfics: +// - ".gfm" = github-flavoured markdown +// - ".cm" = standard commonmark +// - ".md" (and anything else) = commonmark + extensions (linkify, auto-heading id, unsafe HTML) +func convertMarkdownToHTML(lang string, buf []byte) (string, error) { + var markdown goldmark.Markdown + switch lang { + case ".gfm": + markdown = goldmark.New( + goldmark.WithExtensions( + goldmarkext.GFM, + goldmarkext.Table, + goldmarkext.Strikethrough, + goldmarkext.Linkify, + goldmarkext.TaskList, + ), + goldmark.WithParserOptions( + goldmarkparse.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + goldmarkhtml.WithUnsafe(), + goldmarkhtml.WithHardWraps(), + ), + ) + case ".cm": + markdown = goldmark.New() + case ".md": + fallthrough + default: + markdown = goldmark.New( + goldmark.WithExtensions( + goldmarkext.Linkify, + ), + goldmark.WithParserOptions( + goldmarkparse.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + goldmarkhtml.WithUnsafe(), + ), + ) + } + + var out bytes.Buffer + err := markdown.Convert(buf, &out) + return out.String(), err +} diff --git a/page_test.go b/page_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "os" + "time" + "testing" +) + +func TestLoadContentDir(t *testing.T) { + t.Parallel() + + var err error + tdir := t.TempDir() + if err = createProjectContents(tdir); err != nil { + t.Errorf("failed to create test content: %s", err) + } + + var p []Page + if p, err = LoadContentDir(tdir); err != nil { + t.Fatalf("LoadContentDir failed: %s", err) + } + + validateContents(t, p, err) +} + +func validateContents(t *testing.T, pages []Page, e error) { + if len(pages) != len(contents)-1 { + t.Fatalf("invalid number of pages returned (%d should be %d)", + len(pages), len(contents)) + } + + var last time.Time + for i, p := range pages { + if len(p.Title) == 0 { + t.Fatal("empty Title for page:", p) + } + if len(p.Path) == 0 { + t.Fatal("empty Path for page:", p) + } + if _, ok := p.Meta["page"]; !ok || len(p.Meta) == 0 { + t.Fatal("missing page Meta key for page:", p.Path) + } + if _, ok := p.Meta["default"]; !ok || len(p.Meta) == 0 { + t.Fatal("empty default Meta key for page:", p.Path) + } + if len(p.Contents) == 0 { + t.Fatal("empty Contents for page:", p.Path) + } + if len(p.Assets) == 0 { + t.Fatal("empty Assets for page:", p.Path) + } + + if i == 0 { + last = p.Updated + } else if p.Updated.Before(last) { + for _, pp := range pages { + t.Log(pp.Updated) + } + t.Fatal("Contents Pages returned in wrong order") + } + } +} + +var contents = map[string]string{ + ".txt": `p1 +p2 + + pre1 + pre2 +p3 + +p4 +`, + ".html": `<p>p1<br> +p2</p> +<pre>pre1 +pre2 +</pre> +<p>p3</p> +<p>p4</p>`, + ".md": `p1 +p2 + + pre1 + pre2 + +p3 +`, + ".gfm": `p1 +p2 + + pre1 + pre2 + +p3`, + ".cm": `p1 +p2 + + pre1 + pre2 + +p3`, +} + +var asset = []byte{ // 5x5 black png + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, + 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, + 0x00, 0x05, 0x01, 0x03, 0x00, 0x00, 0x00, 0xb7, 0xa1, 0xb4, 0xa6, + 0x00, 0x00, 0x00, 0x06, 0x50, 0x4c, 0x54, 0x45, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xa5, 0x67, 0xb9, 0xcf, 0x00, 0x00, 0x00, 0x02, + 0x74, 0x52, 0x4e, 0x53, 0xff, 0x00, 0xe5, 0xb7, 0x30, 0x4a, 0x00, + 0x00, 0x00, 0x09, 0x70, 0x48, 0x59, 0x73, 0x00, 0x00, 0x0e, 0xc4, + 0x00, 0x00, 0x0e, 0xc4, 0x01, 0x95, 0x2b, 0x0e, 0x1b, 0x00, 0x00, + 0x00, 0x10, 0x49, 0x44, 0x41, 0x54, 0x08, 0x99, 0x63, 0x60, 0x66, + 0x60, 0x66, 0x60, 0x00, 0x62, 0x76, 0x00, 0x00, 0x4a, 0x00, 0x11, + 0x3a, 0x34, 0x8c, 0xad, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, +} + +func createProjectContents(dir string) (err error) { + writef := func(path, data string) { + if err == nil { + err = os.WriteFile(path, []byte(data), 0644) + } + } + + for l, lang := range contentExts { + if l == 0 { + writef(fmt.Sprintf("%s/defaults.json", dir), "{ \"default\": \"data\" }") + } else if l > 1 { + dir, err = os.MkdirTemp(dir, "page") + } + writef(fmt.Sprintf("%s/.page.toml", dir), "page = \"data\"") + writef(fmt.Sprintf("%s/body%d%s", dir, l, lang), contents[lang]) + writef(fmt.Sprintf("%s/asset.png", dir), string(asset)) + + if err != nil { + break + } + } + + return +}