diff --git a/Dockerfile b/Dockerfile index 9593caf..9a15d1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,55 @@ -# --- Build step --- - FROM golang:1.22 AS build - WORKDIR /app - COPY go.mod go.sum ./ - RUN go mod download - COPY . . - RUN CGO_ENABLED=0 go build -o /blog ./cmd/blog - - # --- Runtime + optional MySQL client libraries --- - FROM debian:bookworm-slim - RUN apt-get update && apt-get install -y default-mysql-client ca-certificates && rm -rf /var/lib/apt/lists/* - COPY --from=build /blog /usr/local/bin/blog - COPY content/ /content - COPY internal/web/static/ /static - EXPOSE 8080 - ENV BLOG_CONTENT_DIR=/content - ENV BLOG_STATIC_DIR=/static - CMD ["blog"] - \ No newline at end of file +############################ +# 1) Go‑Build +############################ +FROM golang:1.22 AS build + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o /blog ./cmd/blog + + +############################ +# 2) Content‑Clone (Stage) +############################ +FROM alpine/git AS content + +# Parameterisierbar beim docker build --build-arg … +ARG CONTENT_REPO=https://github.com/username/blog-content.git +ARG CONTENT_REF=main + +RUN git clone --depth 1 --branch ${CONTENT_REF} ${CONTENT_REPO} /src + +# ─── Repack: bring alles in eine saubere Struktur ──────────────── +# • Markdown‑Posts: /src/articles/*.md → /out/content +# • Bilder + CSS + JS: /src/web/static/**/* → /out/static +# (Pfad‑Anpassung hier nach DEINEM Repository‑Layout) + +RUN mkdir -p /out/content /out/static +RUN find /src/articles -name '*.md' -exec cp {} /out/content/ \; +RUN cp -r /src/web/static/* /out/static/ + + +############################ +# 3) Runtime‑Image +############################ +FROM debian:bookworm-slim + +# (optional) MySQL‑Client für später +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# ─── Binärdatei ───── +COPY --from=build /blog /usr/local/bin/blog + +# ─── Content + Assets ─── +COPY --from=content /out/content /content +COPY --from=content /out/static /static + +ENV BLOG_CONTENT_DIR=/content +ENV BLOG_STATIC_DIR=/static + +EXPOSE 8080 +CMD ["blog"] diff --git a/cmd/blog/main.go b/cmd/blog/main.go index 074cd16..6ddff0c 100644 --- a/cmd/blog/main.go +++ b/cmd/blog/main.go @@ -46,19 +46,22 @@ func main() { } // Basislayout zuerst parsen - base := template.Must( + layout := template.Must( template.New("base").Funcs(funcs). ParseFiles("internal/web/templates/base.html"), ) // LIST‑Seite: base + list.html - tplList = template.Must(base.Clone()) + tplList = template.Must(layout.Clone()) template.Must(tplList.Funcs(funcs).ParseFiles("internal/web/templates/list.html")) // ARTICLE‑Instanz - tplArticle = template.Must(base.Clone()) + tplArticle = template.Must(layout.Clone()) template.Must(tplArticle.Funcs(funcs).ParseFiles("internal/web/templates/article.html")) + tplPage := template.Must(layout.Clone()) + template.Must(tplPage.ParseFiles("internal/web/templates/page.html")) + mux := http.NewServeMux() staticDir := "internal/web/static" @@ -68,6 +71,13 @@ func main() { fmt.Println(err) } + staticPages, err := article.LoadStatic("pages") + if err != nil { + fmt.Println(err) + } else { + fmt.Println(staticPages) + } + // Handler für / mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { @@ -92,6 +102,19 @@ func main() { http.NotFound(w, r) }) + mux.HandleFunc("/page/", func(w http.ResponseWriter, r *http.Request) { + slug := strings.TrimPrefix(r.URL.Path, "/page/") + p, ok := staticPages[slug] + if !ok { + http.NotFound(w, r) + return + } + // "layout" kommt aus deinem Template‑Pool (list/article nutzen es ja auch) + if err := tplPage.ExecuteTemplate(w, "page", p); err != nil { + http.Error(w, err.Error(), 500) + } + }) + mux.Handle("/static/", cacheControl(http.StripPrefix("/static/", http.FileServer(http.Dir(staticDir))))) diff --git a/content/2025/test - Kopie.md b/content/2025/test - Kopie.md new file mode 100644 index 0000000..3b68a38 --- /dev/null +++ b/content/2025/test - Kopie.md @@ -0,0 +1,5 @@ + + +# Hello Markdown 🌟 + +Dies ist *kursiv*, **fett** und ein [Link](https://example.com). diff --git a/internal/article/load.go b/internal/article/load.go index e091171..f6a391c 100644 --- a/internal/article/load.go +++ b/internal/article/load.go @@ -4,11 +4,10 @@ package article // internal/article/load.go (gekürzt) import ( - "bufio" + "bytes" "encoding/json" "fmt" "html/template" - "io" "io/fs" "os" "path/filepath" @@ -18,85 +17,178 @@ import ( md "github.com/gomarkdown/markdown" "github.com/gomarkdown/markdown/html" - "github.com/gomarkdown/markdown/parser" ) +// LoadDir liest alle *.html und *.md unter root/content, +// parst Front‑Matter (JSON‑Kommentar in der 1. Zeile) und liefert []Article. func LoadDir(root string) ([]Article, error) { var out []Article + seen := make(map[string]bool) // Duplikats‑Check - // gültige Extension‑Maske - exts := parser.CommonExtensions | parser.AutoHeadingIDs | parser.DefinitionLists mdRenderer := html.NewRenderer(html.RendererOptions{ Flags: html.CommonFlags | html.HrefTargetBlank, }) - err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { - if err != nil || d.IsDir() { - return err + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr } - - ext := filepath.Ext(p) - if ext != ".html" && ext != ".md" { + if d.IsDir() { return nil } - f, err := os.Open(p) - if err != nil { - return err + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".md" && ext != ".html" { + return nil // uninteressante Datei } - defer f.Close() - r := bufio.NewReader(f) - headerLine, err := r.ReadBytes('\n') - if err != nil && err != io.EOF { - return err + raw, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + parts := bytes.SplitN(raw, []byte("\n"), 2) + if len(parts) < 2 { + return fmt.Errorf("%s: missing front‑matter", path) + } + + headerJSON, err := extractJSONHeader(parts[0]) + if err != nil { + return fmt.Errorf("%s: %w", path, err) } var meta struct { Title string `json:"title"` Date string `json:"date"` Slug string `json:"slug"` - Cover string `json:"cover"` // NEW + Cover string `json:"cover"` } - clean := strings.TrimSpace(string(headerLine)) - - clean = strings.TrimPrefix(clean, "" abschneiden (egal ob Leerzeichen davor) - if idx := strings.Index(clean, "-->"); idx != -1 { - clean = clean[:idx] - } - clean = strings.TrimSpace(clean) - - if err := json.Unmarshal([]byte(clean), &meta); err != nil { - return fmt.Errorf("Front‑Matter in %s: %w (%q)", p, err, clean) + if err := json.Unmarshal(headerJSON, &meta); err != nil { + return fmt.Errorf("%s: %w", path, err) } - bodyBytes, err := io.ReadAll(r) - if err != nil { - return err + // Fallback‑Slug aus Dateinamen + if meta.Slug == "" { + meta.Slug = strings.TrimSuffix(filepath.Base(path), ext) } + if seen[meta.Slug] { + return fmt.Errorf("%s: duplicate slug %q", path, meta.Slug) + } + seen[meta.Slug] = true - htmlBody := bodyBytes + body := parts[1] if ext == ".md" { - mdParser := parser.NewWithExtensions(exts) - htmlBody = md.ToHTML(bodyBytes, mdParser, mdRenderer) + body = md.ToHTML(body, nil, mdRenderer) // frischer Parser pro Call } date, err := time.Parse("2006-01-02", meta.Date) if err != nil { - fmt.Println("Time", err, date) + return fmt.Errorf("%s: parse date: %w", path, err) } + out = append(out, Article{ Title: meta.Title, Slug: meta.Slug, Date: date, - Cover: meta.Cover, // NEW - Body: template.HTML(htmlBody), + Cover: meta.Cover, + Body: template.HTML(body), }) return nil }) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].Date.After(out[j].Date) }) - return out, err + return out, nil +} + +// ---------- NEU ---------- +type StaticPage struct { + Title string + Slug string + Body template.HTML +} + +// helper.go – einmal zentral verwenden +func extractJSONHeader(line []byte) ([]byte, error) { + s := strings.TrimSpace(string(line)) + s = strings.TrimPrefix(s, "" abschneiden + if idx := strings.Index(s, "-->"); idx != -1 { + s = s[:idx] + } + s = strings.TrimSpace(s) + + // optional: falls doch kein JSON, besserer Fehler + if !strings.HasPrefix(s, "{") { + return nil, fmt.Errorf("no JSON front‑matter found") + } + return []byte(s), nil +} + +// LoadStatic liest alle .md/.html‑Dateien unter dir und liefert sie als Map[slug]StaticPage. +func LoadStatic(dir string) (map[string]StaticPage, error) { + pages := make(map[string]StaticPage) + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + for _, e := range entries { + if e.IsDir() { + continue + } + ext := filepath.Ext(e.Name()) + if ext != ".md" && ext != ".html" { + continue // unbekanntes Format + } + + path := filepath.Join(dir, e.Name()) + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + // Front‑Matter (erste Zeile) herauslösen + parts := bytes.SplitN(raw, []byte("\n"), 2) + if len(parts) < 2 { + return nil, fmt.Errorf("%s: missing front‑matter", path) + } + + headerJSON, err := extractJSONHeader(parts[0]) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + + var meta struct { + Title string `json:"title"` + Slug string `json:"slug"` + } + if err := json.Unmarshal(headerJSON, &meta); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + + // Fallback: Slug aus Dateinamen ableiten, falls im Header leer + if meta.Slug == "" { + meta.Slug = strings.TrimSuffix(e.Name(), ext) + } + if _, dup := pages[meta.Slug]; dup { + return nil, fmt.Errorf("%s: duplicate slug %q", path, meta.Slug) + } + + body := parts[1] + if ext == ".md" { + body = md.ToHTML(body, nil, nil) // eigener Parser je Aufruf + } + + pages[meta.Slug] = StaticPage{ + Title: meta.Title, + Slug: meta.Slug, + Body: template.HTML(body), + } + } + return pages, nil } diff --git a/internal/web/static/main.css b/internal/web/static/main.css index 77c00e8..4f020c3 100644 --- a/internal/web/static/main.css +++ b/internal/web/static/main.css @@ -1,133 +1,151 @@ -/* ---------- Farbpalette (Feel‑free to tune) ---------- */ +/* ---------- Farbpalette ---------- */ :root { + /* Light theme */ + --bg: #f5f7fa; + --bg-alt: #ffffff; + --card-bg: #ffffff; + --text: #1f2933; + --text-muted: #64748b; + --accent: #2563eb; /* Indigo‑600 */ + --accent-light: #3b82f6; /* Indigo‑500 */ + --code-bg: #f1f5f9; + --code-border: #e2e8f0; + --radius: 0.75rem; + --gap: 2rem; + --shadow: 0 4px 16px rgba(0,0,0,.08); + font-size: 16px; + font-family: "Inter", system-ui, "Segoe UI", Roboto, sans-serif; + color-scheme: light; +} + +/* Dark mode (optional) */ +@media (prefers-color-scheme: dark) { + :root { --bg: #0d1117; - --bg‑alt: #161b22; + --bg-alt: #161b22; + --card-bg: #161b22; --text: #c9d1d9; - --text‑muted: #8b949e; - --accent: #3b82f6; /* Blau */ - --accent‑light:#60a5fa; - --code‑bg: #1e242c; - --code‑border: #30363d; - --radius: 0.6rem; - --gap: 1.5rem; - font‑size: 16px; - font‑family: system‑ui, "Segoe UI", Roboto, sans‑serif; - color‑scheme: dark; + --text-muted: #8b949e; + --accent: #3b82f6; + --accent-light:#60a5fa; + --code-bg: #1e242c; + --code-border: #30363d; + --shadow: 0 4px 16px rgba(0,0,0,.32); } - - /* ---------- Globale Elemente ---------- */ - * { box‑sizing: border‑box; } - body { - margin: 0; - background: var(--bg); - color: var(--text); - line‑height: 1.6; - } - a { - color: var(--accent); - text‑decoration: none; - } - a:hover { text‑decoration: underline; } - - /* ---------- Layout ---------- */ - .wrapper { - max‑width: 768px; - margin: 0 auto; - padding: var(--gap); - } - header, footer { - display: flex; - align‑items: center; - justify‑content: space-between; - background: var(--bg‑alt); - padding: 1rem var(--gap); - border‑bottom: 1px solid var(--code‑border); - } - header h1 { - margin: 0; - font‑size: 1.4rem; - letter‑spacing: 0.5px; - } - header a { color: var(--text); } - - footer { - border‑top: 1px solid var(--code‑border); - font‑size: 0.9rem; - color: var(--text‑muted); - } - - /* ---------- Artikelliste ---------- */ - .post‑list li { - margin: 0; - padding: 1rem 0; - border‑bottom: 1px solid var(--code‑border); - } - .post‑list li:last‑child { border‑bottom: none; } - .post‑list time { color: var(--text‑muted); font‑size: 0.9rem; } - - /* ---------- Einzelartikel ---------- */ - article h1 { margin‑top: 0; font‑size: 2rem; } - article time { color: var(--text‑muted); font‑size: 0.9rem; } - article img, article video { - max‑width: 100%; height: auto; border‑radius: var(--radius); - } - article pre, article code { - font‑family: "Fira Code", Consolas, monospace; - } - article pre { - background: var(--code‑bg); - border: 1px solid var(--code‑border); - padding: 1rem; - border‑radius: var(--radius); - overflow: auto; - } - article blockquote { - margin: 1rem 0; - padding: 0.5rem 1rem; - border‑left: 4px solid var(--accent‑light); - background: var(--bg‑alt); - color: var(--text‑muted); - } - /* ---------- Karten‑Layout für Artikelliste ---------- */ -.card { - display: grid; - grid-template-columns: 160px 1fr; - gap: 1rem; - padding: 1rem; - border: 1px solid var(--code-border); - border-radius: var(--radius); +} + +/* ---------- Grundlayout ---------- */ +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + line-height: 1.65; +} +a { + color: var(--accent); + text-decoration: none; + transition: color .15s; +} +a:hover { color: var(--accent-light); } + +/* Container in der Mitte */ +.wrapper { + max-width: 1200px; + margin: 0 auto; + padding: var(--gap) calc(var(--gap) / 1.5); +} + +/* ---------- Kopf & Fuß ---------- */ +header, footer { background: var(--bg-alt); - transition: transform .15s ease, border-color .15s ease; + box-shadow: var(--shadow); } -.card:hover { - transform: translateY(-4px); - border-color: var(--accent); +header { + position: sticky; top: 0; z-index: 10; + display: flex; justify-content: space-between; align-items: center; + padding: 1rem calc(var(--gap) / 1.2); } +header h1 { margin: 0; font-size: 1.4rem; } +footer { + text-align: center; + padding: 2rem 1rem; + color: var(--text-muted); + font-size: .9rem; + margin-top: var(--gap); +} + +/* ---------- Karten‑Grid ---------- */ +.post-list { + display: grid; + gap: var(--gap); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + list-style: none; + padding: 0; + margin: 0; +} +.card { + display: flex; + flex-direction: column; + background: var(--card-bg); + border-radius: var(--radius); + box-shadow: var(--shadow); + overflow: hidden; + transition: transform .2s ease, box-shadow .2s ease; +} +.card:hover { transform: translateY(-6px); box-shadow: 0 6px 24px rgba(0,0,0,.1); } .card img { - width: 160px; - height: 100px; - object-fit: cover; - border-radius: var(--radius); + width: 100%; height: 180px; object-fit: cover; } -.card h2 { - margin: 0 0 .4rem; - font-size: 1.2rem; -} -.card time { color: var(--text-muted); font-size: .9rem; } +.card-content { padding: 1rem 1.25rem 1.5rem; } +.card h2 { margin: .25rem 0 .5rem; font-size: 1.25rem; line-height: 1.3; } +.card time { color: var(--text-muted); font-size: .85rem; } -/* ---------- Hero‑Bild im Artikel ---------- */ +/* ---------- Artikel ---------- */ .hero { - width: 100%; - max-height: 340px; - object-fit: cover; + width: 100%; height: 320px; object-fit: cover; border-radius: var(--radius); - margin-bottom: 1rem; + box-shadow: var(--shadow); +} +article h1 { font-size: 2.2rem; margin: 1.2rem 0 .3rem; } +article time { color: var(--text-muted); font-size: .9rem; } +article img:not(.hero), article video { + max-width: 100%; height: auto; border-radius: var(--radius); + box-shadow: var(--shadow); + margin: 1rem 0; +} +article pre { + background: var(--code-bg); + border: 1px solid var(--code-border); + padding: 1rem 1.2rem; + border-radius: var(--radius); + overflow: auto; + font-family: "Fira Code", Consolas, monospace; +} +article blockquote { + border-left: 4px solid var(--accent); + padding: .5rem 1rem; margin: 1rem 0; + background: var(--code-bg); + color: var(--text-muted); +} +.main-nav ul { + display: flex; + gap: 1.25rem; + list-style: none; + margin: 0; + padding: 0; +} +.main-nav a { + font-weight: 600; + color: var(--text); +} +.main-nav a:hover { + color: var(--accent); } - - /* ---------- Responsive ---------- */ - @media (max‑width: 600px) { - :root { font‑size: 15px; } - header, footer { flex‑direction: column; gap: 0.5rem; } - } - \ No newline at end of file +/* ---------- Responsive ---------- */ +@media (max-width: 640px) { + :root { font-size: 15px; } + .hero { height: 200px; } + header { flex-direction: column; gap: .5rem; } +} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index f4c2b4f..7e18e69 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -9,12 +9,18 @@ + +