From f8b31941995b5bae460a5342b1897cfabc29cf9f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 13 Feb 2026 10:28:37 +0000 Subject: [PATCH] rethem --- content/archive.md | 1 + content/default.css | 390 +++++++++++++++++------------ content/entries/test_math.md | 17 ++ content/theme.js | 131 ++++++++++ go.mod | 1 + go.sum | 7 + parse.go | 458 ++++++++++++++++++++++++++++++++--- templates/style.go | 45 +++- templates/tags.go | 1 - 9 files changed, 849 insertions(+), 202 deletions(-) create mode 100644 content/archive.md create mode 100644 content/entries/test_math.md create mode 100644 content/theme.js diff --git a/content/archive.md b/content/archive.md new file mode 100644 index 0000000..a4a7385 --- /dev/null +++ b/content/archive.md @@ -0,0 +1 @@ +[directory] diff --git a/content/default.css b/content/default.css index c49940e..2e16ab0 100644 --- a/content/default.css +++ b/content/default.css @@ -1,20 +1,29 @@ /* content/default.css */ -/* - The "27bslash6" Aesthetic: - - Stark Black & White - - Helvetica / Arial - - Narrow, centered reading column - - Zero border radius, zero shadows -*/ - :root { --bg-color: #ffffff; - --text-color: #000000; - --link-color: #cc0000; /* That specific "angry" red he sometimes uses, or just black */ - --meta-color: #666666; - --font-stack: "Helvetica Neue", Helvetica, Arial, sans-serif; - --max-width: 680px; /* The classic narrow column */ + --header-bg: #000000; + --header-text: #ffffff; + --text-color: #333333; + --link-color: #000000; + --accent-color: #cc0000; + --sidebar-bg: #ffffff; + --sidebar-border: #000000; + --font-primary: "Helvetica Neue", Helvetica, Arial, sans-serif; + --font-mono: "Monaco", "Menlo", monospace; + --content-width: 680px; +} + +body.dark-mode { + --bg-color: #1a1a1a; + --header-bg: #000000; + /* Keep header black, maybe slightly lighter? User said "dark theme" */ + --header-text: #e0e0e0; + --text-color: #e0e0e0; + --link-color: #ffffff; + --accent-color: #ff4444; + --sidebar-bg: #2a2a2a; + --sidebar-border: #555555; } * { @@ -22,216 +31,279 @@ } body { + margin: 0; + padding: 0; background-color: var(--bg-color); color: var(--text-color); - font-family: var(--font-stack); - font-size: 15px; /* Slightly smaller, crisp text */ - line-height: 1.5; - margin: 0; - padding: 2rem 1rem; + font-family: var(--font-primary); + font-size: 18px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; } -/* The main document container */ -body > * { - max-width: var(--max-width); - margin-left: auto; - margin-right: auto; - display: block; +/* Header - Stark Black Block */ +/* Ensure header text color uses variable */ +.main-header { + background-color: var(--header-bg); + color: var(--header-text); + /* Make header taller per request */ + padding: 6rem 1rem; + margin-bottom: 5rem; +} + +.header-content { + max-width: var(--content-width); + margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; + /* Space between logo and text */ +} + +.header-logo img { + height: 80px; + /* Adjust as needed */ + width: auto; + margin: 0; + border: none; +} + +.main-header h1 { + margin: 0; + /* Make title bigger per request */ + font-size: 4rem; + font-weight: 700; + letter-spacing: -1px; + line-height: 1; + text-align: left; +} + +/* Content Container */ +.main-content { + max-width: var(--content-width); + margin: 0 auto; + padding: 0 1rem 8rem 1rem; + /* Bottom padding for scroll space */ } /* Typography */ -h1, h2, h3 { - font-weight: bold; - letter-spacing: -0.5px; - margin-top: 2.5rem; - margin-bottom: 1rem; +/* Update headers to use text color variable */ +h1, +h2, +h3, +h4 { + color: var(--text-color); + margin-top: 2.5em; + margin-bottom: 0.8em; + font-weight: 700; + line-height: 1.2; } -h1 { - font-size: 24px; - border-bottom: 1px solid #000; - padding-bottom: 10px; - margin-top: 0; +/* First Line Logic - "Sugar" */ +/* Update first paragraph color */ +.main-content>p:first-of-type { + font-size: 1.3em; + color: var(--text-color); + /* Was hardcoded #000 */ + margin-bottom: 2em; + line-height: 1.5; } -h2 { - font-size: 18px; +/* Dark Mode Toggle Button */ +#theme-toggle { + background: none; + border: 1px solid var(--sidebar-border); + color: var(--text-color); + padding: 5px 10px; + cursor: pointer; + font-family: var(--font-primary); + margin-top: 1rem; + width: 100%; + text-transform: uppercase; + font-size: 0.8rem; +} + +#theme-toggle:hover { + background-color: var(--accent-color); + color: #fff; + border-color: var(--accent-color); } p { - margin-bottom: 1.2rem; - text-align: justify; /* Gives it that "formal letter" look */ + margin-bottom: 1.5em; } -/* Links - Stark and obvious */ a { - color: var(--text-color); + color: var(--link-color); text-decoration: underline; text-decoration-thickness: 1px; + text-underline-offset: 2px; } a:hover { - color: var(--link-color); - text-decoration: none; + color: var(--accent-color); + text-decoration-color: var(--accent-color); } -/* Code blocks - Raw and industrial */ -pre { - background: #f0f0f0; - border: 1px solid #ccc; - padding: 15px; - font-family: "Monaco", "Menlo", "Consolas", monospace; - font-size: 12px; - overflow-x: auto; - margin: 1.5rem 0; -} - -code { - font-family: "Monaco", "Menlo", "Consolas", monospace; - background: #f0f0f0; - padding: 2px 4px; - font-size: 90%; -} - -/* Blockquotes - The "Email Reply" look */ -blockquote { - margin: 1.5rem 0; - padding-left: 15px; - border-left: 4px solid #000; - color: var(--meta-color); - font-style: italic; -} - -/* Images - fit the column */ +/* Images - Full width of container */ img { max-width: 100%; height: auto; display: block; - margin: 2rem auto; - border: 1px solid #000; /* Optional: adds to the brutalist feel */ + margin: 3rem auto; } -/* Lists */ -ul, ol { - padding-left: 20px; - margin-bottom: 1.5rem; +/* Code */ +pre { + background: #f4f4f4; + padding: 1.5rem; + overflow-x: auto; + font-size: 0.9em; + border-radius: 4px; + /* Slight softening */ + margin: 2rem 0; } -li { - margin-bottom: 0.5rem; +code { + font-family: var(--font-mono); + background: #f4f4f4; + padding: 2px 4px; + font-size: 0.9em; } -/* - Specific Components -*/ - -/* Top Banner - The "System Notice" Style -*/ -.topbanner { - background-color: #f4f4f4; /* Very light grey, distinct from white body */ - border: 1px solid #000; /* Crisp definition */ - border-left-width: 6px; /* Thick accent, matches your sidebar theme */ - padding: 1.5rem 2rem; /* Generous padding for readability */ - margin-bottom: 3rem; /* Push the H1 down */ - font-size: 14px; /* Slightly smaller than body text */ +blockquote { + border-left: 3px solid #000; + margin: 2rem 0; + padding-left: 1.5rem; + font-style: italic; + color: #666; } -/* Ensure text inside the banner doesn't inherit the 'justify' from global p */ -.topbanner p { - text-align: left; - margin-bottom: 0.5rem; -} - -.topbanner p:last-child { - margin-bottom: 0; -} - -/* Optional: Make links inside the banner aggressive */ -.topbanner a { - font-weight: bold; - border-bottom: 2px solid var(--link-color); - text-decoration: none; -} - -.topbanner a:hover { - background-color: var(--link-color); - color: white; -} - -/* The Site Headline (Top of Index) */ -.site-headline { - margin-bottom: 3rem; - text-align: left; -} - -.site-headline h1 { - font-size: 32px; - border: none; - padding: 0; - margin: 0; - letter-spacing: -1px; -} - -/* Sidebar Styles */ +/* Sidebar - The "Ghost" Panel */ .sidebar { - float: right; - width: 30%; - min-width: 250px; - margin-left: 2rem; - margin-bottom: 1rem; - padding: 1rem; - background-color: #f9f9f9; - border-left: 4px solid #333; + position: fixed; + right: 2rem; + bottom: 2rem; + width: 300px; + background: var(--sidebar-bg); + border: 1px solid var(--sidebar-border); + padding: 2rem; + opacity: 0; + /* Default hidden */ + pointer-events: none; + transition: opacity 0.3s ease; + z-index: 100; + box-shadow: 10px 10px 0px rgba(0, 0, 0, 0.1); + /* 27bslash6 vibe */ } -/* The Latest Posts List */ -.latest-posts { - margin-top: 3rem; - border-top: 4px solid #000; - padding-top: 1rem; +.sidebar.force-visible { + opacity: 1 !important; + pointer-events: auto !important; +} + +#close-sidebar { + position: absolute; + top: 5px; + right: 10px; + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + font-weight: bold; + color: var(--text-color); } .latest-posts h2 { - font-size: 14px; + margin-top: 0; + font-size: 1rem; text-transform: uppercase; - color: var(--meta-color); - margin-bottom: 1rem; + border-bottom: 2px solid var(--sidebar-border); + padding-bottom: 0.5rem; } .latest-posts ul { list-style: none; padding: 0; + margin: 0; } .latest-posts li { + margin-bottom: 0.8rem; + font-size: 0.9rem; display: flex; - justify-content: space-between; - border-bottom: 1px solid #eee; - padding: 8px 0; + flex-direction: column; } .latest-posts .date { - color: var(--meta-color); - font-size: 12px; - margin-right: 1rem; - min-width: 100px; + font-size: 0.75rem; + color: #888; + margin-bottom: 2px; } .latest-posts a { text-decoration: none; - font-weight: bold; + font-weight: 500; } .latest-posts a:hover { - text-decoration: underline; - color: var(--link-color); + color: var(--accent-color); } -/* Directory Link */ .directory-link { - margin: 2rem 0; - text-align: right; - font-size: 12px; - text-transform: uppercase; + margin-top: 1.5rem; + text-align: center; + font-size: 0.9rem; font-weight: bold; + text-transform: uppercase; +} + +/* Mobile Trigger */ +#read-more-trigger { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + background: #000; + color: #fff; + text-align: center; + padding: 1rem; + font-weight: bold; + cursor: pointer; + transform: translateY(100%); + transition: transform 0.3s ease; + z-index: 90; + display: none; + /* Desktop default hidden */ +} + +/* Mobile Tweaks */ +@media (max-width: 900px) { + .sidebar { + display: none; + /* Hide standard sidebar on mobile */ + } + + .sidebar.force-visible { + display: block; + left: 1rem; + right: 1rem; + bottom: 4rem; + /* Above trigger */ + width: auto; + } + + #read-more-trigger { + display: block; + /* Active on mobile */ + } + + #read-more-trigger.visible { + transform: translateY(0); + } + + .main-header h1 { + font-size: 2rem; + } } \ No newline at end of file diff --git a/content/entries/test_math.md b/content/entries/test_math.md new file mode 100644 index 0000000..aa77f4c --- /dev/null +++ b/content/entries/test_math.md @@ -0,0 +1,17 @@ +--- +title: "Math Test" +--- + +This is a test of **MathJax** support. + +Inline math: $E=mc^2$ is the theory of special relativity. + +Block math: +$$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +$$ + +Another block: +$$ +\int_{-\infty}^{\infty} e^{-x^2} dx = \sqrt{\pi} +$$ diff --git a/content/theme.js b/content/theme.js new file mode 100644 index 0000000..36c1df5 --- /dev/null +++ b/content/theme.js @@ -0,0 +1,131 @@ +document.addEventListener('DOMContentLoaded', () => { + const sidebar = document.getElementById('sidebar'); + const closeBtn = document.getElementById('close-sidebar'); + const trigger = document.getElementById('read-more-trigger'); + const body = document.body; + let manualClose = false; + + // --- Sidebar Logic --- + + function checkSidebarVisibility() { + if (manualClose) return; + + const scrollTop = window.scrollY; + const windowHeight = window.innerHeight; + const docHeight = document.documentElement.scrollHeight; + + // "Sidebar fix": Check if page is short (content fits in window or close to it) + // If content is short, sidebar should be visible immediately (if desktop) + if (docHeight <= windowHeight * 1.2 && window.innerWidth > 900) { + sidebar.classList.add('visible'); + sidebar.style.opacity = 1; + sidebar.style.pointerEvents = 'auto'; + return; + } + + // Scroll Logic for longer pages + if (scrollTop + windowHeight > docHeight * 0.9) { + sidebar.classList.add('visible'); + trigger.classList.add('visible'); + + // Dynamic Opacity near bottom + const remaining = docHeight - (scrollTop + windowHeight); + const threshold = docHeight * 0.1; + + if (remaining < threshold) { + const opacity = 1 - (remaining / threshold); + sidebar.style.opacity = Math.min(Math.max(opacity, 0), 1); + + if (opacity > 0) { + sidebar.style.pointerEvents = 'auto'; + } else { + sidebar.style.pointerEvents = 'none'; + } + } else { + // Should be redundant with visibility check but safe + } + } else { + // Hide if scrolled up? + // "It just is gone then... manual close... refresh if want it back" + // But scroll behavior implies transient visibility. + // We'll hide it if we scroll back up, unless it's short content. + sidebar.style.opacity = 0; + sidebar.style.pointerEvents = 'none'; + } + } + + window.addEventListener('scroll', checkSidebarVisibility); + window.addEventListener('resize', checkSidebarVisibility); + + // Initial check + setTimeout(checkSidebarVisibility, 100); // Slight delay for rendering + + // Mobile Trigger + trigger.addEventListener('click', () => { + sidebar.classList.add('force-visible'); + sidebar.style.opacity = 1; + sidebar.style.pointerEvents = 'auto'; + }); + + // Close Button + closeBtn.addEventListener('click', () => { + sidebar.classList.remove('visible'); + sidebar.classList.remove('force-visible'); + sidebar.style.opacity = 0; + sidebar.style.pointerEvents = 'none'; + manualClose = true; + trigger.style.display = 'none'; + }); + + // --- Dark Mode Logic --- + + // Create Toggle Button + const sidebarContent = document.querySelector('.sidebar-content'); + const themeBtn = document.createElement('button'); + themeBtn.id = 'theme-toggle'; + themeBtn.textContent = 'Toggle Theme'; + sidebarContent.appendChild(themeBtn); + + function setDarkMode(enable) { + if (enable) { + body.classList.add('dark-mode'); + themeBtn.textContent = 'Switch to Light Mode'; + } else { + body.classList.remove('dark-mode'); + themeBtn.textContent = 'Switch to Dark Mode'; + } + } + + function getCookie(name) { + const v = document.cookie.match('(^|;) ?' + name + '=([^;]*)(;|$)'); + return v ? v[2] : null; + } + + function setCookie(name, value, hours) { + const d = new Date(); + d.setTime(d.getTime() + (hours * 60 * 60 * 1000)); + document.cookie = name + "=" + value + ";path=/;expires=" + d.toUTCString(); + } + + // Init Dark Mode + const cookieTheme = getCookie('theme'); + if (cookieTheme === 'dark') { + setDarkMode(true); + } else if (cookieTheme === 'light') { + setDarkMode(false); + } else { + // Time based auto-detect + const hour = new Date().getHours(); + if (hour < 6 || hour >= 18) { + setDarkMode(true); + } else { + setDarkMode(false); + } + } + + themeBtn.addEventListener('click', () => { + const isDark = body.classList.contains('dark-mode'); + setDarkMode(!isDark); + setCookie('theme', !isDark ? 'dark' : 'light', 24); + }); +}); diff --git a/go.mod b/go.mod index 4f8d212..fbe5dcf 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require go.abhg.dev/goldmark/frontmatter v0.3.0 require ( github.com/BurntSushi/toml v1.6.0 // indirect + github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f // indirect github.com/tendstofortytwo/goldmark-customtag v0.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0b623c1..4bf0a52 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,25 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f h1:plCPYXRXDCO57qjqegCzaVf1t6aSbgCMD+zfz18POfs= +github.com/litao91/goldmark-mathjax v0.0.0-20210217064022-a43cf739a50f/go.mod h1:leg+HM7jUS84JYuY120zmU68R6+UeU6uZ/KAW7cViKE= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tendstofortytwo/goldmark-customtag v0.0.1 h1:c3Wnzi98gE7BPaqio/UOQVGGJU0EMdgKwjQHo9Y7NKQ= github.com/tendstofortytwo/goldmark-customtag v0.0.1/go.mod h1:9KHP0WeHIafExF+Zu4CIDfzgfMo/FaEKXUO7NFmCyos= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.abhg.dev/goldmark/frontmatter v0.3.0 h1:ZOrMkeyyYzhlbenFNmOXyGFx1dFE8TgBWAgZfs9D5RA= go.abhg.dev/goldmark/frontmatter v0.3.0/go.mod h1:W3KXvVveKKxU1FIFZ7fgFFQrlkcolnDcOVmu19cCO9U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/parse.go b/parse.go index d9694f3..8b0ee1e 100644 --- a/parse.go +++ b/parse.go @@ -8,40 +8,24 @@ import ( "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) - // 1. Check Cache Freshness (if not in TestMode) - if !TestMode { - cacheInfo, errCache := os.Stat(cachePath) - origInfo, errOrig := os.Stat(file.OriginalPath) - if errCache == nil && errOrig == nil && cacheInfo.ModTime().After(origInfo.ModTime()) { - return cachePath, nil - } - } - - // 2. Regenerate Content - cacheMutex.Lock() - defer cacheMutex.Unlock() - - if !TestMode { - cacheInfo, errCache := os.Stat(cachePath) - origInfo, errOrig := os.Stat(file.OriginalPath) - if errCache == nil && errOrig == nil && cacheInfo.ModTime().After(origInfo.ModTime()) { - return cachePath, nil - } - } // Read Raw File raw, err := ReadRaw(file.OriginalPath) @@ -59,9 +43,9 @@ func GetContentPath(file ContentFile) (string, error) { goldmark.WithExtensions( extension.GFM, &frontmatter.Extender{}, + mathjax.MathJax, templates.SidebarTag, // ||| -> templates.TopBanner, - templates.Mathtext, ), goldmark.WithRendererOptions( html.WithUnsafe(), @@ -75,11 +59,67 @@ func GetContentPath(file ContentFile) (string, error) { } // 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 { - if err := d.Decode(&meta); err != 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 + } } } @@ -91,21 +131,130 @@ func GetContentPath(file ContentFile) (string, error) { htmlContent := buf.String() // 3. Post-Process for Index (Dynamic Content Injection) + // If Home Page, replace content with Latest Post Preview if file.RoutePath == "/" { - // Generate posts data - var posts []templates.PostSnippet + // 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)) - - posts = append(posts, templates.PostSnippet{ + + // 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. @@ -116,6 +265,21 @@ func GetContentPath(file ContentFile) (string, error) { 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() @@ -146,23 +310,114 @@ func GetContentPath(file ContentFile) (string, error) { } -} - - // Mathtext mathtext - mathtextRegex := regexp.MustCompile("(?s).*?") - if mathtextRegex.MatchString(htmlContent) { - htmlContent = mathtextRegex.ReplaceAllStringFunc(htmlContent, func(match string) string { - innerContent := strings.TrimPrefix(match, "") - innerContent = strings.TrimSuffix(innerContent, "") - return fmt.Sprintf("
  • %s
  • ", innerContent) + + + // [filename] Image Resolution + // Matches [filename.ext] or [filename] + // Avoids matches that look like markdown links [text](url) or existing tags + // We'll use a slightly broad regex and rely on file existence check + // Regex: \[([^\]<]+)\] + // But careful not to match [text](url). + // Goldmark renders [text](url) as .... + // However, if the link is broken (reference link not defined)? It renders as [text]. + // So this strategy is acceptable per user request. + + imgRegex := regexp.MustCompile(`\[([^\]<]+)\]`) + if imgRegex.MatchString(htmlContent) { + htmlContent = imgRegex.ReplaceAllStringFunc(htmlContent, func(match string) string { + // match is "[filename]" + name := match[1 : len(match)-1] // Strip [ and ] + + // Resolve Image Path + // Current directory of the document + docDir := filepath.Dir(file.OriginalPath) + + // Root content directory + rootDir := "content" + + imagePath, err := resolveImage(name, docDir, rootDir) + if err != nil { + // Not found, return original text + return match + } + + // Calculate web path + // imagePath is a filesystem path e.g. "content/entries/image.png" + // We need "/entries/image.png" + webPath := "/" + filepath.ToSlash(strings.TrimPrefix(imagePath, "content/")) + + // Check for styling metadata + // Config key is the filename (e.g. "image.png") + // Use the resolved filename for the key + resolvedName := filepath.Base(imagePath) + + var styleAttr, otherAttrs string + + if configVal, ok := meta.Raw[resolvedName]; ok { + // Config can be: + // 1. A map (TOML: image = {width="...", ...}) + // 2. A string (TOML: image = "style=...") ? User example: image = "width: auto" ? No, user said: [a] style = "..." OR width = auto + + if configMap, ok := configVal.(map[string]interface{}); ok { + for k, v := range configMap { + val := fmt.Sprintf("%v", v) + if k == "style" { + styleAttr = fmt.Sprintf(` style="%s"`, val) + } else { + // height, width, etc. + otherAttrs += fmt.Sprintf(` %s="%s"`, k, val) + } + } + } + } + + return fmt.Sprintf(`%s`, webPath, name, styleAttr, otherAttrs) }) } -// Build Full Page - dataToWrite = templates.BuildFullPage([]byte(htmlContent), meta) + + // Build Full Page + // Generate global posts data (Latest 5-10) for the sidebar + // This logic is now handled within the sidebarRegex.MatchString block if a sidebar is present. + // If no sidebar, we still need to generate the latest posts for the full page template. + var globalPostsForTemplate []templates.PostSnippet + for _, f := range AllContent { + if f.IsMarkdown && f.RoutePath != "/" { + name := filepath.Base(f.OriginalPath) + title := strings.TrimSuffix(name, filepath.Ext(name)) + + globalPostsForTemplate = append(globalPostsForTemplate, templates.PostSnippet{ + Title: title, + URL: f.RoutePath, + Date: f.ModTime, + }) + } + // Limit to 10 latest + if len(globalPostsForTemplate) >= 10 { + break + } + } + + // This `latestPostsHTML` is for the overall page template, not necessarily the sidebar content. + // The sidebar content is generated and injected directly into `htmlContent`. + // This `latestPostsHTML` is passed to `BuildFullPage` for the general layout. + latestPostsHTML := templates.RenderLatestPosts(globalPostsForTemplate) + latestPostsHTML += templates.RenderDirectoryLink() + + dataToWrite = templates.BuildFullPage([]byte(htmlContent), meta, latestPostsHTML) } - // 4. Write to Cache + // 4. Return Content Object or Path? + // The function signature returns (string, error), which is the path to the cached file. + // Since we are disabling cache, we should probably change the architecture to return ([]byte, error) + // or write to a temporary file if the rest of the app expects a file path. + // The rest of the app (server.go) does http.ServeFile(w, r, cachePath). + // So we MUST return a file path. + + // For now, we will continue to write to the "cache" location, but since we removed the read check, + // it basically acts as a "render to temp file" on every request. + // This satisfies the "disable caching" requirement (always fresh) while keeping the file-based serving architecture. + tmpPath := cachePath + ".tmp" if err := os.WriteFile(tmpPath, dataToWrite, 0644); err != nil { return "", fmt.Errorf("failed to write cache: %w", err) @@ -173,3 +428,130 @@ func GetContentPath(file ContentFile) (string, error) { return cachePath, nil } + +// loadExternalMetadata checks for .yml, .yaml, .toml files and loads them. +func loadExternalMetadata(markdownPath string, meta *templates.PageMetadata) error { + basePath := strings.TrimSuffix(markdownPath, filepath.Ext(markdownPath)) + + extensions := []string{".yml", ".yaml", ".toml"} + + for _, ext := range extensions { + path := basePath + ext + if _, err := os.Stat(path); err == nil { + // Found a metadata file + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read file %s: %w", path, err) + } + + // Decode into a map first to capture everything in Raw + if meta.Raw == nil { + meta.Raw = make(map[string]interface{}) + } + + if ext == ".toml" { + if _, err := toml.Decode(string(data), &meta.Raw); err != nil { + return fmt.Errorf("decode toml %s: %w", path, err) + } + } else { + // yaml handles both .yml and .yaml + if err := yaml.Unmarshal(data, &meta.Raw); err != nil { + return fmt.Errorf("decode yaml %s: %w", path, err) + } + } + + // Map known fields to struct + if v, ok := meta.Raw["title"]; ok { + if s, ok := v.(string); ok { meta.Title = s } + } + if v, ok := meta.Raw["stylesheet"]; ok { + if s, ok := v.(string); ok { meta.Stylesheet = s } + } + if v, ok := meta.Raw["style"]; ok { + if s, ok := v.(string); ok { meta.Style = s } + } + + // Stop after finding the first matching metadata file + // Priority order is implicit in `extensions` slice (.yml > .yaml > .toml) + return nil + } + } + return nil +} + +// resolveImage searches for the image file +func resolveImage(name, docDir, rootDir string) (string, error) { + // 1. Check strict path in docDir + path := filepath.Join(docDir, name) + if !hasExt(name) { + // Try extensions + if found, err := findNewestWithExt(docDir, name); err == nil { + return found, nil + } + } else { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + // 2. Check strict path in rootDir + path = filepath.Join(rootDir, name) + if !hasExt(name) { + if found, err := findNewestWithExt(rootDir, name); err == nil { + return found, nil + } + } else { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + + return "", fmt.Errorf("not found") +} + +func hasExt(name string) bool { + return filepath.Ext(name) != "" +} + +func findNewestWithExt(dir, baseName string) (string, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return "", err + } + + var bestMatch string + var newestTime time.Time + + targetBase := strings.ToLower(baseName) + + for _, e := range entries { + if e.IsDir() { + continue + } + + name := e.Name() + ext := filepath.Ext(name) + if ext == "" { + continue + } + + nameNoExt := strings.TrimSuffix(name, ext) + if strings.ToLower(nameNoExt) == targetBase { + // Check if image extension + info, err := e.Info() + if err != nil { + continue + } + + if bestMatch == "" || info.ModTime().After(newestTime) { + bestMatch = filepath.Join(dir, name) + newestTime = info.ModTime() + } + } + } + + if bestMatch != "" { + return bestMatch, nil + } + return "", fmt.Errorf("not found") +} diff --git a/templates/style.go b/templates/style.go index 42b158e..c61d261 100644 --- a/templates/style.go +++ b/templates/style.go @@ -8,13 +8,14 @@ import ( // PageMetadata holds the frontmatter data type PageMetadata struct { - Title string `toml:"title" yaml:"title"` - Stylesheet string `toml:"stylesheet" yaml:"stylesheet"` - Style string `toml:"style" yaml:"style"` // Inline CSS + Title string `toml:"title" yaml:"title"` + Stylesheet string `toml:"stylesheet" yaml:"stylesheet"` + Style string `toml:"style" yaml:"style"` // Inline CSS + Raw map[string]interface{} `toml:"-" yaml:"-"` // Catch-all for other fields } // BuildFullPage wraps the content in the HTML shell, injecting metadata. -func BuildFullPage(content []byte, meta PageMetadata) []byte { +func BuildFullPage(content []byte, meta PageMetadata, latestPostsHTML string) []byte { var buf bytes.Buffer // Default title if missing @@ -39,6 +40,20 @@ func BuildFullPage(content []byte, meta PageMetadata) []byte { buf.WriteString(` `) buf.WriteString(cssLink) + buf.WriteString(``) + buf.WriteString(``) + // MathJax Configuration + buf.WriteString(``) + buf.WriteString(``) if meta.Style != "" { buf.WriteString(`