Compare commits

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

55 commits
main ... dev

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
63ca2a7b46 Merge branch 'dev' of github.com:ficcdaf/zona into dev 2024-12-28 21:22:39 -05:00
d934e0c250 updated go to 1.23.4 2024-12-04 14:36:53 -05:00
89f43ea03c Update readme 2024-12-02 00:38:38 -05:00
1467001f67 renamed build to install 2024-12-01 00:03:41 -05:00
58ee11622d fix install script 2024-11-30 23:40:31 -05:00
b30e0d3ed9 fixed yaml frontmatter not being parsed properly 2024-11-30 18:25:11 -05:00
46f7891e1b fixed config path resolution 2024-11-30 18:24:58 -05:00
ab1b7afaf8 fixed issue with header/footer rules not rendering properly 2024-11-30 18:24:36 -05:00
93c0359df2 fixed relative paths for stylesheet 2024-11-28 19:05:24 -05:00
065c344c03 fixed stylesheet embedding 2024-11-28 18:44:47 -05:00
c65ebfc809 fixed article template override 2024-11-28 18:15:12 -05:00
bfe7ddffd4 fixed settings parsing & embed 2024-11-28 18:03:00 -05:00
878cb3d3a8 finished config parser (untested)
refactored some config parser types
2024-11-25 16:22:00 -05:00
c6c801e248 continue working on config and default parsing 2024-11-25 16:05:35 -05:00
4d1b18fd12 refactoring; began implementing embedding 2024-11-25 14:55:45 -05:00
12ebba687b fixed title casing 2024-11-25 14:15:33 -05:00
ff1357c8da removed tree package 2024-11-25 14:15:22 -05:00
64e243773a implemented basic templating and default settings 2024-11-24 21:49:37 -05:00
11f724732d refactored some modules 2024-11-24 21:14:38 -05:00
68d2ddb692 Began implementing YAML metadata 2024-11-24 19:38:05 -05:00
7915a4bb09 Implemented proper conversion of links to local markdown files into html links 2024-11-24 17:47:08 -05:00
46e4f483f6 created testing functions for conversion operations 2024-11-24 17:46:36 -05:00
7aebcef803 added testing output dir to ignore list 2024-11-24 17:46:09 -05:00
a42cf66cee Implemented copying site directory and processing certain filetypes 2024-11-24 15:42:11 -05:00
e68611afb1 updated markdown package required 2024-11-18 00:18:08 -05:00
44 changed files with 2036 additions and 240 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

1
.gitignore vendored
View file

@ -24,3 +24,4 @@ bin/
go.sum go.sum
# test/ # test/
foobar/

View file

@ -1,67 +1,49 @@
# Zona # Zona
Zona is a small tool for building a static blog website. It allows users to write pages and blog posts in Markdown and automatically build them into a static, lightweight blog. Zona is a tool for building a static website, optimized for lightweight blogs following minimalist design principles.
> **Warning:** Implementing v1 functionality is still WIP. There will be binaries available to download from the Releases tab when Zona is ready to be used. Until then, you are welcome to download the source code, but I can't promise anything will work! **Warning:** Zona has not yet reached **v1**. The `dev-*` branches of this repository contain the code -- however, there is no assurance of stability or functionality until the first release. Configuration and usage documentation will also be provided at this time.
## Table of Contents ## Table of Contents
- [Features](#v1-features) - [Design Goals](#design-goals)
- [Installation](#installation) - [v1 Features](#v1-features)
- [Roadmap](#roadmap) - [Roadmap](#roadmap)
- [Contribution](#contribution)
- [Inspirations](#inspirations)
## Design Goals
Zona is intended to be easy-to-use. A user should be able to build a reasonably complex website or blog with only a directory of Markdown content and a single command, without needing to write any HTML or configuration. However, users should optionally have access to sensible and flexible configuration options, including writing HTML. The output of Zona should also be lightweight, retaining the smallest file sizes possible. These characteristics make Zona well-suited for both beginners and power users that wish to host a website on a service like Neocities or GitHub Pages.
## v1 Features ## v1 Features
- Write your pages in Markdown. - Write pages purely in Markdown.
- Build a lightweight website with zero JavaScript. - Single-command build process.
- Simple CLI build interface. - Lightweight output.
- HTML layout optimized for screen readers and Neocities. - Sensible default template and stylesheet.
- Configuration entirely optional, but very powerful.
## Getting Started - Site header and footer defined in Markdown.
- Declarative metadata per Markdown file.
### Dependencies - Automatically generated `Archive`, `Recent Posts`, and `Image Gallery` elements.
- Support for custom stylesheets, favicons, and page templates.
- `go 1.23.2`
```Bash
# On Arch Linux
sudo pacman -S go
# On Ubuntu/Debian
sudo apt install go
```
### Installation
First, download the repository and open it:
```Bash
git clone https://github.com/ficcdaf/zona.git && cd zona
```
On Linux:
```Bash
# run the provided build script
./build.sh
```
On other platforms:
```Bash
go build -o bin/zona cmd/zona
```
The resulting binary can be found at `bin/zona`.
## Roadmap ## Roadmap
- [ ] Zona configuration file to define build options. - [ ] RSS/Atom feed generation.
- [ ] Image optimization & dithering options. - [ ] Image optimization & dithering.
- [ ] AUR package after first release - [ ] Windows, Mac, Linux releases.
- [ ] Automatic RSS/Atom feed generation. - [ ] AUR package.
- [ ] Custom Markdown tags that expand to user-defined templates.
- [ ] Live preview local server.
## Contribution
Zona is a small project maintained by a very busy graduate student. If you want to contribute, you are more than welcome to submit issues and pull requests.
## Inspirations ## Inspirations
- [Zoner](https://git.sr.ht/~ryantrawick/zoner) - [Zoner](https://git.sr.ht/~ryantrawick/zoner)
- Zonelets - [Zonelets](https://zonelets.net/)
> Note: I am aware of `Zola`, and the similar name is entirely a coincidence. I have never used it, nor read its documentation, thus it is not listed as an inspiration.

106
README.md.orig Normal file
View file

@ -0,0 +1,106 @@
# Zona
Zona is a tool for building a static website, optimized for lightweight blogs following minimalist design principles.
**Warning:** Zona has not yet reached **v1**. The `dev-*` branches of this repository contain the code -- however, there is no assurance of stability or functionality until the first release. Configuration and usage documentation will also be provided at this time.
## Table of Contents
- [Features](#v1-features)
- [Installation](#installation)
- [Roadmap](#roadmap)
## v1 Features
- Write your pages in Markdown.
- Build a lightweight website with zero JavaScript.
- Simple CLI build interface.
- HTML layout optimized for screen readers and Neocities.
## Getting Started
### Dependencies
- `go 1.23.2`
```Bash
# On Arch Linux
sudo pacman -S go
# On Ubuntu/Debian
sudo apt install go
```
### Installation
First, download the repository and open it:
```Bash
git clone https://github.com/ficcdaf/zona.git && cd zona
```
On Linux:
```Bash
# run the provided build script
./build.sh
```
On other platforms:
```Bash
go build -o bin/zona cmd/zona
```
The resulting binary can be found at `bin/zona`.
## Roadmap
- [ ] Zona configuration file to define build options.
- [ ] Image optimization & dithering options.
- [ ] AUR package after first release
- [ ] Automatic RSS/Atom feed generation.
=======
- [Contribution](#contribution)
- [Inspirations](#inspirations)
## Design Goals
Zona is intended to be easy-to-use. A user should be able to build a reasonably complex website or blog with only a directory of Markdown content and a single command, without needing to write any HTML or configuration. However, users should optionally have access to sensible and flexible configuration options, including writing HTML. The output of Zona should also be lightweight, retaining the smallest file sizes possible. These characteristics make Zona well-suited for both beginners and power users that wish to host a website on a service like Neocities or GitHub Pages.
## v1 Features
- Write pages purely in Markdown.
- Single-command build process.
- Lightweight output.
- Sensible default template and stylesheet.
- Configuration entirely optional, but very powerful.
- Site header and footer defined in Markdown.
- Declarative metadata per Markdown file.
- Automatically generated `Archive`, `Recent Posts`, and `Image Gallery` elements.
- Support for custom stylesheets, favicons, and page templates.
## Roadmap
- [ ] RSS/Atom feed generation.
- [ ] Image optimization & dithering.
- [ ] Windows, Mac, Linux releases.
- [ ] AUR package.
- [ ] Custom Markdown tags that expand to user-defined templates.
- [ ] Live preview local server.
## Contribution
Zona is a small project maintained by a very busy graduate student. If you want to contribute, you are more than welcome to submit issues and pull requests.
>>>>>>> e368126 (Update readme)
## Inspirations
- [Zoner](https://git.sr.ht/~ryantrawick/zoner)
<<<<<<< HEAD
- Zonelets
=======
- [Zonelets](https://zonelets.net/)
> Note: I am aware of `Zola`, and the similar name is entirely a coincidence. I have never used it, nor read its documentation, thus it is not listed as an inspiration.
>>>>>>> e368126 (Update readme)

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

@ -1,4 +0,0 @@
#!/bin/bash
go build -o bin/zona ./cmd/zona
ln -sf bin/zona ./zona

View file

@ -6,19 +6,19 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/ficcdaf/zona/internal/util" "github.com/ficcdaf/zona/internal/builder"
) )
// validateFile checks whether a given path // // validateFile checks whether a given path
// is a valid file && matches an expected extension // // is a valid file && matches an expected extension
func validateFile(path, ext string) bool { // func validateFile(path, ext string) bool {
return (util.CheckExtension(path, ext) == nil) && (util.PathIsValid(path, true)) // return (util.CheckExtension(path, ext) == nil) && (util.PathIsValid(path, true))
} // }
func main() { func main() {
mdPath := flag.String("file", "", "Path to the markdown file.") rootPath := flag.String("file", "", "Path to the markdown file.")
flag.Parse() flag.Parse()
if *mdPath == "" { if *rootPath == "" {
// no flag provided, check for positional argument instead // no flag provided, check for positional argument instead
n := flag.NArg() n := flag.NArg()
var e error var e error
@ -27,7 +27,7 @@ func main() {
// we read the positional arg // we read the positional arg
arg := flag.Arg(0) arg := flag.Arg(0)
// mdPath wants a pointer so we get arg's address // mdPath wants a pointer so we get arg's address
mdPath = &arg rootPath = &arg
case 0: case 0:
// in case of no flag and no arg, we fail // in case of no flag and no arg, we fail
e = errors.New("Required argument missing!") e = errors.New("Required argument missing!")
@ -41,13 +41,19 @@ func main() {
} }
} }
// if !validateFile(*mdPath, ".md") { settings := builder.GetSettings(*rootPath, "foobar")
// fmt.Println("File validation failed!") // err := builder.Traverse(*rootPath, "foobar", settings)
// os.Exit(1) // traverse the source and process file metadata
// } pm, err := builder.ProcessTraverse(*rootPath, "foobar", settings)
// convert.ConvertFile(*mdPath, "test/test.html")
err := util.Traverse(*mdPath, "foobar")
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)
} }

9
go.mod
View file

@ -1,5 +1,10 @@
module github.com/ficcdaf/zona module github.com/ficcdaf/zona
go 1.23.2 go 1.24.2
require github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8 // indirect require (
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/text v0.23.0

5
install.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/bash
go mod tidy
go build -o bin/zona ./cmd/zona
sudo cp -f bin/zona /usr/bin/zona

View file

@ -0,0 +1,203 @@
package builder
import (
"bytes"
"errors"
"fmt"
"html/template"
"log"
"path/filepath"
"strings"
"github.com/ficcdaf/zona/internal/util"
"gopkg.in/yaml.v3"
)
type PageData struct {
Title string
Icon string
Stylesheet string
HeaderName string
Header template.HTML
Content template.HTML
NextPost string
PrevPost string
FooterName string
Footer template.HTML
Template string
Type string
}
type Metadata map[string]any
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")
if !strings.HasPrefix(normalized, ("---\n")) {
// No valid yaml, so return the entire content
return nil, f, nil
}
// Separate YAML from rest of document
split := strings.SplitN(normalized, "---\n", 3)
if len(split) < 3 {
return nil, nil, fmt.Errorf("invalid frontmatter format")
}
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
}
func buildPageData(m *FrontMatter, in string, out string, settings *Settings) *PageData {
p := &PageData{}
if m != nil && m.Title != "" {
p.Title = util.WordsToTitle(m.Title)
} else {
p.Title = util.PathToTitle(in)
}
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 m != nil && m.Style != "" {
stylePath = m.Style
} else {
stylePath = settings.StylePath
}
curDir := filepath.Dir(out)
relPath, err := filepath.Rel(curDir, stylePath)
// fmt.Printf("fp: %s, sp: %s, rp: %s\n", curDir, stylePath, relPath)
if err != nil {
log.Fatalln("Error calculating stylesheet path: ", err)
}
p.Stylesheet = relPath
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 m != nil && m.Footer != "" {
p.FooterName = m.Footer
p.Footer = settings.Footer
} else {
p.FooterName = settings.FooterName
p.Footer = settings.Footer
}
// 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 _BuildHtmlFile(in string, out string, settings *Settings) error {
mdPre, err := util.ReadFile(in)
if err != nil {
return err
}
metadata, md, err := processWithYaml(mdPre)
if err != nil {
return err
}
pd := buildPageData(metadata, in, out, settings)
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
}
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
}

155
internal/builder/config.go Normal file
View file

@ -0,0 +1,155 @@
package builder
import (
"embed"
"html/template"
"log"
"path/filepath"
"github.com/ficcdaf/zona/internal/util"
"gopkg.in/yaml.v3"
)
var defaultNames = map[string]string{
"config": ".zona.yml",
"header": "header.md",
"footer": "footer.md",
"style": "style.css",
"stylePath": filepath.Join("style", "style.css"),
"icon": "favicon.png",
"article": "article.html",
"template": "default.html",
}
//go:embed embed/article.html
//go:embed embed/.zona.yml
//go:embed embed/default.html
//go:embed embed/favicon.png
//go:embed embed/footer.md
//go:embed embed/header.md
//go:embed embed/style.css
var embedDir embed.FS
type Settings struct {
Header template.HTML
HeaderName string
Footer template.HTML
FooterName string
StylesheetName string
IconName string
DefaultTemplate string
DefaultTemplateName string
ArticleTemplate string
Stylesheet []byte
StylePath string
Icon []byte
}
var isDefaultStyle bool
// processSetting checks the user's configuration for
// each option. If set, reads the specified file. If not,
// default option is used.
func processSetting(c map[string]interface{}, s string) (string, []byte, error) {
if name, ok := c[s].(string); ok {
val, err := util.ReadFile(name)
if err != nil {
return "", nil, util.ErrorPrepend("Could not read "+s+" specified in config: ", err)
}
return name, val, nil
} else {
val := readEmbed(defaultNames[s])
isDefaultStyle = true
return defaultNames[s], val, nil
}
}
// buildSettings constructs the Settings struct.
func buildSettings(f []byte, outRoot string) (*Settings, error) {
s := &Settings{}
var c map[string]interface{}
// Parse YAML
if err := yaml.Unmarshal(f, &c); err != nil {
return nil, err
}
n, v, err := processSetting(c, "header")
if err != nil {
return nil, err
}
s.HeaderName = n
s.Header = template.HTML(MdToHTML(v))
n, v, err = processSetting(c, "footer")
if err != nil {
return nil, err
}
s.FooterName = n
s.Footer = template.HTML(MdToHTML(v))
isDefaultStyle = false
n, v, err = processSetting(c, "style")
if err != nil {
return nil, err
}
s.StylesheetName = n
s.Stylesheet = v
if isDefaultStyle {
stylePath := filepath.Join(outRoot, defaultNames["stylePath"])
s.StylePath = stylePath
err := util.CreateParents(stylePath)
if err != nil {
return nil, util.ErrorPrepend("Could not create default stylesheet directory: ", err)
}
err = util.WriteFile(s.Stylesheet, stylePath)
if err != nil {
return nil, util.ErrorPrepend("Could not create default stylesheet: ", err)
}
}
n, v, err = processSetting(c, "icon")
if err != nil {
return nil, err
}
s.IconName = n
s.Icon = v
n, v, err = processSetting(c, "template")
if err != nil {
return nil, err
}
s.DefaultTemplateName = n
s.DefaultTemplate = string(v)
artTemp := readEmbed(string(defaultNames["article"]))
s.ArticleTemplate = string(artTemp)
return s, nil
}
// readEmbed reads a file inside the embedded dir
func readEmbed(name string) []byte {
f, err := embedDir.ReadFile("embed/" + name)
if err != nil {
// panic(0)
log.Fatalf("Fatal internal error: Could not read embedded default %s! %u", name, err)
}
return f
}
func GetSettings(root string, outRoot string) *Settings {
var config []byte
configPath := filepath.Join(root, defaultNames["config"])
if !util.FileExists(configPath) {
// Config file does not exist, we used embedded default
config = readEmbed(defaultNames["config"])
} else {
var err error
// config, err = util.ReadFile(filepath.Join(root, configPath))
config, err = util.ReadFile(configPath)
if err != nil {
log.Fatalln("Fatal internal error: Config file exists but could not be read!", err)
}
}
s, err := buildSettings(config, outRoot)
if err != nil {
log.Fatalf("Fatal error: could not parse config: %u\n", err)
}
return s
}

166
internal/builder/convert.go Normal file
View file

@ -0,0 +1,166 @@
package builder
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"github.com/ficcdaf/zona/internal/util"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
// This function takes a Markdown document and returns an HTML document.
func MdToHTML(md []byte) []byte {
// create parser with extensions
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse(md)
// build HTML renderer
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := newZonaRenderer(opts)
return markdown.Render(doc, renderer)
}
// PathIsValid checks if a path is valid.
// If requireFile is set, directories are not considered valid.
func PathIsValid(path string, requireFile bool) bool {
s, err := os.Stat(path)
if os.IsNotExist(err) {
return false
} else if requireFile {
// fmt.Printf("Directory status: %s\n", strconv.FormatBool(s.IsDir()))
return !s.IsDir()
}
return err == nil
}
func processLink(p string) string {
// fmt.Println("Processing link...")
ext := filepath.Ext(p)
// Only process if it points to an existing, local markdown file
if ext == ".md" && filepath.IsLocal(p) {
// fmt.Println("Markdown link detected...")
return util.ChangeExtension(p, ".html")
} else {
return p
}
}
// 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))
fmt.Fprintf(w, `<a href="%s"`, destPath)
for _, attr := range html.BlockAttrs(l) {
fmt.Fprintf(w, ` %s`, attr)
}
io.WriteString(w, ">")
} else {
io.WriteString(w, "</a>")
}
}
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

@ -0,0 +1 @@
title: Something

View file

@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<title>{{ .Title }}</title>
<link rel="icon" href="{{ .Icon }}" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" />
<link
href="{{ .Stylesheet }}"
rel="stylesheet"
type="text/css"
media="all"
/>
</head>
<body>
<div id="container">
<header id="header">{{ .Header }}</header>
<article id="content">
{{ .Content }}
<nav id="nextprev">
{{ .NextPost }}<br />
{{ .PrevPost }}
</nav>
</article>
<footer id="footer">{{ .Footer }}</footer>
</div>
</body>
</html>

View file

@ -0,0 +1,22 @@
<!doctype html>
<html>
<head>
<title>{{ .Title }}</title>
<link rel="icon" href="{{ .Icon }}" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" />
<link
href="{{ .Stylesheet }}"
rel="stylesheet"
type="text/css"
media="all"
/>
</head>
<body>
<div id="container">
<header id="header">{{ .Header }}<hr></header>
{{ .Content }}
<footer id="footer"><hr>{{ .Footer }}</footer>
</div>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1 @@
# Footer File

View file

@ -0,0 +1 @@
# Header File

View file

@ -0,0 +1,148 @@
:root {
--main-text-color: white;
--main-bg-color: #1b1b1b;
--main-transparent: rgba(255, 255, 255, 0.15);
--main-small-text-color: rgba(255, 255, 255, 0.45);
}
body {
line-height: 1.6;
font-size: 18px;
font-family: sans-serif;
background: var(--main-bg-color);
color: var(--main-text-color);
padding-left: calc(100vw - 100%);
}
h1 {
margin-block-start: 0.67rem;
margin-block-end: 0.67rem;
font-size: 2rem;
font-weight: bold;
}
article h1:first-of-type {
margin-block-start: 1.67rem;
}
h2 {
margin-block-start: 0.83rem;
margin-block-end: 0.83rem;
font-size: 1.5rem;
font-weight: bold;
}
h3 {
margin-block-start: 1rem;
margin-block-end: 1rem;
font-size: 1.17em;
font-weight: bold;
}
h4 {
margin-block-start: 1.33rem;
margin-block-end: 1.33rem;
font-size: 1rem;
font-weight: bold;
}
article h1 + h4:first-of-type {
margin-block-start: 0rem;
}
h5 {
margin-block-start: 1.67rem;
margin-block-end: 1.67rem;
font-size: 0.83rem;
font-weight: bold;
}
h6 {
margin-block-start: 2.33rem;
margin-block-end: 2.33rem;
font-size: 0.67rem;
font-weight: bold;
}
a {
color: var(--main-text-color);
}
a:hover {
background: var(--main-transparent);
}
img {
width: auto;
height: auto;
}
blockquote {
color: var(--main-small-text-color);
border-left: 3px solid var(--main-transparent);
padding: 0 1rem;
margin-left: 0;
margin-right: 0;
}
hr {
border: none;
height: 1px;
background: var(--main-small-text-color);
}
code {
background: var(--main-transparent);
border-radius: 0.1875rem;
/* padding: .0625rem .1875rem; */
/* margin: 0 .1875rem; */
}
code,
pre {
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
small {
font-size: 0.95rem;
color: var(--main-small-text-color);
}
small a {
color: inherit; /* Inherit the color of the surrounding <small> text */
text-decoration: underline; /* Optional: Keep the underline to indicate a link */
}
.image-container {
text-align: center;
margin: 20px 0; /* Optional: add some spacing around the image container */
}
.image-container img {
/* max-width: 308px; */
max-height: 308px;
}
.image-container small {
display: block; /* Ensure the caption is on a new line */
margin-top: 5px; /* Optional: adjust spacing between image and caption */
}
.image-container small a {
color: inherit; /* Ensure the link color matches the small text */
text-decoration: underline; /* Optional: underline to indicate a link */
}
#header ul {
list-style-type: none;
padding-left: 0;
}
#header li {
display: inline;
font-size: 1.2rem;
margin-right: 1.2rem;
}
#container {
margin: 2.5rem auto;
width: 90%;
max-width: 60ch;
}
#postlistdiv ul {
list-style-type: none;
padding-left: 0;
}
.moreposts {
font-size: 0.95rem;
padding-left: 0.5rem;
}
#nextprev {
text-align: center;
margin-top: 1.4rem;
font-size: 0.95rem;
}
#footer {
color: var(--main-small-text-color);
}

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

@ -0,0 +1,63 @@
package builder
import (
"errors"
"io/fs"
"path/filepath"
"github.com/ficcdaf/zona/internal/util"
)
// 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
}
if !entry.IsDir() {
ext := filepath.Ext(inPath)
outPath := util.ReplaceRoot(inPath, outRoot)
switch ext {
case ".md":
// fmt.Println("Processing markdown...")
outPath = util.ChangeExtension(outPath, ".html")
if err := util.CreateParents(outPath); err != nil {
return err
}
if err := _BuildHtmlFile(inPath, outPath, settings); err != nil {
return errors.Join(errors.New("Error processing file "+inPath), err)
} else {
return nil
}
// If it's not a file we need to process,
// we simply copy it to the destination path.
default:
if err := util.CreateParents(outPath); err != nil {
return err
}
if err := util.CopyFile(inPath, outPath); err != nil {
return errors.Join(errors.New("Error processing file "+inPath), err)
} else {
return nil
}
}
}
// fmt.Printf("Visited: %s\n", inPath)
return nil
}
func Traverse(root string, outRoot string, settings *Settings) error {
walkFunc := func(path string, entry fs.DirEntry, err error) error {
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,76 +0,0 @@
package convert
import (
"io"
"os"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
)
// This function takes a Markdown document and returns an HTML document.
func MdToHTML(md []byte) ([]byte, error) {
// create parser with extensions
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
p := parser.NewWithExtensions(extensions)
doc := p.Parse(md)
// build HTML renderer
htmlFlags := html.CommonFlags | html.HrefTargetBlank
opts := html.RendererOptions{Flags: htmlFlags}
renderer := html.NewRenderer(opts)
return markdown.Render(doc, renderer), nil
}
// WriteFile writes a given byte array to the given path.
func WriteFile(b []byte, p string) error {
f, err := os.Create(p)
if err != nil {
return err
}
_, err = f.Write(b)
defer f.Close()
if err != nil {
return err
}
return nil
}
// ReadFile reads a byte array from a given path.
func ReadFile(p string) ([]byte, error) {
f, err := os.Open(p)
if err != nil {
return nil, err
}
var result []byte
buf := make([]byte, 1024)
for {
n, err := f.Read(buf)
// check for a non EOF error
if err != nil && err != io.EOF {
return nil, err
}
// n==0 when there are no chunks left to read
if n == 0 {
defer f.Close()
break
}
result = append(result, buf[:n]...)
}
return result, nil
}
func ConvertFile(in string, out string) error {
md, err := ReadFile(in)
if err != nil {
return err
}
html, err := MdToHTML(md)
if err != nil {
return err
}
err = WriteFile(html, out)
return err
}

View file

@ -1,50 +0,0 @@
package tree
// Node is a struct containing nodes of a file tree.
type Node struct {
// can be nil
Parent *Node
Name string
// Empty value mean directory
Ext string
// cannot be nil; may have len 0
Children []*Node
}
// NewNode constructs and returns a Node instance.
// parent and children are optional and can be nil,
// in which case Parent will be stored as nil,
// but Children will be initialized as []*Node of len 0.
// If ext == "", the Node is a directory.
func NewNode(name string, ext string, parent *Node, children []*Node) *Node {
var c []*Node
if children == nil {
c = make([]*Node, 0)
} else {
c = children
}
n := &Node{
Name: name,
Ext: ext,
Parent: parent,
Children: c,
}
return n
}
func (n *Node) IsRoot() bool {
return n.Parent == nil
}
func (n *Node) IsTail() bool {
return len(n.Children) == 0
}
func (n *Node) IsDir() bool {
return n.Ext == ""
}
// TODO: Implement recursive depth-first traversal to process a tree
func Traverse(root *Node) {
}

111
internal/util/file.go Normal file
View file

@ -0,0 +1,111 @@
package util
import (
"bufio"
"bytes"
"io"
"os"
)
// WriteFile writes a given byte array to the given path.
func WriteFile(b []byte, p string) error {
f, err := os.Create(p)
if err != nil {
return err
}
_, err = f.Write(b)
defer f.Close()
if err != nil {
return err
}
return nil
}
// ReadFile reads a byte array from a given path.
func ReadFile(p string) ([]byte, error) {
f, err := os.Open(p)
if err != nil {
return nil, err
}
var result []byte
buf := make([]byte, 1024)
for {
n, err := f.Read(buf)
// check for a non EOF error
if err != nil && err != io.EOF {
return nil, err
}
// n==0 when there are no chunks left to read
if n == 0 {
defer f.Close()
break
}
result = append(result, buf[:n]...)
}
return result, nil
}
// CopyFile reads the file at the input path, and write
// it to the output path.
func CopyFile(inPath string, outPath string) error {
inB, err := ReadFile(inPath)
if err != nil {
return err
}
if err := WriteFile(inB, outPath); err != nil {
return err
} else {
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

@ -4,7 +4,6 @@ package util
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -20,75 +19,122 @@ func CheckExtension(path, ext string) error {
} }
} }
// PathIsValid checks if a path is valid. func ChangeExtension(in string, outExt string) string {
// If requireFile is set, directories are not considered valid. return strings.TrimSuffix(in, filepath.Ext(in)) + outExt
func PathIsValid(path string, requireFile bool) bool {
s, err := os.Stat(path)
if os.IsNotExist(err) {
return false
} else if requireFile {
// fmt.Printf("Directory status: %s\n", strconv.FormatBool(s.IsDir()))
return !s.IsDir()
}
return err == nil
} }
// 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 {
relPath := strings.TrimPrefix(inPath, getRoot(inPath)) relPath := strings.TrimPrefix(inPath, getRoot(inPath))
outPath := filepath.Join(outRoot, relPath) outPath := filepath.Join(outRoot, relPath)
return outPath return outPath
} }
func createFileWithParents(path string) error { // 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 {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
// CreateParents creates the parent directories required for a given path
func CreateParents(path string) error {
dir := filepath.Dir(path) dir := filepath.Dir(path)
// Check if the parent directory already exists // Check if the parent directory already exists
// before trying to create it // before trying to create it
if _, dirErr := os.Stat(dir); os.IsNotExist(dirErr) { if _, dirErr := os.Stat(dir); os.IsNotExist(dirErr) {
// create directories // create directories
err := os.MkdirAll(dir, os.ModePerm) if err := os.MkdirAll(dir, os.ModePerm); err != nil {
if err != nil {
return err return err
} }
// TODO: write the file here
} }
return nil return nil
} }
func processFile(inPath string, entry fs.DirEntry, err error, outRoot string) error { func StripTopDir(path string) string {
if err != nil { cleanedPath := filepath.Clean(path)
return err components := strings.Split(cleanedPath, string(filepath.Separator))
if len(components) <= 1 {
return path
} }
if !entry.IsDir() { return filepath.Join(components[1:]...)
ext := filepath.Ext(inPath)
fmt.Println("Root: ", replaceRoot(inPath, outRoot))
switch ext {
case ".md":
fmt.Println("Processing markdown...")
default:
// All other file types, we copy!
}
}
fmt.Printf("Visited: %s\n", inPath)
return nil
} }
func Traverse(root string, outRoot string) error { func resolveRelativeTo(relPath string, basePath string) string {
// err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { baseDir := filepath.Dir(basePath)
walkFunc := func(path string, entry fs.DirEntry, err error) error { combined := filepath.Join(baseDir, relPath)
return processFile(path, entry, err, outRoot) resolved := filepath.Clean(combined)
} return resolved
err := filepath.WalkDir(root, walkFunc) }
return err
// 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
}
}

30
internal/util/title.go Normal file
View file

@ -0,0 +1,30 @@
package util
import (
"path/filepath"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// PathToWords takes a full path
// and strips separators and extension
// from the file name
func PathToWords(path string) string {
stripped := ChangeExtension(filepath.Base(path), "")
replaced := strings.NewReplacer("-", " ", "_", " ", `\ `, " ").Replace(stripped)
return strings.ToTitle(replaced)
}
func WordsToTitle(words string) string {
caser := cases.Title(language.English)
return caser.String(words)
}
// PathToTitle converts a full path to a string
// in title case
func PathToTitle(path string) string {
words := PathToWords(path)
return WordsToTitle(words)
}

23
internal/util/util.go Normal file
View file

@ -0,0 +1,23 @@
package util
import (
"errors"
"strings"
)
func NormalizeContent(content string) string {
var normalized []string
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
normalized = append(normalized, line)
}
}
return strings.Join(normalized, "\n")
}
// ErrorPrepend returns a new error with a message prepended to the given error.
func ErrorPrepend(m string, err error) error {
return errors.Join(errors.New(m), err)
}

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

8
runtest.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/bash
if [ -e foobar ]; then
rm -rf foobar
fi
go run cmd/zona/main.go test
bat foobar/img.html

28
templates/article.html Normal file
View file

@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<title>{{ .Title }}</title>
<link rel="icon" href="{{ .Icon }}" type="image/x-icon" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="UTF-8" />
<link
href="{{ .Stylesheet }}"
rel="stylesheet"
type="text/css"
media="all"
/>
</head>
<body>
<div id="container">
<header id="header">{{ .Header }}</header>
<article id="content">
{{ .Content }}
<nav id="nextprev">
{{ .NextPost }}<br />
{{ .PrevPost }}
</nav>
</article>
<footer id="footer">{{ .Footer }}</footer>
</div>
</body>
</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,3 +1,10 @@
---
title: tiltetest
---
# My amazing markdown file! # My amazing markdown file!
I can _even_ do **this**! I can _even_ do **this**!
- Or, I could...
- [Link](page.md) to this file

3
test/page.md Normal file
View file

@ -0,0 +1,3 @@
# My amazing markdown filez 2!
This file gets linked to...

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

148
test/style/style.css Normal file
View file

@ -0,0 +1,148 @@
:root {
--main-text-color: white;
--main-bg-color: #1b1b1b;
--main-transparent: rgba(255, 255, 255, 0.15);
--main-small-text-color: rgba(255, 255, 255, 0.45);
}
body {
line-height: 1.6;
font-size: 18px;
font-family: sans-serif;
background: var(--main-bg-color);
color: var(--main-text-color);
padding-left: calc(100vw - 100%);
}
h1 {
margin-block-start: 0.67rem;
margin-block-end: 0.67rem;
font-size: 2rem;
font-weight: bold;
}
article h1:first-of-type {
margin-block-start: 1.67rem;
}
h2 {
margin-block-start: 0.83rem;
margin-block-end: 0.83rem;
font-size: 1.5rem;
font-weight: bold;
}
h3 {
margin-block-start: 1rem;
margin-block-end: 1rem;
font-size: 1.17em;
font-weight: bold;
}
h4 {
margin-block-start: 1.33rem;
margin-block-end: 1.33rem;
font-size: 1rem;
font-weight: bold;
}
article h1 + h4:first-of-type {
margin-block-start: 0rem;
}
h5 {
margin-block-start: 1.67rem;
margin-block-end: 1.67rem;
font-size: 0.83rem;
font-weight: bold;
}
h6 {
margin-block-start: 2.33rem;
margin-block-end: 2.33rem;
font-size: 0.67rem;
font-weight: bold;
}
a {
color: var(--main-text-color);
}
a:hover {
background: var(--main-transparent);
}
img {
width: auto;
height: auto;
}
blockquote {
color: var(--main-small-text-color);
border-left: 3px solid var(--main-transparent);
padding: 0 1rem;
margin-left: 0;
margin-right: 0;
}
hr {
border: none;
height: 1px;
background: var(--main-small-text-color);
}
code {
background: var(--main-transparent);
border-radius: 0.1875rem;
/* padding: .0625rem .1875rem; */
/* margin: 0 .1875rem; */
}
code,
pre {
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
small {
font-size: 0.95rem;
color: var(--main-small-text-color);
}
small a {
color: inherit; /* Inherit the color of the surrounding <small> text */
text-decoration: underline; /* Optional: Keep the underline to indicate a link */
}
.image-container {
text-align: center;
margin: 20px 0; /* Optional: add some spacing around the image container */
}
.image-container img {
/* max-width: 308px; */
max-height: 308px;
}
.image-container small {
display: block; /* Ensure the caption is on a new line */
margin-top: 5px; /* Optional: adjust spacing between image and caption */
}
.image-container small a {
color: inherit; /* Ensure the link color matches the small text */
text-decoration: underline; /* Optional: underline to indicate a link */
}
#header ul {
list-style-type: none;
padding-left: 0;
}
#header li {
display: inline;
font-size: 1.2rem;
margin-right: 1.2rem;
}
#container {
margin: 2.5rem auto;
width: 90%;
max-width: 60ch;
}
#postlistdiv ul {
list-style-type: none;
padding-left: 0;
}
.moreposts {
font-size: 0.95rem;
padding-left: 0.5rem;
}
#nextprev {
text-align: center;
margin-top: 1.4rem;
font-size: 0.95rem;
}
#footer {
color: var(--main-small-text-color);
}

View file

@ -0,0 +1,6 @@
# My amazing markdown file!
I can _even_ do **this**!
- Or, I could...
- [Link](page.md) to this file

11
test/yamltest.md Normal file
View file

@ -0,0 +1,11 @@
---
title: Yaml testing file
type: article
---
# My amazing markdown file!
I can _even_ do **this**!
- Or, I could...
- [Link](page.md) to this file