v2-Neuerstellung
Some checks failed
release-tag / release-image (push) Failing after 1m48s

This commit is contained in:
2026-05-18 11:25:31 +02:00
parent a38c883450
commit b7edfdd544
15 changed files with 836 additions and 600 deletions

View File

@@ -1,8 +1,5 @@
// internal/article/load.go
package article
// internal/article/load.go (gekürzt)
import (
"bytes"
"encoding/json"
@@ -19,15 +16,24 @@ import (
"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
type LoaderOptions struct {
// TrustedHTML erlaubt rohe .html-Inhalte aus deinem Content-Repository.
// Achtung: Nur aktivieren, wenn du dem Repository vertraust.
TrustedHTML bool
}
mdRenderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.HrefTargetBlank,
})
type frontMatter struct {
Title string `json:"title"`
Date string `json:"date"`
Slug string `json:"slug"`
Cover string `json:"cover"`
Description string `json:"description"`
Format string `json:"format"`
}
func LoadDir(root string, opts LoaderOptions) ([]Article, error) {
var out []Article
seen := make(map[string]bool)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
@@ -38,37 +44,15 @@ func LoadDir(root string) ([]Article, error) {
}
ext := strings.ToLower(filepath.Ext(path))
if ext != ".md" && ext != ".html" {
return nil // uninteressante Datei
if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" {
return nil
}
raw, err := os.ReadFile(path)
meta, body, format, err := readContentFile(path, opts)
if err != nil {
return fmt.Errorf("read %s: %w", path, err)
return 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"`
Description string `json:"description"`
}
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)
}
@@ -77,11 +61,6 @@ func LoadDir(root string) ([]Article, error) {
}
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)
@@ -93,7 +72,9 @@ func LoadDir(root string) ([]Article, error) {
Date: date,
Cover: meta.Cover,
Description: meta.Description,
Body: template.HTML(body),
Body: body,
Format: format,
SourcePath: path,
})
return nil
})
@@ -105,34 +86,7 @@ func LoadDir(root string) ([]Article, error) {
return out, nil
}
// ---------- NEU ----------
type StaticPage struct {
Title string
Slug string
Description 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) {
func LoadStatic(dir string, opts LoaderOptions) (map[string]StaticPage, error) {
pages := make(map[string]StaticPage)
entries, err := os.ReadDir(dir)
@@ -144,38 +98,17 @@ func LoadStatic(dir string) (map[string]StaticPage, error) {
if e.IsDir() {
continue
}
ext := filepath.Ext(e.Name())
if ext != ".md" && ext != ".html" {
continue // unbekanntes Format
ext := strings.ToLower(filepath.Ext(e.Name()))
if ext != ".md" && ext != ".markdown" && ext != ".html" && ext != ".htm" {
continue
}
path := filepath.Join(dir, e.Name())
raw, err := os.ReadFile(path)
meta, body, format, err := readContentFile(path, opts)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
return nil, 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"`
Description string `json:"description"`
}
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)
}
@@ -183,17 +116,88 @@ func LoadStatic(dir string) (map[string]StaticPage, error) {
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,
Description: meta.Description,
Body: template.HTML(body),
Body: body,
Format: format,
SourcePath: path,
}
}
return pages, nil
}
func readContentFile(path string, opts LoaderOptions) (frontMatter, template.HTML, Format, error) {
raw, err := os.ReadFile(path)
if err != nil {
return frontMatter{}, "", "", fmt.Errorf("read %s: %w", path, err)
}
parts := bytes.SplitN(raw, []byte("\n"), 2)
if len(parts) < 2 {
return frontMatter{}, "", "", fmt.Errorf("%s: missing front-matter", path)
}
headerJSON, err := extractJSONHeader(parts[0])
if err != nil {
return frontMatter{}, "", "", fmt.Errorf("%s: %w", path, err)
}
var meta frontMatter
if err := json.Unmarshal(headerJSON, &meta); err != nil {
return frontMatter{}, "", "", fmt.Errorf("%s: %w", path, err)
}
ext := strings.ToLower(filepath.Ext(path))
format := detectFormat(ext, meta.Format)
body := renderBody(parts[1], format, opts)
return meta, body, format, nil
}
func detectFormat(ext, explicit string) Format {
switch strings.ToLower(strings.TrimSpace(explicit)) {
case "html":
return FormatHTML
case "md", "markdown":
return FormatMarkdown
}
switch ext {
case ".html", ".htm":
return FormatHTML
default:
return FormatMarkdown
}
}
func renderBody(body []byte, format Format, opts LoaderOptions) template.HTML {
if format == FormatMarkdown {
renderer := html.NewRenderer(html.RendererOptions{
Flags: html.CommonFlags | html.HrefTargetBlank,
})
return template.HTML(md.ToHTML(body, nil, renderer))
}
if opts.TrustedHTML {
return template.HTML(body)
}
// Sicherheits-Fallback: HTML-Dateien werden escaped, solange BLOG_TRUSTED_HTML=false ist.
return template.HTML(template.HTMLEscapeString(string(body)))
}
func extractJSONHeader(line []byte) ([]byte, error) {
s := strings.TrimSpace(string(line))
s = strings.TrimPrefix(s, "<!--")
if idx := strings.Index(s, "-->"); idx != -1 {
s = s[:idx]
}
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "{") {
return nil, fmt.Errorf("no JSON front-matter found")
}
return []byte(s), nil
}

View File

@@ -1,4 +1,3 @@
// internal/article/model.go
package article
import (
@@ -6,6 +5,13 @@ import (
"time"
)
type Format string
const (
FormatMarkdown Format = "markdown"
FormatHTML Format = "html"
)
type Article struct {
Title string
Slug string
@@ -13,7 +19,9 @@ type Article struct {
Cover string
Body template.HTML
Description string
Counter string
Counter int64
Format Format
SourcePath string
}
type ListPage struct {
@@ -21,3 +29,12 @@ type ListPage struct {
Description string
Articles []Article
}
type StaticPage struct {
Title string
Slug string
Description string
Body template.HTML
Format Format
SourcePath string
}

95
internal/store/sqlite.go Normal file
View File

@@ -0,0 +1,95 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
type SQLiteCounterStore struct {
db *sql.DB
}
func NewSQLiteCounterStore(path string) (*SQLiteCounterStore, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
return &SQLiteCounterStore{db: db}, nil
}
func (s *SQLiteCounterStore) Init(ctx context.Context) error {
_, err := s.db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS ticks (
slug TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
return err
}
func (s *SQLiteCounterStore) Increment(ctx context.Context, slug string) (int64, error) {
if slug == "" {
return 0, fmt.Errorf("empty slug")
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO ticks(slug, count) VALUES(?, 1)
ON CONFLICT(slug) DO UPDATE SET
count = count + 1,
updated_at = CURRENT_TIMESTAMP;
`, slug)
if err != nil {
return 0, err
}
return s.Get(ctx, slug)
}
func (s *SQLiteCounterStore) Get(ctx context.Context, slug string) (int64, error) {
var count int64
err := s.db.QueryRowContext(ctx, `SELECT count FROM ticks WHERE slug = ?`, slug).Scan(&count)
if err == sql.ErrNoRows {
return 0, nil
}
return count, err
}
func (s *SQLiteCounterStore) GetMany(ctx context.Context, slugs []string) (map[string]int64, error) {
out := make(map[string]int64, len(slugs))
if len(slugs) == 0 {
return out, nil
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(slugs)), ",")
args := make([]any, len(slugs))
for i, slug := range slugs {
args[i] = slug
out[slug] = 0
}
rows, err := s.db.QueryContext(ctx, `SELECT slug, count FROM ticks WHERE slug IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var slug string
var count int64
if err := rows.Scan(&slug, &count); err != nil {
return nil, err
}
out[slug] = count
}
return out, rows.Err()
}
func (s *SQLiteCounterStore) Close() error {
return s.db.Close()
}

11
internal/store/store.go Normal file
View File

@@ -0,0 +1,11 @@
package store
import "context"
type CounterStore interface {
Init(ctx context.Context) error
Increment(ctx context.Context, slug string) (int64, error)
Get(ctx context.Context, slug string) (int64, error)
GetMany(ctx context.Context, slugs []string) (map[string]int64, error)
Close() error
}

View File

@@ -0,0 +1,8 @@
body { font-family: system-ui, sans-serif; margin: 0 auto; max-width: 920px; padding: 2rem; line-height: 1.6; }
header, footer { margin: 2rem 0; }
a { color: inherit; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1rem; }
.card { border: 1px solid #ddd; border-radius: 16px; padding: 1rem; }
.card img, .article img { max-width: 100%; border-radius: 12px; }
.meta { opacity: .7; }
.body pre { overflow: auto; padding: 1rem; background: #f5f5f5; border-radius: 12px; }

View File

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

View File

@@ -1,19 +1,16 @@
{{ define "body" }}
<ul class="post-list">
{{ range .Articles }}
<li>
<a class="card no-underline" href="/post/{{ .Slug }}">
{{ if .Cover }}
<img src="{{ .Cover }}" alt="">
{{ else }}
<img src="/static/img/placeholder.webp" alt="">
{{ end }}
<div class="card-content">
<h2>{{ .Title }}</h2>
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "02.01.2006" }}</time> ({{ .Counter }})
</div>
</a>
</li>
{{ define "content" }}
<h1>{{ .Title }}</h1>
<p>{{ .Description }}</p>
<section class="grid">
{{ range .Articles }}
<article class="card">
{{ if .Cover }}<img src="{{ .Cover }}" alt="">{{ end }}
<h2><a href="/post/{{ .Slug }}">{{ .Title }}</a></h2>
<p>{{ .Description }}</p>
<small>{{ .Date.Format "02.01.2006" }} · {{ .Counter }} Aufrufe · {{ .Format }}</small>
</article>
{{ else }}
<p>Noch keine Artikel.</p>
{{ end }}
</ul>
</section>
{{ end }}

View File

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