This commit is contained in:
@@ -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 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
|
||||
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 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"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(headerJSON, &meta); err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
|
||||
// Fallback‑Slug 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 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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
95
internal/store/sqlite.go
Normal 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
11
internal/store/store.go
Normal 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
|
||||
}
|
||||
8
internal/web/static/style.css
Normal file
8
internal/web/static/style.css
Normal 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; }
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user