// parse.go
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"git.else-if.org/jess/blog/templates"
"github.com/BurntSushi/toml"
mathjax "github.com/litao91/goldmark-mathjax"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"go.abhg.dev/goldmark/frontmatter"
"gopkg.in/yaml.v3"
)
// GetContentPath ensures the content is cached and returns the path to the cached file.
func GetContentPath(file ContentFile) (string, error) {
cachePath := GetCacheFilename(file.OriginalPath)
// Read Raw File
raw, err := ReadRaw(file.OriginalPath)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
var dataToWrite []byte
if !file.IsMarkdown {
dataToWrite = raw
} else {
// Configure Goldmark with the custom tag extension
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
&frontmatter.Extender{},
mathjax.MathJax,
templates.SidebarTag, // ||| ->
templates.TopBanner,
),
goldmark.WithRendererOptions(
html.WithUnsafe(),
),
)
var buf bytes.Buffer
ctx := parser.NewContext()
if err := md.Convert(raw, &buf, parser.WithContext(ctx)); err != nil {
return "", fmt.Errorf("failed to parse markdown: %w", err)
}
// Extract Frontmatter
// 1. Initialize with External Metadata (if any)
var meta templates.PageMetadata
if err := loadExternalMetadata(file.OriginalPath, &meta); err != nil {
fmt.Printf("Warning: failed to load external metadata for %s: %v\n", file.OriginalPath, err)
}
// 2. Parse Internal Frontmatter (overrides external)
d := frontmatter.Get(ctx)
if d != nil {
// Decode into a temporary map to capture all fields
var rawMap map[string]interface{}
if err := d.Decode(&rawMap); err != nil {
fmt.Printf("Warning: failed to decode frontmatter for %s: %v\n", file.OriginalPath, err)
} else {
// Merge into meta
if meta.Raw == nil {
meta.Raw = make(map[string]interface{})
}
for k, v := range rawMap {
meta.Raw[k] = v
// Also update struct fields manually if needed, or use layout decoding
switch strings.ToLower(k) {
case "title":
if s, ok := v.(string); ok {
meta.Title = s
}
case "stylesheet":
if s, ok := v.(string); ok {
meta.Stylesheet = s
}
case "style":
if s, ok := v.(string); ok {
meta.Style = s
}
}
}
}
}
// 3. Resolve Stylesheet Path
if meta.Stylesheet != "" {
// If not absolute path (starting with /), resolve relative to content file
if !strings.HasPrefix(meta.Stylesheet, "/") {
// Get valid web path for the directory containing the file
// file.RoutePath is like "/entries/entry1" or "/index"
// We want the directory part of the RoutePath
dirRoute := filepath.Dir(file.RoutePath)
if dirRoute == "." {
dirRoute = ""
}
// Construct new path
// e.g. /entries + / + style.css -> /entries/style.css
// Clean handles double slashes
meta.Stylesheet = filepath.Join(dirRoute, meta.Stylesheet)
// Ensure it starts with / for web usage
if !strings.HasPrefix(meta.Stylesheet, "/") {
meta.Stylesheet = "/" + meta.Stylesheet
}
}
}
if meta.Title == "" {
base := filepath.Base(file.OriginalPath)
meta.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
htmlContent := buf.String()
// 3. Post-Process for Index (Dynamic Content Injection)
// If Home Page, replace content with Latest Post Preview
if file.RoutePath == "/" {
// Find latest post
var latestPost *ContentFile
for _, f := range AllContent {
if f.IsMarkdown && f.RoutePath != "/" {
// Found one
latestPost = &f
break // AllContent is sorted by time descending
}
}
if latestPost != nil {
// Read the latest post
postRaw, err := ReadRaw(latestPost.OriginalPath)
if err == nil {
// Parse the post to HTML to display as preview
// We need to parse it fully to get the HTML content
// Re-using the MD parser config from above would be cleaner but let's instantiate for now
mdPreview := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
&frontmatter.Extender{},
mathjax.MathJax,
templates.SidebarTag,
templates.TopBanner,
),
goldmark.WithRendererOptions(html.WithUnsafe()),
)
var previewBuf bytes.Buffer
ctxPreview := parser.NewContext()
if err := mdPreview.Convert(postRaw, &previewBuf, parser.WithContext(ctxPreview)); err == nil {
// Extract Metadata of the post to update Page Title?
// User said: "the [title] be big text embedded in the black area."
// So if we are previewing the latest post, the Home Page Title should probably match the Post Title on the Home Page?
// Or should it say "Latest: Title"?
// The main header h1 is where {{Title}} goes.
// Getting metadata from preview context
var postMeta templates.PageMetadata
dPost := frontmatter.Get(ctxPreview)
if dPost != nil {
dPost.Decode(&postMeta)
}
// Fallback title logic
if postMeta.Title == "" {
base := filepath.Base(latestPost.OriginalPath)
postMeta.Title = strings.TrimSuffix(base, filepath.Ext(base))
}
// Update the meta for the HOME PAGE render
meta.Title = postMeta.Title // Use the post's title
// Extract Preview (First Paragraph?)
// "preview of the latest post, then read more"
fullHTML := previewBuf.String()
// Simple strategy: take everything up to the first
or limit chars?
// Or explicit split?
// Let's take the first paragraph.
parts := strings.Split(fullHTML, "")
if len(parts) > 0 {
previewHTML := parts[0] + ""
// Add Read More Link
readMore := fmt.Sprintf(`Read More...
`, latestPost.RoutePath)
htmlContent = previewHTML + readMore
} else {
htmlContent = fullHTML
}
}
}
}
}
// [directory] Tag Expansion
// User can put [directory] in any page (e.g. archive.md) to generate the list
if strings.Contains(htmlContent, "[directory]") {
// Generate Directory HTML (Grouped by Year)
var dirHTML strings.Builder
dirHTML.WriteString(``)
// Group by year
type YearGroup struct {
Year int
Posts []templates.PostSnippet
}
var years []YearGroup
for _, f := range AllContent {
if f.IsMarkdown && f.RoutePath != "/" {
name := filepath.Base(f.OriginalPath)
title := strings.TrimSuffix(name, filepath.Ext(name))
// Get Metadata for title? Ideally yes, but costly to parse all?
// Let's rely on cached AllContent if we had metadata there, but we don't.
// For now, filename title is fast.
year := f.ModTime.Year()
// Add to current year group or new one
if len(years) == 0 || years[len(years)-1].Year != year {
years = append(years, YearGroup{Year: year})
}
years[len(years)-1].Posts = append(years[len(years)-1].Posts, templates.PostSnippet{
Title: title,
URL: f.RoutePath,
Date: f.ModTime,
})
}
}
for _, yg := range years {
dirHTML.WriteString(fmt.Sprintf(`
%d
`, yg.Year))
for _, p := range yg.Posts {
dirHTML.WriteString(fmt.Sprintf(`- %s (%s)
`,
p.URL, p.Title, p.Date.Format("Jan 02")))
}
dirHTML.WriteString(`
`)
}
dirHTML.WriteString(`
`)
htmlContent = strings.ReplaceAll(htmlContent, "[directory]", dirHTML.String())
}
/**
Add More Custom tags here. Add a customTag in tags.go as well, and the implementation in a template file.
*/
// ||| SideBar
sidebarRegex := regexp.MustCompile(`(?s).*?`)
if sidebarRegex.MatchString(htmlContent) {
// Render the dynamic content
// Generate posts data
var posts []templates.PostSnippet
for _, f := range AllContent {
if f.IsMarkdown && f.RoutePath != "/" {
name := filepath.Base(f.OriginalPath)
title := strings.TrimSuffix(name, filepath.Ext(name))
posts = append(posts, templates.PostSnippet{
Title: title,
URL: f.RoutePath,
Date: f.ModTime,
})
}
}
latestPostsHTML := templates.RenderLatestPosts(posts)
dirLink := templates.RenderDirectoryLink()
// Wrap in