Compare commits
10 commits
Author | SHA1 | Date | |
---|---|---|---|
da9589ec57 | |||
d31fc4b1c4 | |||
c797c0e85d | |||
18800e1cd8 | |||
ea286868b7 | |||
1c05dd4a46 | |||
2d3480e94d | |||
e368126761 | |||
79ade5e8a3 | |||
c0b98d7a99 |
47 changed files with 44 additions and 2114 deletions
|
@ -14,4 +14,3 @@ tasks:
|
||||||
git remote add github "$github"
|
git remote add github "$github"
|
||||||
git push --mirror github
|
git push --mirror github
|
||||||
|
|
||||||
|
|
||||||
|
|
72
README.md
72
README.md
|
@ -1,49 +1,65 @@
|
||||||
# Zona
|
# Zona
|
||||||
|
|
||||||
Zona is a tool for building a static website, optimized for lightweight blogs following minimalist design principles.
|
**IMPORTANT:** Zona is currently migrating to a Python implementation. The new
|
||||||
|
repository is called [zona-py](https://git.sr.ht/~ficd/zona-py). **It will
|
||||||
|
replace this repository once the migration is complete.**
|
||||||
|
|
||||||
**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.
|
[Zona](https://sr.ht/~ficd/zona/) is a tool for building a static website,
|
||||||
|
optimized for lightweight blogs following minimalist design principles. The
|
||||||
|
project is hosted on [sourcehut](https://sr.ht/~ficd/zona/) and mirrored on
|
||||||
|
[GitHub](https://github.com/ficcdaf/zona). I am not accepting GitHub issues,
|
||||||
|
please make your submission to the
|
||||||
|
[issue tracker](https://todo.sr.ht/~ficd/zona) or send an email to the public
|
||||||
|
mailing list at
|
||||||
|
[~ficd/zona-devel@lists.sr.ht](mailto:~ficd/zona-devel@lists.sr.ht)
|
||||||
|
|
||||||
## Table of Contents
|
<!-- prettier-ignore-start -->
|
||||||
|
|
||||||
- [Design Goals](#design-goals)
|
> Zona is currently in development. The `main` branch of this repository does
|
||||||
- [v1 Features](#v1-features)
|
> not yet contain the software. The `dev-stable` branch contains the code used
|
||||||
- [Roadmap](#roadmap)
|
> to generate [ficd.ca](https://ficd.ca) -- although the program is undocumented
|
||||||
- [Contribution](#contribution)
|
> and missing features, so please proceed at your own risk. The `dev` branch
|
||||||
- [Inspirations](#inspirations)
|
> contains the latest development updates and is not guaranteed to be functional
|
||||||
|
> (or even compile) at any given commit. Kindly note that the commit history
|
||||||
|
> will be cleaned up before the program is merged into the `main` branch.
|
||||||
|
|
||||||
|
<!-- prettier-ignore-end -->
|
||||||
|
|
||||||
## Design Goals
|
## 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.
|
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
|
## Features Implemented
|
||||||
|
|
||||||
- Write pages purely in Markdown.
|
- Write pages purely in Markdown.
|
||||||
- Single-command build process.
|
- Single-command build process.
|
||||||
- Lightweight output.
|
- Lightweight output.
|
||||||
- Sensible default template and stylesheet.
|
- Sensible default template and stylesheet.
|
||||||
- Configuration entirely optional, but very powerful.
|
- Configuration file.
|
||||||
|
- Internal links preserved.
|
||||||
|
- Custom image element parsing & formatting.
|
||||||
- Site header and footer defined in Markdown.
|
- Site header and footer defined in Markdown.
|
||||||
- Declarative metadata per Markdown file.
|
- YAML frontmatter support.
|
||||||
- Automatically generated `Archive`, `Recent Posts`, and `Image Gallery` elements.
|
|
||||||
|
## Planned Features
|
||||||
|
|
||||||
|
- Automatically treat contents of `posts/` directory as blog posts.
|
||||||
|
- Automatically generated `Archive`, `Recent Posts`, and `Image Gallery`
|
||||||
|
elements.
|
||||||
- Support for custom stylesheets, favicons, and page templates.
|
- Support for custom stylesheets, favicons, and page templates.
|
||||||
|
- Image optimization and dithering.
|
||||||
## Roadmap
|
- Custom markdown tags that expand to user-defined templates.
|
||||||
|
- Live preview server.
|
||||||
- [ ] RSS/Atom feed generation.
|
- Robust tests.
|
||||||
- [ ] 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.
|
|
||||||
|
|
||||||
## Inspirations
|
## Inspirations
|
||||||
|
|
||||||
- [Zoner](https://git.sr.ht/~ryantrawick/zoner)
|
- [Zoner](https://git.sr.ht/~ryantrawick/zoner)
|
||||||
- [Zonelets](https://zonelets.net/)
|
- [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
106
README.md.orig
|
@ -1,106 +0,0 @@
|
||||||
# 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
52
TODO.md
|
@ -1,52 +0,0 @@
|
||||||
# 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!
|
|
|
@ -1,59 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/ficcdaf/zona/internal/builder"
|
|
||||||
)
|
|
||||||
|
|
||||||
// // validateFile checks whether a given path
|
|
||||||
// // is a valid file && matches an expected extension
|
|
||||||
// func validateFile(path, ext string) bool {
|
|
||||||
// return (util.CheckExtension(path, ext) == nil) && (util.PathIsValid(path, true))
|
|
||||||
// }
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
rootPath := flag.String("file", "", "Path to the markdown file.")
|
|
||||||
flag.Parse()
|
|
||||||
if *rootPath == "" {
|
|
||||||
// no flag provided, check for positional argument instead
|
|
||||||
n := flag.NArg()
|
|
||||||
var e error
|
|
||||||
switch n {
|
|
||||||
case 1:
|
|
||||||
// we read the positional arg
|
|
||||||
arg := flag.Arg(0)
|
|
||||||
// mdPath wants a pointer so we get arg's address
|
|
||||||
rootPath = &arg
|
|
||||||
case 0:
|
|
||||||
// in case of no flag and no arg, we fail
|
|
||||||
e = errors.New("Required argument missing!")
|
|
||||||
default:
|
|
||||||
// more args than expected is also fail
|
|
||||||
e = errors.New("Unexpected arguments!")
|
|
||||||
}
|
|
||||||
if e != nil {
|
|
||||||
fmt.Printf("Error: %s\n", e.Error())
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
settings := builder.GetSettings(*rootPath, "foobar")
|
|
||||||
// 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)
|
|
||||||
}
|
|
10
go.mod
10
go.mod
|
@ -1,10 +0,0 @@
|
||||||
module github.com/ficcdaf/zona
|
|
||||||
|
|
||||||
go 1.24.2
|
|
||||||
|
|
||||||
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
|
|
|
@ -1,5 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
go mod tidy
|
|
||||||
go build -o bin/zona ./cmd/zona
|
|
||||||
sudo cp -f bin/zona /usr/bin/zona
|
|
|
@ -1,203 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,155 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,166 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
title: Something
|
|
|
@ -1,28 +0,0 @@
|
||||||
<!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>
|
|
|
@ -1,22 +0,0 @@
|
||||||
<!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.
Before Width: | Height: | Size: 2.5 KiB |
|
@ -1 +0,0 @@
|
||||||
# Footer File
|
|
|
@ -1 +0,0 @@
|
||||||
# Header File
|
|
|
@ -1,148 +0,0 @@
|
||||||
: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);
|
|
||||||
}
|
|
|
@ -1,86 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,183 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,116 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,63 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,111 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
|
@ -1,69 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,140 +0,0 @@
|
||||||
// Package util provides general utilities.
|
|
||||||
package util
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CheckExtension checks if the file located at path (string)
|
|
||||||
// matches the provided extension type
|
|
||||||
func CheckExtension(path, ext string) error {
|
|
||||||
if filepath.Ext(path) == ext {
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
return errors.New("Invalid extension.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 == "/" {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ReplaceRoot(inPath, outRoot string) string {
|
|
||||||
relPath := strings.TrimPrefix(inPath, getRoot(inPath))
|
|
||||||
outPath := filepath.Join(outRoot, relPath)
|
|
||||||
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 {
|
|
||||||
_, 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)
|
|
||||||
// Check if the parent directory already exists
|
|
||||||
// before trying to create it
|
|
||||||
if _, dirErr := os.Stat(dir); os.IsNotExist(dirErr) {
|
|
||||||
// create directories
|
|
||||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func StripTopDir(path string) string {
|
|
||||||
cleanedPath := filepath.Clean(path)
|
|
||||||
components := strings.Split(cleanedPath, string(filepath.Separator))
|
|
||||||
if len(components) <= 1 {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,35 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,26 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,30 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
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
14
justfile
|
@ -1,14 +0,0 @@
|
||||||
# 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
|
|
|
@ -1,8 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
if [ -e foobar ]; then
|
|
||||||
rm -rf foobar
|
|
||||||
fi
|
|
||||||
|
|
||||||
go run cmd/zona/main.go test
|
|
||||||
|
|
||||||
bat foobar/img.html
|
|
|
@ -1,28 +0,0 @@
|
||||||
<!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>
|
|
Binary file not shown.
Before Width: | Height: | Size: 1 KiB |
|
@ -1,3 +0,0 @@
|
||||||
# An image is in this page
|
|
||||||
|
|
||||||

|
|
10
test/in.md
10
test/in.md
|
@ -1,10 +0,0 @@
|
||||||
---
|
|
||||||
title: tiltetest
|
|
||||||
---
|
|
||||||
|
|
||||||
# My amazing markdown file!
|
|
||||||
|
|
||||||
I can _even_ do **this**!
|
|
||||||
|
|
||||||
- Or, I could...
|
|
||||||
- [Link](page.md) to this file
|
|
|
@ -1,3 +0,0 @@
|
||||||
# My amazing markdown filez 2!
|
|
||||||
|
|
||||||
This file gets linked to...
|
|
|
@ -1,11 +0,0 @@
|
||||||
---
|
|
||||||
title: tiltetest
|
|
||||||
icon: ../assets/pic.png
|
|
||||||
---
|
|
||||||
|
|
||||||
# My amazing markdown file!
|
|
||||||
|
|
||||||
I can _even_ do **this**!
|
|
||||||
|
|
||||||
- Or, I could...
|
|
||||||
- [Link](page.md) to this file
|
|
|
@ -1,148 +0,0 @@
|
||||||
: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);
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
<h1 id="my-amazing-markdown-file">My amazing markdown file!</h1>
|
|
||||||
|
|
||||||
<p>I can <em>even</em> do <strong>this</strong>!</p>
|
|
|
@ -1,6 +0,0 @@
|
||||||
# My amazing markdown file!
|
|
||||||
|
|
||||||
I can _even_ do **this**!
|
|
||||||
|
|
||||||
- Or, I could...
|
|
||||||
- [Link](page.md) to this file
|
|
|
@ -1,11 +0,0 @@
|
||||||
---
|
|
||||||
title: Yaml testing file
|
|
||||||
type: article
|
|
||||||
---
|
|
||||||
|
|
||||||
# My amazing markdown file!
|
|
||||||
|
|
||||||
I can _even_ do **this**!
|
|
||||||
|
|
||||||
- Or, I could...
|
|
||||||
- [Link](page.md) to this file
|
|
1
zona
1
zona
|
@ -1 +0,0 @@
|
||||||
bin/zona
|
|
Loading…
Add table
Add a link
Reference in a new issue