Compare commits

...

3 Commits

Author SHA1 Message Date
a1471fc310 finale Anpassung 2025-05-04 17:10:38 +02:00
eb2d05f082 Läuft 2025-05-04 14:16:29 +02:00
92d272b36e Passt so 2025-05-04 13:40:31 +02:00
17 changed files with 650 additions and 0 deletions

55
Dockerfile Normal file
View File

@@ -0,0 +1,55 @@
############################
# 1) GoBuild
############################
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) ContentClone (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 ────────────────
# • MarkdownPosts: /src/articles/*.md → /out/content
# • Bilder + CSS + JS: /src/web/static/**/* → /out/static
# (PfadAnpassung hier nach DEINEM RepositoryLayout)
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) RuntimeImage
############################
FROM debian:bookworm-slim
# (optional) MySQLClient 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"]

123
cmd/blog/main.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"fmt"
"html/template"
"net/http"
"os"
"strconv"
"strings"
"time"
"git.send.nrw/sendnrw/b1tsblog/internal/article"
)
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
func enabled(k string, def bool) bool {
b, err := strconv.ParseBool(strings.ToLower(os.Getenv(k)))
if err != nil {
return def
}
return b
}
func cacheControl(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
next.ServeHTTP(w, r)
})
}
var (
tplList *template.Template
tplArticle *template.Template
)
func main() {
funcs := template.FuncMap{
"now": time.Now, // jetztZeit bereitstellen
}
// Basislayout zuerst parsen
layout := template.Must(
template.New("base").Funcs(funcs).
ParseFiles("internal/web/templates/base.html"),
)
// LISTSeite: base + list.html
tplList = template.Must(layout.Clone())
template.Must(tplList.Funcs(funcs).ParseFiles("internal/web/templates/list.html"))
// ARTICLEInstanz
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"
articles, err := article.LoadDir("content")
if err != nil {
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 != "/" {
http.NotFound(w, r)
return
}
if err := tplList.ExecuteTemplate(w, "layout", articles); err != nil {
http.Error(w, err.Error(), 500)
}
})
mux.HandleFunc("/post/", func(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimPrefix(r.URL.Path, "/post/")
for _, a := range articles {
if a.Slug == slug {
if err := tplArticle.ExecuteTemplate(w, "layout", a); err != nil {
http.Error(w, err.Error(), 500)
}
return
}
}
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 TemplatePool (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)))))
http.ListenAndServe(":8080", mux)
}

3
content/2025/demo.md Normal file
View File

@@ -0,0 +1,3 @@
<!--{"title": "ChatGPT + Go: So geht's", "date": "2025-05-04", "slug": "chatgpt-go", "cover": ""}-->
# Hello Gophers 🤖
*(MarkdownInhalt)*

View File

@@ -0,0 +1,5 @@
<!--{"title": "Mein erster MarkdownPosty", "date": "2025-05-04", "slug": "mein-erster-markdown-posty", "cover": "/static/img/placeholder.png"}-->
# Hello Markdown 🌟
Dies ist *kursiv*, **fett** und ein [Link](https://example.com).

5
content/2025/test.md Normal file
View File

@@ -0,0 +1,5 @@
<!--{"title": "Mein erster MarkdownPost", "date": "2025-05-04", "slug": "mein-erster-markdown-post", "cover": "/static/img/placeholder.png"}-->
# Hello Markdown 🌟
Dies ist *kursiv*, **fett** und ein [Link](https://example.com).

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module git.send.nrw/sendnrw/b1tsblog
go 1.23.1
require github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk=
github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=

194
internal/article/load.go Normal file
View File

@@ -0,0 +1,194 @@
// internal/article/load.go
package article
// internal/article/load.go (gekürzt)
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
md "github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/html"
)
// LoadDir liest alle *.html und *.md unter root/content,
// parst FrontMatter (JSONKommentar in der 1. Zeile) und liefert []Article.
func LoadDir(root string) ([]Article, error) {
var out []Article
seen := make(map[string]bool) // DuplikatsCheck
mdRenderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.HrefTargetBlank,
})
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".md" && ext != ".html" {
return nil // uninteressante Datei
}
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 frontmatter", 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"`
}
if err := json.Unmarshal(headerJSON, &meta); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
// FallbackSlug 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
body := parts[1]
if ext == ".md" {
body = md.ToHTML(body, nil, mdRenderer) // frischer Parser pro Call
}
date, err := time.Parse("2006-01-02", meta.Date)
if err != nil {
return fmt.Errorf("%s: parse date: %w", path, err)
}
out = append(out, Article{
Title: meta.Title,
Slug: meta.Slug,
Date: date,
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, 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, "<!--")
// alles bis zum ersten "-->" 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 frontmatter found")
}
return []byte(s), nil
}
// LoadStatic liest alle .md/.htmlDateien 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)
}
// FrontMatter (erste Zeile) herauslösen
parts := bytes.SplitN(raw, []byte("\n"), 2)
if len(parts) < 2 {
return nil, fmt.Errorf("%s: missing frontmatter", 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
}

15
internal/article/model.go Normal file
View File

@@ -0,0 +1,15 @@
// internal/article/model.go
package article
import (
"html/template"
"time"
)
type Article struct {
Title string
Slug string
Date time.Time
Cover string //  NEW
Body template.HTML // already trusted
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

View File

@@ -0,0 +1,151 @@
/* ---------- Farbpalette ---------- */
:root {
/* Light theme */
--bg: #f5f7fa;
--bg-alt: #ffffff;
--card-bg: #ffffff;
--text: #1f2933;
--text-muted: #64748b;
--accent: #2563eb; /* Indigo600 */
--accent-light: #3b82f6; /* Indigo500 */
--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;
--card-bg: #161b22;
--text: #c9d1d9;
--text-muted: #8b949e;
--accent: #3b82f6;
--accent-light:#60a5fa;
--code-bg: #1e242c;
--code-border: #30363d;
--shadow: 0 4px 16px rgba(0,0,0,.32);
}
}
/* ---------- 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);
box-shadow: var(--shadow);
}
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);
}
/* ---------- KartenGrid ---------- */
.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: 100%; height: 180px; object-fit: cover;
}
.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; }
/* ---------- Artikel ---------- */
.hero {
width: 100%; height: 320px; object-fit: cover;
border-radius: var(--radius);
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: 640px) {
:root { font-size: 15px; }
.hero { height: 200px; }
header { flex-direction: column; gap: .5rem; }
}

View File

@@ -0,0 +1,20 @@
{{ define "title" }}{{ .Title }}  B1tsblog{{ end }}
{{ define "body" }}
<article>
{{ if .Cover }}
<img class="hero" src="{{ .Cover }}" alt="">
{{ end }}
<h1>{{ .Title }}</h1>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time>
<div class="article-content">
{{ .Body }}
</div>
<p><a href="/">Zurück zur Übersicht</a></p>
</article>
{{ end }}
{{ define "article" }}{{ template "layout" . }}{{ end }}

View File

@@ -0,0 +1,36 @@
{{ define "layout" }}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ block "title" . }}B1tsblog{{ end }}</title>
<link rel="stylesheet" href="/static/main.css">
<link rel="preconnect" href="https://fonts.gstatic.com">
<!-- MonospaceFont für Code (optional) -->
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<header>
<h1><a href="/">B1tsblog</a></h1>
<nav class="main-nav">
<ul>
<li><a href="/">Startseite</a></li>
<li><a href="/page/impressum">Impressum</a></li>
<!-- später: <li><a href="/page/datenschutz">Datenschutz</a></li> -->
</ul>
</nav>
</header>
<main class="wrapper">
{{ template "body" . }}
</main>
<footer class="wrapper">
© {{ now.Year }} · B1tK1ll3r
</footer>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,19 @@
{{ define "body" }}
<ul class="post-list">
{{ range . }}
<li>
<a class="card" href="/post/{{ .Slug }}">
{{ if .Cover }}
<img src="{{ .Cover }}" alt="">
{{ else }}
<img src="/static/img/placeholder.png" alt="">
{{ end }}
<div class="card-content">
<h2>{{ .Title }}</h2>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time>
</div>
</a>
</li>
{{ end }}
</ul>
{{ end }}

View File

@@ -0,0 +1,11 @@
{{ define "title" }}{{ .Title }}  B1tsblog{{ end }}
{{ define "body" }}
<article>
<h1>{{ .Title }}</h1>
<div class="article-content">{{ .Body }}</div>
<p><a href="/">Zurück</a></p>
</article>
{{ end }}
{{ define "page" }}{{ template "layout" . }}{{ end }}

6
pages/impressum.md Normal file
View File

@@ -0,0 +1,6 @@
<!--{"title": "Impressum", "slug": "impressum"}-->
Max Mustermann
Musterstraße1
12345 Musterstadt
EMail: max@example.com