Compare commits

...
Sign in to create a new pull request.

30 commits

Author SHA1 Message Date
9a94052b42 updated todo 2025-06-15 19:16:16 -04:00
3355bc5544 added proper frontmatter struct 2025-05-03 00:17:34 -04:00
99bc128578 fixed relative path normalizing 2025-05-03 00:02:02 -04:00
8895ba969c added comments to recipes 2025-05-03 00:02:02 -04:00
c0429fa92b updated todo 2025-04-11 15:56:26 -04:00
577eeeab2d updated todo 2025-04-10 16:44:48 -04:00
af2f071a5a added ci for github mirroring 2025-04-08 16:44:37 -04:00
bdd9e63fdf working on path normalizer 2025-04-05 17:57:15 -04:00
59ead0f26a treat config file as site root 2025-04-05 17:31:14 -04:00
c263879904 added justfile 2025-04-05 16:51:38 -04:00
4b62ed116e renamed config file 2025-04-05 16:29:49 -04:00
fdec4b6f25 bumped dependencies and go version 2025-04-05 16:24:51 -04:00
988d4ba42e added proper output directory structure 2025-04-04 23:53:47 -04:00
fdb8753538 fix: metadata no longer rendered as part of page content 2025-03-24 00:44:36 -04:00
116fb6a883 restore previous functionality with new processing system 2025-03-18 00:24:03 -04:00
65b62ef9a6 added todo tracker 2025-02-08 00:15:51 -05:00
0ecad9e96a fixed frontmatter processing, added test 2025-02-08 00:11:28 -05:00
4629200510 added incomplete test 2025-02-03 21:13:46 -05:00
7644a31016 added build processed files
untested!
2025-02-03 21:13:20 -05:00
4315348cf5 added comment 2025-01-05 03:58:44 -05:00
4d27581f0a
improved frontmatter processing, added tests 2025-01-02 14:41:02 -05:00
587085df86
fixed some error formatting 2025-01-02 14:41:02 -05:00
af81617db5
fixed processFrontmatter and added test 2025-01-02 14:41:01 -05:00
35c14f09c0 begin implementing separate metadata parsing 2024-12-31 22:47:01 -05:00
709a2738f9 feat: image rendering now supports alt text for accessibility 2024-12-31 00:48:22 -05:00
ffe5ea4efc updated test file for image tag embedded rendering 2024-12-31 00:29:48 -05:00
525cbcd980 feat: rendering for image descriptions
Text inside an image's alt-text section `![like this text](img.png)`
will be treated as markdown and rendered as HTML inside `<small>` tags
(all still inside the image-container div).
2024-12-31 00:29:32 -05:00
ce706e4ff9 added tests for image renderer 2024-12-30 23:31:50 -05:00
0c1c842bcd added custom image renderer with image-container div 2024-12-30 23:31:43 -05:00
fb67ef046a added check for posts directory 2024-12-29 20:06:57 -05:00
26 changed files with 949 additions and 156 deletions

17
.build.yml Normal file
View 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
View 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!

View file

@ -42,8 +42,18 @@ func main() {
}
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 {
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
View file

@ -1,11 +1,10 @@
module github.com/ficcdaf/zona
// go 1.23.2
go 1.23.4
go 1.24.2
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
)
require golang.org/x/text v0.20.0
require golang.org/x/text v0.23.0

View file

@ -2,6 +2,7 @@ package builder
import (
"bytes"
"errors"
"fmt"
"html/template"
"log"
@ -24,11 +25,21 @@ type PageData struct {
FooterName string
Footer template.HTML
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
trimmed := bytes.TrimSpace(f)
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
split := strings.SplitN(normalized, "---\n", 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
if err := yaml.Unmarshal([]byte(split[1]), &meta); err != nil {
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{}
if title, ok := m["title"].(string); ok {
p.Title = util.WordsToTitle(title)
if m != nil && m.Title != "" {
p.Title = util.WordsToTitle(m.Title)
} else {
p.Title = util.PathToTitle(in)
}
if icon, ok := m["icon"].(string); ok {
p.Icon = icon
if m != nil && m.Icon != "" {
i, err := util.NormalizePath(m.Icon, in)
if err != nil {
p.Icon = settings.IconName
} else {
p.Icon = i
}
} else {
p.Icon = settings.IconName
}
var stylePath string
if style, ok := m["style"].(string); ok {
stylePath = style
if m != nil && m.Style != "" {
stylePath = m.Style
} else {
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)
}
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
p.Header = settings.Header
} else {
p.HeaderName = settings.HeaderName
p.Header = settings.Header
}
if footer, ok := m["footer"].(string); ok {
p.FooterName = footer
if m != nil && m.Footer != "" {
p.FooterName = m.Footer
p.Footer = settings.Footer
} else {
p.FooterName = settings.FooterName
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.Type = "post"
} else {
p.Template = (settings.DefaultTemplate)
p.Type = ""
}
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)
if err != nil {
return err
@ -126,3 +146,58 @@ func ConvertFile(in string, out string, settings *Settings) error {
err = util.WriteFile(output.Bytes(), out)
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
}

View file

@ -11,7 +11,7 @@ import (
)
var defaultNames = map[string]string{
"config": "config.yml",
"config": ".zona.yml",
"header": "header.md",
"footer": "footer.md",
"style": "style.css",
@ -22,7 +22,7 @@ var defaultNames = map[string]string{
}
//go:embed embed/article.html
//go:embed embed/config.yml
//go:embed embed/.zona.yml
//go:embed embed/default.html
//go:embed embed/favicon.png
//go:embed embed/footer.md

View file

@ -1,6 +1,7 @@
package builder
import (
"bytes"
"fmt"
"io"
"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) {
if entering {
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) {
if link, ok := node.(*ast.Link); ok {
renderLink(w, link, entering)
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
}
// 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 {
opts.RenderNodeHook = htmlRenderHook
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()
}

View file

@ -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)
}
}

View 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)
}
}

View 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
View 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
}

View file

@ -8,7 +8,8 @@ import (
"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 {
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 {
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)
} else {
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 {
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)
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
}

View file

@ -1,6 +1,8 @@
package util
import (
"bufio"
"bytes"
"io"
"os"
)
@ -56,3 +58,54 @@ func CopyFile(inPath string, outPath string) error {
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
}

View 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)
}
})
}
}

View file

@ -3,6 +3,7 @@ package util
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@ -22,16 +23,24 @@ func ChangeExtension(in string, outExt string) string {
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 {
marker := ".zona.yml"
for {
parent := filepath.Dir(path)
if parent == "." {
break
if parent == "/" {
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
}
// fmt.Println("getRoot: ", path)
return path
}
func ReplaceRoot(inPath, outRoot string) string {
@ -40,6 +49,40 @@ func ReplaceRoot(inPath, outRoot string) string {
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
// whether something exists at the path.
func FileExists(path string) bool {
@ -69,3 +112,29 @@ func StripTopDir(path string) string {
}
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
}
}

View 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
View 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
View 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

View file

@ -5,4 +5,4 @@ fi
go run cmd/zona/main.go test
bat foobar/in.html
bat foobar/img.html

0
test/.zona.yml Normal file
View file

BIN
test/assets/pic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

3
test/img.md Normal file
View file

@ -0,0 +1,3 @@
# An image is in this page
![my *alternate* text](assets/pic.png "my title")

View file

@ -1,5 +1,5 @@
---
type: article
title: tiltetest
---
# My amazing markdown file!

11
test/posts/in.md Normal file
View 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

View file

@ -1,5 +1,6 @@
---
title: Yaml testing file
type: article
---
# My amazing markdown file!