diff --git a/.build.yml b/.build.yml new file mode 100644 index 0000000..7546403 --- /dev/null +++ b/.build.yml @@ -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 + + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..005efcf --- /dev/null +++ b/TODO.md @@ -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! diff --git a/cmd/zona/main.go b/cmd/zona/main.go index d61da6c..e1d8a59 100644 --- a/cmd/zona/main.go +++ b/cmd/zona/main.go @@ -42,8 +42,18 @@ func main() { } settings := builder.GetSettings(*rootPath, "foobar") - err := builder.Traverse(*rootPath, "foobar", settings) + // err := builder.Traverse(*rootPath, "foobar", settings) + // traverse the source and process file metadata + pm, err := builder.ProcessTraverse(*rootPath, "foobar", settings) if err != nil { fmt.Printf("Error: %s\n", err.Error()) + os.Exit(1) } + err = builder.BuildProcessedFiles(pm, settings) + if err != nil { + fmt.Printf("Error: %s\n", err.Error()) + os.Exit(1) + } + + fmt.Printf("%#v", pm) } diff --git a/go.mod b/go.mod index d9c42d7..8f10aa7 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,10 @@ module github.com/ficcdaf/zona -// go 1.23.2 -go 1.23.4 +go 1.24.2 require ( - github.com/gomarkdown/markdown v0.0.0-20241105142532-d03b89096d81 + github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/text v0.20.0 +require golang.org/x/text v0.23.0 diff --git a/internal/builder/build_page.go b/internal/builder/build_page.go index 783268f..4fa9b3b 100644 --- a/internal/builder/build_page.go +++ b/internal/builder/build_page.go @@ -2,6 +2,7 @@ package builder import ( "bytes" + "errors" "fmt" "html/template" "log" @@ -24,11 +25,21 @@ type PageData struct { FooterName string Footer template.HTML Template string + Type string } -type Metadata map[string]interface{} +type Metadata map[string]any -func processWithYaml(f []byte) (Metadata, []byte, error) { +type FrontMatter struct { + Title string `yaml:"title"` + Icon string `yaml:"icon"` + Style string `yaml:"style"` + Header string `yaml:"header"` + Footer string `yaml:"footer"` + Type string `yaml:"type"` +} + +func processWithYaml(f []byte) (*FrontMatter, []byte, error) { // Check if the file has valid metadata trimmed := bytes.TrimSpace(f) normalized := strings.ReplaceAll(string(trimmed), "\r\n", "\n") @@ -39,31 +50,36 @@ func processWithYaml(f []byte) (Metadata, []byte, error) { // Separate YAML from rest of document split := strings.SplitN(normalized, "---\n", 3) if len(split) < 3 { - return nil, nil, fmt.Errorf("Invalid frontmatter format.") + return nil, nil, fmt.Errorf("invalid frontmatter format") } - var meta Metadata + var meta FrontMatter // Parse YAML if err := yaml.Unmarshal([]byte(split[1]), &meta); err != nil { return nil, nil, err } - return meta, []byte(split[2]), nil + return &meta, []byte(split[2]), nil } -func buildPageData(m Metadata, in string, out string, settings *Settings) *PageData { +func buildPageData(m *FrontMatter, in string, out string, settings *Settings) *PageData { p := &PageData{} - if title, ok := m["title"].(string); ok { - p.Title = util.WordsToTitle(title) + if m != nil && m.Title != "" { + p.Title = util.WordsToTitle(m.Title) } else { p.Title = util.PathToTitle(in) } - if icon, ok := m["icon"].(string); ok { - p.Icon = icon + if m != nil && m.Icon != "" { + i, err := util.NormalizePath(m.Icon, in) + if err != nil { + p.Icon = settings.IconName + } else { + p.Icon = i + } } else { p.Icon = settings.IconName } var stylePath string - if style, ok := m["style"].(string); ok { - stylePath = style + if m != nil && m.Style != "" { + stylePath = m.Style } else { stylePath = settings.StylePath } @@ -74,30 +90,34 @@ func buildPageData(m Metadata, in string, out string, settings *Settings) *PageD log.Fatalln("Error calculating stylesheet path: ", err) } p.Stylesheet = relPath - if header, ok := m["header"].(string); ok { - p.HeaderName = header + + if m != nil && m.Header != "" { + p.HeaderName = m.Header // for now we use default anyways p.Header = settings.Header } else { p.HeaderName = settings.HeaderName p.Header = settings.Header } - if footer, ok := m["footer"].(string); ok { - p.FooterName = footer + if m != nil && m.Footer != "" { + p.FooterName = m.Footer p.Footer = settings.Footer } else { p.FooterName = settings.FooterName p.Footer = settings.Footer } - if t, ok := m["type"].(string); ok && t == "article" || t == "post" { + // TODO: Don't hard code posts dir name + if (m != nil && (m.Type == "article" || m.Type == "post")) || util.InDir(in, "posts") { p.Template = (settings.ArticleTemplate) + p.Type = "post" } else { p.Template = (settings.DefaultTemplate) + p.Type = "" } return p } -func ConvertFile(in string, out string, settings *Settings) error { +func _BuildHtmlFile(in string, out string, settings *Settings) error { mdPre, err := util.ReadFile(in) if err != nil { return err @@ -126,3 +146,58 @@ func ConvertFile(in string, out string, settings *Settings) error { err = util.WriteFile(output.Bytes(), out) return err } + +func BuildFile(f *File, settings *Settings) error { + if f.ShouldCopy { + if err := util.CreateParents(f.OutPath); err != nil { + return err + } + if err := util.CopyFile(f.InPath, f.OutPath); err != nil { + return errors.Join(errors.New("Error processing file "+f.InPath), err) + } else { + return nil + } + } + + if err := util.CreateParents(f.OutPath); err != nil { + return err + } + if err := BuildHtmlFile(f.FrontMatterLen, f.InPath, f.OutPath, f.PageData, settings); err != nil { + return errors.Join(errors.New("Error processing file "+f.InPath), err) + } else { + return nil + } +} + +func BuildHtmlFile(l int, in string, out string, pd *PageData, settings *Settings) error { + // WARN: ReadLineRange is fine, but l is the len of the frontmatter + // NOT including the delimiters! + start := l + // if the frontmatter exists (len > 0), then we need to + // account for two lines of delimiter! + if l != 0 { + start += 2 + } + md, err := util.ReadLineRange(in, start, -1) + if err != nil { + return err + } + fmt.Println("Title: ", pd.Title) + + // build according to template here + html := MdToHTML(md) + pd.Content = template.HTML(html) + + tmpl, err := template.New("webpage").Parse(pd.Template) + if err != nil { + return err + } + + var output bytes.Buffer + if err := tmpl.Execute(&output, pd); err != nil { + return err + } + + err = util.WriteFile(output.Bytes(), out) + return err +} diff --git a/internal/builder/config.go b/internal/builder/config.go index 831ec01..5d960e5 100644 --- a/internal/builder/config.go +++ b/internal/builder/config.go @@ -11,7 +11,7 @@ import ( ) var defaultNames = map[string]string{ - "config": "config.yml", + "config": ".zona.yml", "header": "header.md", "footer": "footer.md", "style": "style.css", @@ -22,7 +22,7 @@ var defaultNames = map[string]string{ } //go:embed embed/article.html -//go:embed embed/config.yml +//go:embed embed/.zona.yml //go:embed embed/default.html //go:embed embed/favicon.png //go:embed embed/footer.md diff --git a/internal/builder/convert.go b/internal/builder/convert.go index ed72fbd..12831b3 100644 --- a/internal/builder/convert.go +++ b/internal/builder/convert.go @@ -1,6 +1,7 @@ package builder import ( + "bytes" "fmt" "io" "os" @@ -53,6 +54,33 @@ func processLink(p string) string { } } +// renderImage outputs an ast Image node as HTML string. +func renderImage(w io.Writer, node *ast.Image, entering bool, next *ast.Text) { + // we add image-container div tag + // here before the opening img tag + if entering { + fmt.Fprintf(w, "
\n") + fmt.Fprintf(w, ` 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, `%s`, html) + } else { + // + io.WriteString(w, ">") + } + } else { + // if it's the closing img tag + // we close the div tag *after* + fmt.Fprintf(w, `
`) + fmt.Println("Image node not entering??") + } +} + func renderLink(w io.Writer, l *ast.Link, entering bool) { if entering { destPath := processLink(string(l.Destination)) @@ -66,15 +94,73 @@ func renderLink(w io.Writer, l *ast.Link, entering bool) { } } +func htmlRenderHookNoImage(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { + if link, ok := node.(*ast.Link); ok { + renderLink(w, link, entering) + return ast.GoToNext, true + } else if _, ok := node.(*ast.Image); ok { + // we do not render images + return ast.GoToNext, true + } + return ast.GoToNext, false +} + +// htmlRenderHook hooks the HTML renderer and overrides the rendering of certain nodes. func htmlRenderHook(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) { if link, ok := node.(*ast.Link); ok { renderLink(w, link, entering) return ast.GoToNext, true + } else if image, ok := node.(*ast.Image); ok { + var nextNode *ast.Text + if entering { + nextNodes := node.GetChildren() + fmt.Println("img next node len:", len(nextNodes)) + if len(nextNodes) == 1 { + if textNode, ok := nextNodes[0].(*ast.Text); ok { + nextNode = textNode + } + } + } + renderImage(w, image, entering, nextNode) + // Skip rendering of `nextNode` explicitly + if nextNode != nil { + return ast.SkipChildren, true + } + return ast.GoToNext, true } return ast.GoToNext, false } +// convertEmbedded renders markdown as HTML +// but does NOT render images +// also returns document AST +func convertEmbedded(md []byte) ([]byte, *ast.Node) { + p := parser.NewWithExtensions(parser.CommonExtensions) + doc := p.Parse(md) + htmlFlags := html.CommonFlags | html.HrefTargetBlank + opts := html.RendererOptions{Flags: htmlFlags} + opts.RenderNodeHook = htmlRenderHookNoImage + r := html.NewRenderer(opts) + html := markdown.Render(doc, r) + return html, &doc +} + func newZonaRenderer(opts html.RendererOptions) *html.Renderer { opts.RenderNodeHook = htmlRenderHook return html.NewRenderer(opts) } + +// ExtractPlainText walks the AST and extracts plain text from the Markdown input. +func extractPlainText(md []byte, doc *ast.Node) string { + var buffer bytes.Buffer + + // Walk the AST and extract text nodes + ast.WalkFunc(*doc, func(node ast.Node, entering bool) ast.WalkStatus { + if textNode, ok := node.(*ast.Text); ok && entering { + buffer.Write(textNode.Literal) // Append the text content + } + return ast.GoToNext + }) + + return buffer.String() +} diff --git a/internal/builder/convert_test.go b/internal/builder/convert_test.go deleted file mode 100644 index 45c4843..0000000 --- a/internal/builder/convert_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package builder_test - -import ( - "os" - "path/filepath" - "testing" - - "github.com/ficcdaf/zona/internal/builder" - "github.com/ficcdaf/zona/internal/util" -) - -func TestMdToHTML(t *testing.T) { - md := []byte("# Hello World\n\nThis is a test.") - expectedHTML := "

Hello World

\n

This is a test.

\n" - nExpectedHTML := util.NormalizeContent(expectedHTML) - html, err := builder.MdToHTML(md) - nHtml := util.NormalizeContent(string(html)) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if nHtml != nExpectedHTML { - t.Errorf("Expected:\n%s\nGot:\n%s", expectedHTML, html) - } -} - -func TestWriteFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "test.txt") - content := []byte("Hello, World!") - - err := builder.WriteFile(content, path) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Verify file content - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("Error reading file: %v", err) - } - if string(data) != string(content) { - t.Errorf("Expected:\n%s\nGot:\n%s", content, data) - } -} - -func TestReadFile(t *testing.T) { - path := filepath.Join(t.TempDir(), "test.txt") - content := []byte("Hello, World!") - - err := os.WriteFile(path, content, 0644) - if err != nil { - t.Fatalf("Error writing file: %v", err) - } - - data, err := builder.ReadFile(path) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - if string(data) != string(content) { - t.Errorf("Expected:\n%s\nGot:\n%s", content, data) - } -} - -func TestCopyFile(t *testing.T) { - src := filepath.Join(t.TempDir(), "source.txt") - dst := filepath.Join(t.TempDir(), "dest.txt") - content := []byte("File content for testing.") - - err := os.WriteFile(src, content, 0644) - if err != nil { - t.Fatalf("Error writing source file: %v", err) - } - - err = builder.CopyFile(src, dst) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Verify destination file content - data, err := os.ReadFile(dst) - if err != nil { - t.Fatalf("Error reading destination file: %v", err) - } - if string(data) != string(content) { - t.Errorf("Expected:\n%s\nGot:\n%s", content, data) - } -} - -func TestConvertFile(t *testing.T) { - src := filepath.Join(t.TempDir(), "test.md") - dst := filepath.Join(t.TempDir(), "test.html") - mdContent := []byte("# Test Title\n\nThis is Markdown content.") - nExpectedHTML := util.NormalizeContent("

Test Title

\n

This is Markdown content.

\n") - - err := os.WriteFile(src, mdContent, 0644) - if err != nil { - t.Fatalf("Error writing source Markdown file: %v", err) - } - - err = builder.ConvertFile(src, dst) - if err != nil { - t.Fatalf("Expected no error, got %v", err) - } - - // Verify destination HTML content - data, err := os.ReadFile(dst) - if err != nil { - t.Fatalf("Error reading HTML file: %v", err) - } - if util.NormalizeContent(string(data)) != nExpectedHTML { - t.Errorf("Expected:\n%s\nGot:\n%s", nExpectedHTML, data) - } -} - -func TestChangeExtension(t *testing.T) { - input := "test.md" - output := builder.ChangeExtension(input, ".html") - expected := "test.html" - - if output != expected { - t.Errorf("Expected %s, got %s", expected, output) - } -} diff --git a/internal/builder/embed/config.yml b/internal/builder/embed/.zona.yml similarity index 100% rename from internal/builder/embed/config.yml rename to internal/builder/embed/.zona.yml diff --git a/internal/builder/frontmatter.go b/internal/builder/frontmatter.go new file mode 100644 index 0000000..b25a1b0 --- /dev/null +++ b/internal/builder/frontmatter.go @@ -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) + } +} diff --git a/internal/builder/frontmatter_test.go b/internal/builder/frontmatter_test.go new file mode 100644 index 0000000..d6abf07 --- /dev/null +++ b/internal/builder/frontmatter_test.go @@ -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) + } + } + }) + } +} diff --git a/internal/builder/process.go b/internal/builder/process.go new file mode 100644 index 0000000..7108668 --- /dev/null +++ b/internal/builder/process.go @@ -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 +} diff --git a/internal/builder/traverse.go b/internal/builder/traverse.go index 5272dca..9022fef 100644 --- a/internal/builder/traverse.go +++ b/internal/builder/traverse.go @@ -8,7 +8,8 @@ import ( "github.com/ficcdaf/zona/internal/util" ) -func processFile(inPath string, entry fs.DirEntry, err error, outRoot string, settings *Settings) error { +// TODO: Process the metadata and build a queue of files to convert here instead of converting them immediately +func buildFile(inPath string, entry fs.DirEntry, err error, outRoot string, settings *Settings) error { if err != nil { return err } @@ -22,7 +23,7 @@ func processFile(inPath string, entry fs.DirEntry, err error, outRoot string, se if err := util.CreateParents(outPath); err != nil { return err } - if err := ConvertFile(inPath, outPath, settings); err != nil { + if err := _BuildHtmlFile(inPath, outPath, settings); err != nil { return errors.Join(errors.New("Error processing file "+inPath), err) } else { return nil @@ -46,8 +47,17 @@ func processFile(inPath string, entry fs.DirEntry, err error, outRoot string, se func Traverse(root string, outRoot string, settings *Settings) error { walkFunc := func(path string, entry fs.DirEntry, err error) error { - return processFile(path, entry, err, outRoot, settings) + return buildFile(path, entry, err, outRoot, settings) } err := filepath.WalkDir(root, walkFunc) return err } + +func ProcessTraverse(root string, outRoot string, settings *Settings) (*ProcessMemory, error) { + pm := NewProcessMemory() + walkFunc := func(path string, entry fs.DirEntry, err error) error { + return processFile(path, entry, err, outRoot, settings, pm) + } + err := filepath.WalkDir(root, walkFunc) + return pm, err +} diff --git a/internal/util/file.go b/internal/util/file.go index 2028018..4570eb2 100644 --- a/internal/util/file.go +++ b/internal/util/file.go @@ -1,6 +1,8 @@ package util import ( + "bufio" + "bytes" "io" "os" ) @@ -56,3 +58,54 @@ func CopyFile(inPath string, outPath string) error { return nil } } + +// ReadNLines reads the first N lines from a file as a single byte array +func ReadNLines(filename string, n int) ([]byte, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var buffer bytes.Buffer + scanner := bufio.NewScanner(file) + for i := 0; i < 3 && scanner.Scan(); i++ { + buffer.Write(scanner.Bytes()) + buffer.WriteByte('\n') + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +// ReadLineRange reads a file in a given range of lines +func ReadLineRange(filename string, start int, end int) ([]byte, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var buffer bytes.Buffer + scanner := bufio.NewScanner(file) + i := 0 + for scanner.Scan() { + if i >= start && (i <= end || end == -1) { + buffer.Write(scanner.Bytes()) + buffer.WriteByte('\n') + } + if i > end && end != -1 { + break + } + i++ + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} diff --git a/internal/util/file_test.go b/internal/util/file_test.go new file mode 100644 index 0000000..df42d4f --- /dev/null +++ b/internal/util/file_test.go @@ -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) + } + }) + } +} diff --git a/internal/util/path.go b/internal/util/path.go index 12fa15e..2442f87 100644 --- a/internal/util/path.go +++ b/internal/util/path.go @@ -3,6 +3,7 @@ package util import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -22,16 +23,24 @@ func ChangeExtension(in string, outExt string) string { return strings.TrimSuffix(in, filepath.Ext(in)) + outExt } +// find the root. check for a .zona.yml first, +// then check if it's cwd. func getRoot(path string) string { + marker := ".zona.yml" for { parent := filepath.Dir(path) - if parent == "." { - break + if parent == "/" { + panic(1) + } + candidate := filepath.Join(parent, marker) + // fmt.Printf("check for: %s\n", candidate) + if FileExists(candidate) { + return parent + } else if parent == "." { + return path } path = parent } - // fmt.Println("getRoot: ", path) - return path } func ReplaceRoot(inPath, outRoot string) string { @@ -40,6 +49,40 @@ func ReplaceRoot(inPath, outRoot string) string { return outPath } +// Indexify converts format path/file.ext +// into path/file/index.ext +func Indexify(in string) string { + ext := filepath.Ext(in) + trimmed := strings.TrimSuffix(in, ext) + filename := filepath.Base(trimmed) + if filename == "index" { + return in + } + prefix := strings.TrimSuffix(trimmed, filename) + return filepath.Join(prefix, filename, "index"+ext) +} + +// InDir checks whether checkPath is +// inside targDir. +func InDir(checkPath string, targDir string) bool { + // fmt.Println("checking dir..") + i := 0 + for i < 10 { + parent := filepath.Dir(checkPath) + fmted := filepath.Base(parent) + switch fmted { + case targDir: + // fmt.Printf("%s in %s\n", checkPath, targDir) + return true + case ".": + return false + } + checkPath = parent + i += 1 + } + return false +} + // FileExists returns a boolean indicating // whether something exists at the path. func FileExists(path string) bool { @@ -69,3 +112,29 @@ func StripTopDir(path string) string { } return filepath.Join(components[1:]...) } + +func resolveRelativeTo(relPath string, basePath string) string { + baseDir := filepath.Dir(basePath) + combined := filepath.Join(baseDir, relPath) + resolved := filepath.Clean(combined) + return resolved +} + +// we want to preserve a valid web-style path +// and convert relative path to web-style +// so we need to see +func NormalizePath(target string, source string) (string, error) { + fmt.Printf("normalizing: %s\n", target) + // empty path is root + if target == "" { + return "/", nil + } + if target[0] == '.' { + resolved := resolveRelativeTo(target, source) + normalized := ReplaceRoot(resolved, "/") + fmt.Printf("Normalized: %s\n", normalized) + return normalized, nil + } else { + return target, nil + } +} diff --git a/internal/util/path_test.go b/internal/util/path_test.go new file mode 100644 index 0000000..ccac2fc --- /dev/null +++ b/internal/util/path_test.go @@ -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) + } + }) + } +} diff --git a/internal/util/queue.go b/internal/util/queue.go new file mode 100644 index 0000000..3fce4fa --- /dev/null +++ b/internal/util/queue.go @@ -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 + } +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..39f5308 --- /dev/null +++ b/justfile @@ -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 diff --git a/runtest.sh b/runtest.sh index d20c26e..5df5005 100755 --- a/runtest.sh +++ b/runtest.sh @@ -5,4 +5,4 @@ fi go run cmd/zona/main.go test -bat foobar/in.html +bat foobar/img.html diff --git a/test/.zona.yml b/test/.zona.yml new file mode 100644 index 0000000..e69de29 diff --git a/test/assets/pic.png b/test/assets/pic.png new file mode 100644 index 0000000..9470f08 Binary files /dev/null and b/test/assets/pic.png differ diff --git a/test/img.md b/test/img.md new file mode 100644 index 0000000..6c2fce1 --- /dev/null +++ b/test/img.md @@ -0,0 +1,3 @@ +# An image is in this page + +![my *alternate* text](assets/pic.png "my title") diff --git a/test/in.md b/test/in.md index 2fd2350..57171ba 100644 --- a/test/in.md +++ b/test/in.md @@ -1,5 +1,5 @@ --- -type: article +title: tiltetest --- # My amazing markdown file! diff --git a/test/posts/in.md b/test/posts/in.md new file mode 100644 index 0000000..fc679ea --- /dev/null +++ b/test/posts/in.md @@ -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 diff --git a/test/yamltest.md b/test/yamltest.md index 974dedb..ffa1fb3 100644 --- a/test/yamltest.md +++ b/test/yamltest.md @@ -1,5 +1,6 @@ --- title: Yaml testing file +type: article --- # My amazing markdown file!