Compare commits
30 commits
dev-stable
...
dev
Author | SHA1 | Date | |
---|---|---|---|
9a94052b42 | |||
3355bc5544 | |||
99bc128578 | |||
8895ba969c | |||
c0429fa92b | |||
577eeeab2d | |||
af2f071a5a | |||
bdd9e63fdf | |||
59ead0f26a | |||
c263879904 | |||
4b62ed116e | |||
fdec4b6f25 | |||
988d4ba42e | |||
fdb8753538 | |||
116fb6a883 | |||
65b62ef9a6 | |||
0ecad9e96a | |||
4629200510 | |||
7644a31016 | |||
4315348cf5 | |||
4d27581f0a | |||
587085df86 | |||
af81617db5 | |||
35c14f09c0 | |||
709a2738f9 | |||
ffe5ea4efc | |||
525cbcd980 | |||
ce706e4ff9 | |||
0c1c842bcd | |||
fb67ef046a |
26 changed files with 949 additions and 156 deletions
17
.build.yml
Normal file
17
.build.yml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
image: alpine/latest
|
||||||
|
packages:
|
||||||
|
- git
|
||||||
|
- openssh
|
||||||
|
secrets:
|
||||||
|
- 0639564d-6995-4e2e-844b-2f8feb0b7fb1
|
||||||
|
environment:
|
||||||
|
repo: zona
|
||||||
|
github: git@github.com:ficcdaf/zona.git
|
||||||
|
tasks:
|
||||||
|
- mirror: |
|
||||||
|
ssh-keyscan github.com >> ~/.ssh/known_hosts
|
||||||
|
cd "$repo"
|
||||||
|
git remote add github "$github"
|
||||||
|
git push --mirror github
|
||||||
|
|
||||||
|
|
52
TODO.md
Normal file
52
TODO.md
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
# TO-DO
|
||||||
|
|
||||||
|
- **First**, re-write the settings & configuration system from scratch! It's
|
||||||
|
broken and messy and not worth trying to fix like this. Instead, actually
|
||||||
|
architect how it should work, _then_ implement it.
|
||||||
|
- Refactor the directory structure processing
|
||||||
|
- Implement zola-style structure instead
|
||||||
|
- `zona init` command to populate the required files, _with_ defaults
|
||||||
|
(unlike zola)
|
||||||
|
- Interactive for setting values, also an option to create `.gitignore`
|
||||||
|
with `public` in it.
|
||||||
|
- `zona.yml` is **required** and should mark the root:
|
||||||
|
- `templates`, `content`, `static`, `zona.yml`
|
||||||
|
- multiple `zona.yml` files should be an error
|
||||||
|
- if the folder containing `zona.yml` doesn't contain _exactly_ the
|
||||||
|
expected directories and files, it's an error
|
||||||
|
- Paths in page metadata should start at these folders
|
||||||
|
- i.e. `(template|footer|header): name.html` → `root/templates/name.html`
|
||||||
|
- `(style|icon): name.ext` → `root/static/name.ext`
|
||||||
|
- Traverse `content` and `static` separately, applying different rules
|
||||||
|
- everything in `static/**` should be directly copied
|
||||||
|
- `content/**` should be processed
|
||||||
|
- `*.md` converted, everything else copied directly
|
||||||
|
- `./name.md` → ./name/index.html
|
||||||
|
- Either `./name.md` or `./name/index.md` are valid, _together_ they
|
||||||
|
cause an error!
|
||||||
|
- What about markdown links to internal pages?
|
||||||
|
- Relative links should be supported to play nice with LSP
|
||||||
|
- in case of relative link, zona should attempt to resolve it, figuring
|
||||||
|
out which file it's pointing to, and convert it to a `/` prefixed link
|
||||||
|
pointing to appropriate place
|
||||||
|
- so `../blog/a-post.md` → `/blog/a-post` where `/blog/a-post/index.html`
|
||||||
|
exists
|
||||||
|
- links from project root should also be supported
|
||||||
|
- check link validity at build time and supply warning
|
||||||
|
- _tl;dr_ all links should be resolved to the absolute path to that resource
|
||||||
|
starting from the website root. that's the link that should actually be
|
||||||
|
written to the HTML.
|
||||||
|
- Re-consider what `zona.yml` should have in it.
|
||||||
|
- Set syntax highlighting theme here
|
||||||
|
- a string that's not a file path: name of any built-in theme in
|
||||||
|
[chroma](https://github.com/alecthomas/chroma)
|
||||||
|
- path to `xml` _or_ `yml` file: custom theme for passing to chroma
|
||||||
|
- if `xml`, pass directly
|
||||||
|
- if `yml`, parse and convert into expected `xml` format before passing
|
||||||
|
- Set website root URL here
|
||||||
|
- toggle option for zona's custom image label expansion, image container div,
|
||||||
|
etc, basically all the custom rendering stuff
|
||||||
|
- Syntax highlighting for code blocks
|
||||||
|
- Add `zona serve` command with local dev server to preview the site
|
||||||
|
- Both `zona build` and `zona serve` should output warning and error
|
||||||
|
- Write actual unit tests!
|
|
@ -42,8 +42,18 @@ func main() {
|
||||||
|
|
||||||
}
|
}
|
||||||
settings := builder.GetSettings(*rootPath, "foobar")
|
settings := builder.GetSettings(*rootPath, "foobar")
|
||||||
err := builder.Traverse(*rootPath, "foobar", settings)
|
// err := builder.Traverse(*rootPath, "foobar", settings)
|
||||||
|
// traverse the source and process file metadata
|
||||||
|
pm, err := builder.ProcessTraverse(*rootPath, "foobar", settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error: %s\n", err.Error())
|
fmt.Printf("Error: %s\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
err = builder.BuildProcessedFiles(pm, settings)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error: %s\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%#v", pm)
|
||||||
}
|
}
|
||||||
|
|
7
go.mod
7
go.mod
|
@ -1,11 +1,10 @@
|
||||||
module github.com/ficcdaf/zona
|
module github.com/ficcdaf/zona
|
||||||
|
|
||||||
// go 1.23.2
|
go 1.24.2
|
||||||
go 1.23.4
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81
|
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/text v0.20.0
|
require golang.org/x/text v0.23.0
|
||||||
|
|
|
@ -2,6 +2,7 @@ package builder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
@ -24,11 +25,21 @@ type PageData struct {
|
||||||
FooterName string
|
FooterName string
|
||||||
Footer template.HTML
|
Footer template.HTML
|
||||||
Template string
|
Template string
|
||||||
|
Type string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metadata map[string]interface{}
|
type Metadata map[string]any
|
||||||
|
|
||||||
func processWithYaml(f []byte) (Metadata, []byte, error) {
|
type FrontMatter struct {
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
Style string `yaml:"style"`
|
||||||
|
Header string `yaml:"header"`
|
||||||
|
Footer string `yaml:"footer"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func processWithYaml(f []byte) (*FrontMatter, []byte, error) {
|
||||||
// Check if the file has valid metadata
|
// Check if the file has valid metadata
|
||||||
trimmed := bytes.TrimSpace(f)
|
trimmed := bytes.TrimSpace(f)
|
||||||
normalized := strings.ReplaceAll(string(trimmed), "\r\n", "\n")
|
normalized := strings.ReplaceAll(string(trimmed), "\r\n", "\n")
|
||||||
|
@ -39,31 +50,36 @@ func processWithYaml(f []byte) (Metadata, []byte, error) {
|
||||||
// Separate YAML from rest of document
|
// Separate YAML from rest of document
|
||||||
split := strings.SplitN(normalized, "---\n", 3)
|
split := strings.SplitN(normalized, "---\n", 3)
|
||||||
if len(split) < 3 {
|
if len(split) < 3 {
|
||||||
return nil, nil, fmt.Errorf("Invalid frontmatter format.")
|
return nil, nil, fmt.Errorf("invalid frontmatter format")
|
||||||
}
|
}
|
||||||
var meta Metadata
|
var meta FrontMatter
|
||||||
// Parse YAML
|
// Parse YAML
|
||||||
if err := yaml.Unmarshal([]byte(split[1]), &meta); err != nil {
|
if err := yaml.Unmarshal([]byte(split[1]), &meta); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return meta, []byte(split[2]), nil
|
return &meta, []byte(split[2]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPageData(m Metadata, in string, out string, settings *Settings) *PageData {
|
func buildPageData(m *FrontMatter, in string, out string, settings *Settings) *PageData {
|
||||||
p := &PageData{}
|
p := &PageData{}
|
||||||
if title, ok := m["title"].(string); ok {
|
if m != nil && m.Title != "" {
|
||||||
p.Title = util.WordsToTitle(title)
|
p.Title = util.WordsToTitle(m.Title)
|
||||||
} else {
|
} else {
|
||||||
p.Title = util.PathToTitle(in)
|
p.Title = util.PathToTitle(in)
|
||||||
}
|
}
|
||||||
if icon, ok := m["icon"].(string); ok {
|
if m != nil && m.Icon != "" {
|
||||||
p.Icon = icon
|
i, err := util.NormalizePath(m.Icon, in)
|
||||||
|
if err != nil {
|
||||||
|
p.Icon = settings.IconName
|
||||||
|
} else {
|
||||||
|
p.Icon = i
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
p.Icon = settings.IconName
|
p.Icon = settings.IconName
|
||||||
}
|
}
|
||||||
var stylePath string
|
var stylePath string
|
||||||
if style, ok := m["style"].(string); ok {
|
if m != nil && m.Style != "" {
|
||||||
stylePath = style
|
stylePath = m.Style
|
||||||
} else {
|
} else {
|
||||||
stylePath = settings.StylePath
|
stylePath = settings.StylePath
|
||||||
}
|
}
|
||||||
|
@ -74,30 +90,34 @@ func buildPageData(m Metadata, in string, out string, settings *Settings) *PageD
|
||||||
log.Fatalln("Error calculating stylesheet path: ", err)
|
log.Fatalln("Error calculating stylesheet path: ", err)
|
||||||
}
|
}
|
||||||
p.Stylesheet = relPath
|
p.Stylesheet = relPath
|
||||||
if header, ok := m["header"].(string); ok {
|
|
||||||
p.HeaderName = header
|
if m != nil && m.Header != "" {
|
||||||
|
p.HeaderName = m.Header
|
||||||
// for now we use default anyways
|
// for now we use default anyways
|
||||||
p.Header = settings.Header
|
p.Header = settings.Header
|
||||||
} else {
|
} else {
|
||||||
p.HeaderName = settings.HeaderName
|
p.HeaderName = settings.HeaderName
|
||||||
p.Header = settings.Header
|
p.Header = settings.Header
|
||||||
}
|
}
|
||||||
if footer, ok := m["footer"].(string); ok {
|
if m != nil && m.Footer != "" {
|
||||||
p.FooterName = footer
|
p.FooterName = m.Footer
|
||||||
p.Footer = settings.Footer
|
p.Footer = settings.Footer
|
||||||
} else {
|
} else {
|
||||||
p.FooterName = settings.FooterName
|
p.FooterName = settings.FooterName
|
||||||
p.Footer = settings.Footer
|
p.Footer = settings.Footer
|
||||||
}
|
}
|
||||||
if t, ok := m["type"].(string); ok && t == "article" || t == "post" {
|
// TODO: Don't hard code posts dir name
|
||||||
|
if (m != nil && (m.Type == "article" || m.Type == "post")) || util.InDir(in, "posts") {
|
||||||
p.Template = (settings.ArticleTemplate)
|
p.Template = (settings.ArticleTemplate)
|
||||||
|
p.Type = "post"
|
||||||
} else {
|
} else {
|
||||||
p.Template = (settings.DefaultTemplate)
|
p.Template = (settings.DefaultTemplate)
|
||||||
|
p.Type = ""
|
||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
func ConvertFile(in string, out string, settings *Settings) error {
|
func _BuildHtmlFile(in string, out string, settings *Settings) error {
|
||||||
mdPre, err := util.ReadFile(in)
|
mdPre, err := util.ReadFile(in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -126,3 +146,58 @@ func ConvertFile(in string, out string, settings *Settings) error {
|
||||||
err = util.WriteFile(output.Bytes(), out)
|
err = util.WriteFile(output.Bytes(), out)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func BuildFile(f *File, settings *Settings) error {
|
||||||
|
if f.ShouldCopy {
|
||||||
|
if err := util.CreateParents(f.OutPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := util.CopyFile(f.InPath, f.OutPath); err != nil {
|
||||||
|
return errors.Join(errors.New("Error processing file "+f.InPath), err)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := util.CreateParents(f.OutPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := BuildHtmlFile(f.FrontMatterLen, f.InPath, f.OutPath, f.PageData, settings); err != nil {
|
||||||
|
return errors.Join(errors.New("Error processing file "+f.InPath), err)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildHtmlFile(l int, in string, out string, pd *PageData, settings *Settings) error {
|
||||||
|
// WARN: ReadLineRange is fine, but l is the len of the frontmatter
|
||||||
|
// NOT including the delimiters!
|
||||||
|
start := l
|
||||||
|
// if the frontmatter exists (len > 0), then we need to
|
||||||
|
// account for two lines of delimiter!
|
||||||
|
if l != 0 {
|
||||||
|
start += 2
|
||||||
|
}
|
||||||
|
md, err := util.ReadLineRange(in, start, -1)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("Title: ", pd.Title)
|
||||||
|
|
||||||
|
// build according to template here
|
||||||
|
html := MdToHTML(md)
|
||||||
|
pd.Content = template.HTML(html)
|
||||||
|
|
||||||
|
tmpl, err := template.New("webpage").Parse(pd.Template)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&output, pd); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = util.WriteFile(output.Bytes(), out)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
|
@ -11,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var defaultNames = map[string]string{
|
var defaultNames = map[string]string{
|
||||||
"config": "config.yml",
|
"config": ".zona.yml",
|
||||||
"header": "header.md",
|
"header": "header.md",
|
||||||
"footer": "footer.md",
|
"footer": "footer.md",
|
||||||
"style": "style.css",
|
"style": "style.css",
|
||||||
|
@ -22,7 +22,7 @@ var defaultNames = map[string]string{
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed embed/article.html
|
//go:embed embed/article.html
|
||||||
//go:embed embed/config.yml
|
//go:embed embed/.zona.yml
|
||||||
//go:embed embed/default.html
|
//go:embed embed/default.html
|
||||||
//go:embed embed/favicon.png
|
//go:embed embed/favicon.png
|
||||||
//go:embed embed/footer.md
|
//go:embed embed/footer.md
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package builder
|
package builder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
@ -53,6 +54,33 @@ func processLink(p string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderImage outputs an ast Image node as HTML string.
|
||||||
|
func renderImage(w io.Writer, node *ast.Image, entering bool, next *ast.Text) {
|
||||||
|
// we add image-container div tag
|
||||||
|
// here before the opening img tag
|
||||||
|
if entering {
|
||||||
|
fmt.Fprintf(w, "<div class=\"image-container\">\n")
|
||||||
|
fmt.Fprintf(w, `<img src="%s" title="%s"`, node.Destination, node.Title)
|
||||||
|
if next != nil && len(next.Literal) > 0 {
|
||||||
|
md := []byte(next.Literal)
|
||||||
|
html, doc := convertEmbedded(md)
|
||||||
|
altText := extractPlainText(md, doc)
|
||||||
|
fmt.Fprintf(w, ` alt="%s">`, altText)
|
||||||
|
// TODO: render inside a special div?
|
||||||
|
// is this necessary since this is all inside image-container anyways?
|
||||||
|
fmt.Fprintf(w, `<small>%s</small>`, html)
|
||||||
|
} else {
|
||||||
|
//
|
||||||
|
io.WriteString(w, ">")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// if it's the closing img tag
|
||||||
|
// we close the div tag *after*
|
||||||
|
fmt.Fprintf(w, `</div>`)
|
||||||
|
fmt.Println("Image node not entering??")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func renderLink(w io.Writer, l *ast.Link, entering bool) {
|
func renderLink(w io.Writer, l *ast.Link, entering bool) {
|
||||||
if entering {
|
if entering {
|
||||||
destPath := processLink(string(l.Destination))
|
destPath := processLink(string(l.Destination))
|
||||||
|
@ -66,15 +94,73 @@ func renderLink(w io.Writer, l *ast.Link, entering bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func htmlRenderHookNoImage(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
|
||||||
|
if link, ok := node.(*ast.Link); ok {
|
||||||
|
renderLink(w, link, entering)
|
||||||
|
return ast.GoToNext, true
|
||||||
|
} else if _, ok := node.(*ast.Image); ok {
|
||||||
|
// we do not render images
|
||||||
|
return ast.GoToNext, true
|
||||||
|
}
|
||||||
|
return ast.GoToNext, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlRenderHook hooks the HTML renderer and overrides the rendering of certain nodes.
|
||||||
func htmlRenderHook(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
|
func htmlRenderHook(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
|
||||||
if link, ok := node.(*ast.Link); ok {
|
if link, ok := node.(*ast.Link); ok {
|
||||||
renderLink(w, link, entering)
|
renderLink(w, link, entering)
|
||||||
return ast.GoToNext, true
|
return ast.GoToNext, true
|
||||||
|
} else if image, ok := node.(*ast.Image); ok {
|
||||||
|
var nextNode *ast.Text
|
||||||
|
if entering {
|
||||||
|
nextNodes := node.GetChildren()
|
||||||
|
fmt.Println("img next node len:", len(nextNodes))
|
||||||
|
if len(nextNodes) == 1 {
|
||||||
|
if textNode, ok := nextNodes[0].(*ast.Text); ok {
|
||||||
|
nextNode = textNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderImage(w, image, entering, nextNode)
|
||||||
|
// Skip rendering of `nextNode` explicitly
|
||||||
|
if nextNode != nil {
|
||||||
|
return ast.SkipChildren, true
|
||||||
|
}
|
||||||
|
return ast.GoToNext, true
|
||||||
}
|
}
|
||||||
return ast.GoToNext, false
|
return ast.GoToNext, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertEmbedded renders markdown as HTML
|
||||||
|
// but does NOT render images
|
||||||
|
// also returns document AST
|
||||||
|
func convertEmbedded(md []byte) ([]byte, *ast.Node) {
|
||||||
|
p := parser.NewWithExtensions(parser.CommonExtensions)
|
||||||
|
doc := p.Parse(md)
|
||||||
|
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||||
|
opts := html.RendererOptions{Flags: htmlFlags}
|
||||||
|
opts.RenderNodeHook = htmlRenderHookNoImage
|
||||||
|
r := html.NewRenderer(opts)
|
||||||
|
html := markdown.Render(doc, r)
|
||||||
|
return html, &doc
|
||||||
|
}
|
||||||
|
|
||||||
func newZonaRenderer(opts html.RendererOptions) *html.Renderer {
|
func newZonaRenderer(opts html.RendererOptions) *html.Renderer {
|
||||||
opts.RenderNodeHook = htmlRenderHook
|
opts.RenderNodeHook = htmlRenderHook
|
||||||
return html.NewRenderer(opts)
|
return html.NewRenderer(opts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractPlainText walks the AST and extracts plain text from the Markdown input.
|
||||||
|
func extractPlainText(md []byte, doc *ast.Node) string {
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
|
||||||
|
// Walk the AST and extract text nodes
|
||||||
|
ast.WalkFunc(*doc, func(node ast.Node, entering bool) ast.WalkStatus {
|
||||||
|
if textNode, ok := node.(*ast.Text); ok && entering {
|
||||||
|
buffer.Write(textNode.Literal) // Append the text content
|
||||||
|
}
|
||||||
|
return ast.GoToNext
|
||||||
|
})
|
||||||
|
|
||||||
|
return buffer.String()
|
||||||
|
}
|
||||||
|
|
|
@ -1,122 +0,0 @@
|
||||||
package builder_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/ficcdaf/zona/internal/builder"
|
|
||||||
"github.com/ficcdaf/zona/internal/util"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestMdToHTML(t *testing.T) {
|
|
||||||
md := []byte("# Hello World\n\nThis is a test.")
|
|
||||||
expectedHTML := "<h1 id=\"hello-world\">Hello World</h1>\n<p>This is a test.</p>\n"
|
|
||||||
nExpectedHTML := util.NormalizeContent(expectedHTML)
|
|
||||||
html, err := builder.MdToHTML(md)
|
|
||||||
nHtml := util.NormalizeContent(string(html))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if nHtml != nExpectedHTML {
|
|
||||||
t.Errorf("Expected:\n%s\nGot:\n%s", expectedHTML, html)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteFile(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "test.txt")
|
|
||||||
content := []byte("Hello, World!")
|
|
||||||
|
|
||||||
err := builder.WriteFile(content, path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify file content
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error reading file: %v", err)
|
|
||||||
}
|
|
||||||
if string(data) != string(content) {
|
|
||||||
t.Errorf("Expected:\n%s\nGot:\n%s", content, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadFile(t *testing.T) {
|
|
||||||
path := filepath.Join(t.TempDir(), "test.txt")
|
|
||||||
content := []byte("Hello, World!")
|
|
||||||
|
|
||||||
err := os.WriteFile(path, content, 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error writing file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := builder.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if string(data) != string(content) {
|
|
||||||
t.Errorf("Expected:\n%s\nGot:\n%s", content, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCopyFile(t *testing.T) {
|
|
||||||
src := filepath.Join(t.TempDir(), "source.txt")
|
|
||||||
dst := filepath.Join(t.TempDir(), "dest.txt")
|
|
||||||
content := []byte("File content for testing.")
|
|
||||||
|
|
||||||
err := os.WriteFile(src, content, 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error writing source file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = builder.CopyFile(src, dst)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify destination file content
|
|
||||||
data, err := os.ReadFile(dst)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error reading destination file: %v", err)
|
|
||||||
}
|
|
||||||
if string(data) != string(content) {
|
|
||||||
t.Errorf("Expected:\n%s\nGot:\n%s", content, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestConvertFile(t *testing.T) {
|
|
||||||
src := filepath.Join(t.TempDir(), "test.md")
|
|
||||||
dst := filepath.Join(t.TempDir(), "test.html")
|
|
||||||
mdContent := []byte("# Test Title\n\nThis is Markdown content.")
|
|
||||||
nExpectedHTML := util.NormalizeContent("<h1 id=\"test-title\">Test Title</h1>\n<p>This is Markdown content.</p>\n")
|
|
||||||
|
|
||||||
err := os.WriteFile(src, mdContent, 0644)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error writing source Markdown file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = builder.ConvertFile(src, dst)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify destination HTML content
|
|
||||||
data, err := os.ReadFile(dst)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Error reading HTML file: %v", err)
|
|
||||||
}
|
|
||||||
if util.NormalizeContent(string(data)) != nExpectedHTML {
|
|
||||||
t.Errorf("Expected:\n%s\nGot:\n%s", nExpectedHTML, data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestChangeExtension(t *testing.T) {
|
|
||||||
input := "test.md"
|
|
||||||
output := builder.ChangeExtension(input, ".html")
|
|
||||||
expected := "test.html"
|
|
||||||
|
|
||||||
if output != expected {
|
|
||||||
t.Errorf("Expected %s, got %s", expected, output)
|
|
||||||
}
|
|
||||||
}
|
|
86
internal/builder/frontmatter.go
Normal file
86
internal/builder/frontmatter.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func processFrontmatter(p string) (*FrontMatter, int, error) {
|
||||||
|
f, l, err := readFrontmatter(p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, l, err
|
||||||
|
}
|
||||||
|
var meta FrontMatter
|
||||||
|
// Parse YAML
|
||||||
|
if err := yaml.Unmarshal(f, &meta); err != nil {
|
||||||
|
return nil, l, fmt.Errorf("yaml frontmatter could not be parsed: %w", err)
|
||||||
|
}
|
||||||
|
return &meta, l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrontmatter reads the file at `path` and scans
|
||||||
|
// it for --- delimited frontmatter. It does not attempt
|
||||||
|
// to parse the data, it only scans for the delimiters.
|
||||||
|
// It returns the frontmatter contents as a byte array
|
||||||
|
// and its length in lines.
|
||||||
|
func readFrontmatter(path string) ([]byte, int, error) {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
lines := make([]string, 0, 10)
|
||||||
|
s := bufio.NewScanner(file)
|
||||||
|
i := 0
|
||||||
|
delims := 0
|
||||||
|
for s.Scan() {
|
||||||
|
l := s.Text()
|
||||||
|
if l == `---` {
|
||||||
|
if i == 1 && delims == 0 {
|
||||||
|
// if --- is not the first line, we
|
||||||
|
// assume the file does not contain frontmatter
|
||||||
|
// fmt.Println("Delimiter first line")
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
delims += 1
|
||||||
|
i += 1
|
||||||
|
if delims == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if i == 0 {
|
||||||
|
return nil, 0, nil
|
||||||
|
}
|
||||||
|
lines = append(lines, l)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// check whether any errors occurred while scanning
|
||||||
|
if err := s.Err(); err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if delims == 2 {
|
||||||
|
l := len(lines)
|
||||||
|
if l == 0 {
|
||||||
|
// no valid frontmatter
|
||||||
|
return nil, 0, errors.New("frontmatter cannot be empty")
|
||||||
|
}
|
||||||
|
// convert to byte array
|
||||||
|
var b bytes.Buffer
|
||||||
|
for _, line := range lines {
|
||||||
|
b.WriteString(line + "\n")
|
||||||
|
}
|
||||||
|
return b.Bytes(), l, nil
|
||||||
|
} else {
|
||||||
|
// not enough delimiters, don't
|
||||||
|
// treat as frontmatter
|
||||||
|
s := fmt.Sprintf("%s: frontmatter is missing closing delimiter", path)
|
||||||
|
return nil, 0, errors.New(s)
|
||||||
|
}
|
||||||
|
}
|
183
internal/builder/frontmatter_test.go
Normal file
183
internal/builder/frontmatter_test.go
Normal file
|
@ -0,0 +1,183 @@
|
||||||
|
// FILE: internal/builder/build_page_test.go
|
||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessFrontmatter(t *testing.T) {
|
||||||
|
// Create a temporary file with valid frontmatter
|
||||||
|
validContent := `---
|
||||||
|
title: "Test Title"
|
||||||
|
description: "Test Description"
|
||||||
|
---
|
||||||
|
This is the body of the document.`
|
||||||
|
|
||||||
|
tmpfile, err := os.CreateTemp("", "testfile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpfile.Name()) // clean up
|
||||||
|
|
||||||
|
if _, err := tmpfile.Write([]byte(validContent)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tmpfile.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the processFrontmatter function with valid frontmatter
|
||||||
|
meta, l, err := processFrontmatter(tmpfile.Name())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processFrontmatter failed: %v", err)
|
||||||
|
}
|
||||||
|
if l != 2 {
|
||||||
|
t.Errorf("Expected length 2, got %d", l)
|
||||||
|
}
|
||||||
|
|
||||||
|
if meta["title"] != "Test Title" || meta["description"] != "Test Description" {
|
||||||
|
t.Errorf("Expected title 'Test Title' and description 'Test Description', got title '%s' and description '%s'", meta["title"], meta["description"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a temporary file with invalid frontmatter
|
||||||
|
invalidContent := `---
|
||||||
|
title: "Test Title"
|
||||||
|
description: "Test Description"
|
||||||
|
There is no closing delimiter???
|
||||||
|
This is the body of the document.`
|
||||||
|
|
||||||
|
tmpfile, err = os.CreateTemp("", "testfile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpfile.Name()) // clean up
|
||||||
|
|
||||||
|
if _, err := tmpfile.Write([]byte(invalidContent)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tmpfile.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the processFrontmatter function with invalid frontmatter
|
||||||
|
_, _, err = processFrontmatter(tmpfile.Name())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Expected error for invalid frontmatter, got nil")
|
||||||
|
}
|
||||||
|
// Create a temporary file with invalid frontmatter
|
||||||
|
invalidContent = `---
|
||||||
|
---
|
||||||
|
This is the body of the document.`
|
||||||
|
|
||||||
|
tmpfile, err = os.CreateTemp("", "testfile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpfile.Name()) // clean up
|
||||||
|
|
||||||
|
if _, err := tmpfile.Write([]byte(invalidContent)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tmpfile.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the processFrontmatter function with invalid frontmatter
|
||||||
|
_, _, err = processFrontmatter(tmpfile.Name())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("Expected error for invalid frontmatter, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadFrontmatter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
content string
|
||||||
|
wantErr bool
|
||||||
|
wantData []byte
|
||||||
|
wantLen int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid frontmatter",
|
||||||
|
content: `---
|
||||||
|
title: "Test"
|
||||||
|
author: "User"
|
||||||
|
---
|
||||||
|
Content here`,
|
||||||
|
wantErr: false,
|
||||||
|
wantData: []byte("title: \"Test\"\nauthor: \"User\"\n"),
|
||||||
|
wantLen: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing closing delimiter",
|
||||||
|
content: `---
|
||||||
|
title: "Incomplete Frontmatter"`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Frontmatter later in file",
|
||||||
|
content: `This is some content
|
||||||
|
---
|
||||||
|
title: "Not Frontmatter"
|
||||||
|
---`,
|
||||||
|
wantErr: false,
|
||||||
|
wantData: nil, // Should return nil because `---` is not the first line
|
||||||
|
wantLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty frontmatter",
|
||||||
|
content: `---
|
||||||
|
---`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No frontmatter",
|
||||||
|
content: `This is just a normal file.`,
|
||||||
|
wantErr: false,
|
||||||
|
wantData: nil, // Should return nil as there's no frontmatter
|
||||||
|
wantLen: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create a temporary file
|
||||||
|
tmpFile, err := os.CreateTemp("", "testfile-*.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
|
||||||
|
// Write test content
|
||||||
|
_, err = tmpFile.WriteString(tc.content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to write to temp file: %v", err)
|
||||||
|
}
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
// Call function under test
|
||||||
|
data, length, err := readFrontmatter(tmpFile.Name())
|
||||||
|
|
||||||
|
// Check for expected error
|
||||||
|
if tc.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error but got none")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Check content
|
||||||
|
if !bytes.Equal(data, tc.wantData) {
|
||||||
|
t.Errorf("expected %q, got %q", tc.wantData, data)
|
||||||
|
}
|
||||||
|
// Check length
|
||||||
|
if length != tc.wantLen {
|
||||||
|
t.Errorf("expected length %d, got %d", tc.wantLen, length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
116
internal/builder/process.go
Normal file
116
internal/builder/process.go
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
package builder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/ficcdaf/zona/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessMemory struct {
|
||||||
|
// Files holds all page data that may be
|
||||||
|
// needed while building *other* pages.
|
||||||
|
Files []*File
|
||||||
|
// Queue is a FIFO queue of Pages indexes to be built.
|
||||||
|
// queue should be constructed after all the Pages have been parsed
|
||||||
|
Queue []int
|
||||||
|
// Posts is an array of pointers to post pages
|
||||||
|
// This list is ONLY referenced for generating
|
||||||
|
// the archive, NOT by the build process!
|
||||||
|
Posts []*File
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
PageData *PageData
|
||||||
|
Ext string
|
||||||
|
InPath string
|
||||||
|
OutPath string
|
||||||
|
ShouldCopy bool
|
||||||
|
HasFrontmatter bool
|
||||||
|
FrontMatterLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProcessMemory initializes an empty
|
||||||
|
// process memory structure
|
||||||
|
func NewProcessMemory() *ProcessMemory {
|
||||||
|
f := make([]*File, 0)
|
||||||
|
q := make([]int, 0)
|
||||||
|
p := make([]*File, 0)
|
||||||
|
pm := &ProcessMemory{
|
||||||
|
f,
|
||||||
|
q,
|
||||||
|
p,
|
||||||
|
}
|
||||||
|
return pm
|
||||||
|
}
|
||||||
|
|
||||||
|
// processFile processes the metadata only
|
||||||
|
// of each file
|
||||||
|
func processFile(inPath string, entry fs.DirEntry, err error, outRoot string, settings *Settings, pm *ProcessMemory) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var toProcess bool
|
||||||
|
var outPath string
|
||||||
|
var ext string
|
||||||
|
if entry.IsDir() {
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
ext = filepath.Ext(inPath)
|
||||||
|
// NOTE: This could be an if statement, but keeping
|
||||||
|
// the switch makes it easy to extend the logic here later
|
||||||
|
switch ext {
|
||||||
|
case ".md":
|
||||||
|
toProcess = true
|
||||||
|
outPath = util.ReplaceRoot(inPath, outRoot)
|
||||||
|
outPath = util.ChangeExtension(outPath, ".html")
|
||||||
|
outPath = util.Indexify(outPath)
|
||||||
|
default:
|
||||||
|
toProcess = false
|
||||||
|
outPath = util.ReplaceRoot(inPath, outRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pd *PageData
|
||||||
|
hasFrontmatter := false
|
||||||
|
l := 0
|
||||||
|
if toProcess {
|
||||||
|
// process its frontmatter here
|
||||||
|
m, le, err := processFrontmatter(inPath)
|
||||||
|
l = le
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if m != nil {
|
||||||
|
hasFrontmatter = true
|
||||||
|
}
|
||||||
|
pd = buildPageData(m, inPath, outPath, settings)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
pd = nil
|
||||||
|
}
|
||||||
|
file := &File{
|
||||||
|
pd,
|
||||||
|
ext,
|
||||||
|
inPath,
|
||||||
|
outPath,
|
||||||
|
!toProcess,
|
||||||
|
hasFrontmatter,
|
||||||
|
l,
|
||||||
|
}
|
||||||
|
if pd != nil && pd.Type == "post" {
|
||||||
|
pm.Posts = append(pm.Posts, file)
|
||||||
|
}
|
||||||
|
pm.Files = append(pm.Files, file)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildProcessedFiles(pm *ProcessMemory, settings *Settings) error {
|
||||||
|
for _, f := range pm.Files {
|
||||||
|
err := BuildFile(f, settings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -8,7 +8,8 @@ import (
|
||||||
"github.com/ficcdaf/zona/internal/util"
|
"github.com/ficcdaf/zona/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
func processFile(inPath string, entry fs.DirEntry, err error, outRoot string, settings *Settings) error {
|
// TODO: Process the metadata and build a queue of files to convert here instead of converting them immediately
|
||||||
|
func buildFile(inPath string, entry fs.DirEntry, err error, outRoot string, settings *Settings) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -22,7 +23,7 @@ func processFile(inPath string, entry fs.DirEntry, err error, outRoot string, se
|
||||||
if err := util.CreateParents(outPath); err != nil {
|
if err := util.CreateParents(outPath); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ConvertFile(inPath, outPath, settings); err != nil {
|
if err := _BuildHtmlFile(inPath, outPath, settings); err != nil {
|
||||||
return errors.Join(errors.New("Error processing file "+inPath), err)
|
return errors.Join(errors.New("Error processing file "+inPath), err)
|
||||||
} else {
|
} else {
|
||||||
return nil
|
return nil
|
||||||
|
@ -46,8 +47,17 @@ func processFile(inPath string, entry fs.DirEntry, err error, outRoot string, se
|
||||||
|
|
||||||
func Traverse(root string, outRoot string, settings *Settings) error {
|
func Traverse(root string, outRoot string, settings *Settings) error {
|
||||||
walkFunc := func(path string, entry fs.DirEntry, err error) error {
|
walkFunc := func(path string, entry fs.DirEntry, err error) error {
|
||||||
return processFile(path, entry, err, outRoot, settings)
|
return buildFile(path, entry, err, outRoot, settings)
|
||||||
}
|
}
|
||||||
err := filepath.WalkDir(root, walkFunc)
|
err := filepath.WalkDir(root, walkFunc)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ProcessTraverse(root string, outRoot string, settings *Settings) (*ProcessMemory, error) {
|
||||||
|
pm := NewProcessMemory()
|
||||||
|
walkFunc := func(path string, entry fs.DirEntry, err error) error {
|
||||||
|
return processFile(path, entry, err, outRoot, settings, pm)
|
||||||
|
}
|
||||||
|
err := filepath.WalkDir(root, walkFunc)
|
||||||
|
return pm, err
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
@ -56,3 +58,54 @@ func CopyFile(inPath string, outPath string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadNLines reads the first N lines from a file as a single byte array
|
||||||
|
func ReadNLines(filename string, n int) ([]byte, error) {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for i := 0; i < 3 && scanner.Scan(); i++ {
|
||||||
|
buffer.Write(scanner.Bytes())
|
||||||
|
buffer.WriteByte('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLineRange reads a file in a given range of lines
|
||||||
|
func ReadLineRange(filename string, start int, end int) ([]byte, error) {
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
var buffer bytes.Buffer
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
i := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
if i >= start && (i <= end || end == -1) {
|
||||||
|
buffer.Write(scanner.Bytes())
|
||||||
|
buffer.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if i > end && end != -1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
69
internal/util/file_test.go
Normal file
69
internal/util/file_test.go
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
// FILE: internal/util/file_test.go
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadNLines(t *testing.T) {
|
||||||
|
// Create a temporary file
|
||||||
|
tmpfile, err := os.CreateTemp("", "testfile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpfile.Name()) // clean up
|
||||||
|
|
||||||
|
// Write some lines to the temporary file
|
||||||
|
content := []byte("line1\nline2\nline3\nline4\nline5\n")
|
||||||
|
if _, err := tmpfile.Write(content); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tmpfile.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the ReadNLines function
|
||||||
|
lines, err := ReadNLines(tmpfile.Name(), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadNLines failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := []byte("line1\nline2\nline3\n")
|
||||||
|
if !bytes.Equal(lines, expected) {
|
||||||
|
t.Errorf("Expected %q, got %q", expected, lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadLineRange(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string // description of this test case
|
||||||
|
// Named input parameters for target function.
|
||||||
|
filename string
|
||||||
|
start int
|
||||||
|
end int
|
||||||
|
want []byte
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
// TODO: Add test cases.
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, gotErr := ReadLineRange(tt.filename, tt.start, tt.end)
|
||||||
|
if gotErr != nil {
|
||||||
|
if !tt.wantErr {
|
||||||
|
t.Errorf("ReadLineRange() failed: %v", gotErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.wantErr {
|
||||||
|
t.Fatal("ReadLineRange() succeeded unexpectedly")
|
||||||
|
}
|
||||||
|
// TODO: update the condition below to compare got with tt.want.
|
||||||
|
if true {
|
||||||
|
t.Errorf("ReadLineRange() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@ package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -22,16 +23,24 @@ func ChangeExtension(in string, outExt string) string {
|
||||||
return strings.TrimSuffix(in, filepath.Ext(in)) + outExt
|
return strings.TrimSuffix(in, filepath.Ext(in)) + outExt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// find the root. check for a .zona.yml first,
|
||||||
|
// then check if it's cwd.
|
||||||
func getRoot(path string) string {
|
func getRoot(path string) string {
|
||||||
|
marker := ".zona.yml"
|
||||||
for {
|
for {
|
||||||
parent := filepath.Dir(path)
|
parent := filepath.Dir(path)
|
||||||
if parent == "." {
|
if parent == "/" {
|
||||||
break
|
panic(1)
|
||||||
|
}
|
||||||
|
candidate := filepath.Join(parent, marker)
|
||||||
|
// fmt.Printf("check for: %s\n", candidate)
|
||||||
|
if FileExists(candidate) {
|
||||||
|
return parent
|
||||||
|
} else if parent == "." {
|
||||||
|
return path
|
||||||
}
|
}
|
||||||
path = parent
|
path = parent
|
||||||
}
|
}
|
||||||
// fmt.Println("getRoot: ", path)
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReplaceRoot(inPath, outRoot string) string {
|
func ReplaceRoot(inPath, outRoot string) string {
|
||||||
|
@ -40,6 +49,40 @@ func ReplaceRoot(inPath, outRoot string) string {
|
||||||
return outPath
|
return outPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Indexify converts format path/file.ext
|
||||||
|
// into path/file/index.ext
|
||||||
|
func Indexify(in string) string {
|
||||||
|
ext := filepath.Ext(in)
|
||||||
|
trimmed := strings.TrimSuffix(in, ext)
|
||||||
|
filename := filepath.Base(trimmed)
|
||||||
|
if filename == "index" {
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSuffix(trimmed, filename)
|
||||||
|
return filepath.Join(prefix, filename, "index"+ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InDir checks whether checkPath is
|
||||||
|
// inside targDir.
|
||||||
|
func InDir(checkPath string, targDir string) bool {
|
||||||
|
// fmt.Println("checking dir..")
|
||||||
|
i := 0
|
||||||
|
for i < 10 {
|
||||||
|
parent := filepath.Dir(checkPath)
|
||||||
|
fmted := filepath.Base(parent)
|
||||||
|
switch fmted {
|
||||||
|
case targDir:
|
||||||
|
// fmt.Printf("%s in %s\n", checkPath, targDir)
|
||||||
|
return true
|
||||||
|
case ".":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
checkPath = parent
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// FileExists returns a boolean indicating
|
// FileExists returns a boolean indicating
|
||||||
// whether something exists at the path.
|
// whether something exists at the path.
|
||||||
func FileExists(path string) bool {
|
func FileExists(path string) bool {
|
||||||
|
@ -69,3 +112,29 @@ func StripTopDir(path string) string {
|
||||||
}
|
}
|
||||||
return filepath.Join(components[1:]...)
|
return filepath.Join(components[1:]...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveRelativeTo(relPath string, basePath string) string {
|
||||||
|
baseDir := filepath.Dir(basePath)
|
||||||
|
combined := filepath.Join(baseDir, relPath)
|
||||||
|
resolved := filepath.Clean(combined)
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// we want to preserve a valid web-style path
|
||||||
|
// and convert relative path to web-style
|
||||||
|
// so we need to see
|
||||||
|
func NormalizePath(target string, source string) (string, error) {
|
||||||
|
fmt.Printf("normalizing: %s\n", target)
|
||||||
|
// empty path is root
|
||||||
|
if target == "" {
|
||||||
|
return "/", nil
|
||||||
|
}
|
||||||
|
if target[0] == '.' {
|
||||||
|
resolved := resolveRelativeTo(target, source)
|
||||||
|
normalized := ReplaceRoot(resolved, "/")
|
||||||
|
fmt.Printf("Normalized: %s\n", normalized)
|
||||||
|
return normalized, nil
|
||||||
|
} else {
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
35
internal/util/path_test.go
Normal file
35
internal/util/path_test.go
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
package util_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/ficcdaf/zona/internal/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIndexify(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string // description of this test case
|
||||||
|
// Named input parameters for target function.
|
||||||
|
in string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"Simple Path",
|
||||||
|
"foo/bar/name.html",
|
||||||
|
"foo/bar/name/index.html",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Index Name",
|
||||||
|
"foo/bar/index.md",
|
||||||
|
"foo/bar/index.md",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := util.Indexify(tt.in)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("Indexify() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
26
internal/util/queue.go
Normal file
26
internal/util/queue.go
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
package util
|
||||||
|
|
||||||
|
// Enqueue appends an int to the queue
|
||||||
|
func Enqueue(queue []int, element int) []int {
|
||||||
|
queue = append(queue, element)
|
||||||
|
return queue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dequeue pops the first element of the queue
|
||||||
|
func Dequeue(queue []int) (int, []int) {
|
||||||
|
element := queue[0] // The first element is the one to be dequeued.
|
||||||
|
if len(queue) == 1 {
|
||||||
|
tmp := []int{}
|
||||||
|
return element, tmp
|
||||||
|
}
|
||||||
|
return element, queue[1:] // Slice off the element once it is dequeued.
|
||||||
|
}
|
||||||
|
|
||||||
|
func Tail(queue []int) int {
|
||||||
|
l := len(queue)
|
||||||
|
if l == 0 {
|
||||||
|
return -1
|
||||||
|
} else {
|
||||||
|
return l - 1
|
||||||
|
}
|
||||||
|
}
|
14
justfile
Normal file
14
justfile
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
# run go tests
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# test outputs
|
||||||
|
gentest:
|
||||||
|
#!/bin/bash
|
||||||
|
if [ -e foobar ]; then
|
||||||
|
rm -rf foobar
|
||||||
|
fi
|
||||||
|
|
||||||
|
go run cmd/zona/main.go test
|
||||||
|
|
||||||
|
# bat foobar/img.html
|
|
@ -5,4 +5,4 @@ fi
|
||||||
|
|
||||||
go run cmd/zona/main.go test
|
go run cmd/zona/main.go test
|
||||||
|
|
||||||
bat foobar/in.html
|
bat foobar/img.html
|
||||||
|
|
0
test/.zona.yml
Normal file
0
test/.zona.yml
Normal file
BIN
test/assets/pic.png
Normal file
BIN
test/assets/pic.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1 KiB |
3
test/img.md
Normal file
3
test/img.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# An image is in this page
|
||||||
|
|
||||||
|

|
|
@ -1,5 +1,5 @@
|
||||||
---
|
---
|
||||||
type: article
|
title: tiltetest
|
||||||
---
|
---
|
||||||
|
|
||||||
# My amazing markdown file!
|
# My amazing markdown file!
|
||||||
|
|
11
test/posts/in.md
Normal file
11
test/posts/in.md
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
---
|
||||||
|
title: tiltetest
|
||||||
|
icon: ../assets/pic.png
|
||||||
|
---
|
||||||
|
|
||||||
|
# My amazing markdown file!
|
||||||
|
|
||||||
|
I can _even_ do **this**!
|
||||||
|
|
||||||
|
- Or, I could...
|
||||||
|
- [Link](page.md) to this file
|
|
@ -1,5 +1,6 @@
|
||||||
---
|
---
|
||||||
title: Yaml testing file
|
title: Yaml testing file
|
||||||
|
type: article
|
||||||
---
|
---
|
||||||
|
|
||||||
# My amazing markdown file!
|
# My amazing markdown file!
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue