Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,13 @@ jobs:
- name: npm run lint
run: npm run lint

- name: npm run build
run: npm run build
# The build prerenders against the generated publication data, so it is
# driven through `make site` — the same command used locally, which starts
# the build-time content server and stops it afterwards.
- name: make site
working-directory: .
run: make site

- name: production syndication contract
run: npm run test:syndication

# Production deploy. `needs` lists every check job above, so a red check skips
# the deploy; the `if` restricts it to a push landing on main (a pull_request
Expand Down
6 changes: 5 additions & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,17 @@ linters:
paths:
- vendor
rules:
# Test files allow higher complexity and flexible helpers
# Test files allow higher complexity and flexible helpers. gosec is
# excluded because its file-inclusion and directory-permission checks
# guard untrusted input and deployed artifacts; a test reading a path it
# just wrote under t.TempDir() is neither.
- path: _test\.go$
linters:
- gocyclo
- gocognit
- errcheck
- unparam
- gosec

# Generated code skipped
- path: internal/db/
Expand Down
20 changes: 19 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,25 @@
# migrations/ before each suite. CI runs the SAME `make test-integration`
# command so local and CI behaviour cannot drift.

.PHONY: test test-integration verify
.PHONY: test test-integration verify site publish

# Publication data: renders content/ into the frontend's public directory.
# Fast and dependency-free, so it runs before every site build rather than
# being cached.
publish:
go run ./cmd/publish -content content -out frontend/public

# The public site, end to end: snapshots in, static files out.
#
# Prerendering fetches the publication data over HTTP, so the generated files
# are served on 127.0.0.1 for the length of the build. The server is killed
# whether the build succeeds or fails; a leaked one would silently serve stale
# data to the next build.
site: publish
cd frontend && \
node tools/serve-content.mjs public 8099 & echo $$! > .site-server.pid; \
trap 'kill $$(cat .site-server.pid) 2>/dev/null; rm -f .site-server.pid' EXIT; \
cd frontend && npm run build

# Unit lane: race detector, NO integration build tag. Mirrors the CI `go` job.
test:
Expand Down
114 changes: 114 additions & 0 deletions cmd/publish/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Command publish renders the snapshots under content/ into the static data the
// public site is built from. It is the whole publication pipeline; there is no
// server.
package main

import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"

"github.com/Koopa0/koopa/internal/publication"
)

// Exit codes, for callers that are not human. Bad content and a bad invocation
// are separated because only the first is worth opening content/ for.
const (
exitOK = 0
exitFailure = 1
exitUsage = 2
)

func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}

type options struct {
contentDir string
outDir string
site publication.Site
}

func run(args []string, stdout, stderr io.Writer) int {
opts, code, err := parse(args, stderr)
if err != nil {
fmt.Fprintf(stderr, "publish: %v\n", err)
return code
}
if code != exitOK {
return code
}

articles, err := publication.Load(os.DirFS(opts.contentDir))
if err != nil {
fmt.Fprintf(stderr, "publish: %v\n", err)
return exitFailure
}

files, err := publication.Build(opts.site, articles)
if err != nil {
fmt.Fprintf(stderr, "publish: %v\n", err)
return exitFailure
}

if err := write(opts.outDir, files); err != nil {
fmt.Fprintf(stderr, "publish: %v\n", err)
return exitFailure
}

fmt.Fprintf(stdout, "published %d article(s) to %s\n", len(articles), opts.outDir)
return exitOK
}

// parse returns exitOK with a nil error when -h was handled.
func parse(args []string, stderr io.Writer) (options, int, error) {
var opts options

fs := flag.NewFlagSet("publish", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.StringVar(&opts.contentDir, "content", "content", "directory of publication snapshots")
fs.StringVar(&opts.outDir, "out", "frontend/public", "directory to render the site data into")
fs.StringVar(&opts.site.BaseURL, "base-url", "https://koopa0.dev", "absolute site origin, without a trailing slash")
fs.StringVar(&opts.site.Title, "title", "koopa0.dev", "site title, used in the feed")
fs.StringVar(&opts.site.Description, "description", "Notes on Go, systems, and the craft of building them.", "feed channel description")
fs.StringVar(&opts.site.Author, "author", "Koopa", "feed managing editor")

if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return opts, exitOK, nil
}
return opts, exitUsage, err
}
if fs.NArg() > 0 {
return opts, exitUsage, fmt.Errorf("unexpected argument %q", fs.Arg(0))
}

opts.site.BaseURL = strings.TrimRight(opts.site.BaseURL, "/")
if opts.site.BaseURL == "" {
return opts, exitUsage, errors.New("-base-url is required")
}
if _, err := os.Stat(opts.contentDir); err != nil {
return opts, exitUsage, fmt.Errorf("-content: %w", err)
}

return opts, exitOK, nil
}

// write is deliberately not atomic: the output is regenerated from scratch, so
// a partial write is discarded rather than served.
func write(root string, files []publication.File) error {
for _, f := range files {
dest := filepath.Join(root, filepath.FromSlash(f.Path))
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
return fmt.Errorf("creating %s: %w", filepath.Dir(dest), err)
}
if err := os.WriteFile(dest, f.Bytes, 0o644); err != nil {
return fmt.Errorf("writing %s: %w", dest, err)
}
}
return nil
}
163 changes: 163 additions & 0 deletions cmd/publish/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package main

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)

const snapshot = `---
title: Escape analysis
topics: [go, performance]
published_at: 2026-07-28
source_path: Writing/articles/go-escape-analysis.md
source_sha: 3f2a91c0d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
---
Variables live on the stack until they do not.
`

// corpus writes a content directory holding the given files and returns its
// path along with a separate output directory.
func corpus(t *testing.T, files map[string]string) (contentDir, outDir string) {
t.Helper()
root := t.TempDir()
contentDir = filepath.Join(root, "content")
outDir = filepath.Join(root, "public")

if err := os.MkdirAll(contentDir, 0o755); err != nil {
t.Fatalf("creating content dir: %v", err)
}
for name, body := range files {
if err := os.WriteFile(filepath.Join(contentDir, name), []byte(body), 0o644); err != nil {
t.Fatalf("writing %s: %v", name, err)
}
}
return contentDir, outDir
}

// TestPublishRendersTheSite is the command's happy path: a snapshot in, a
// complete site out, exit 0.
func TestPublishRendersTheSite(t *testing.T) {
contentDir, outDir := corpus(t, map[string]string{"go-escape-analysis.md": snapshot})

var stdout, stderr bytes.Buffer
code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr)

if code != exitOK {
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, stderr.String())
}
if !strings.Contains(stdout.String(), "published 1 article") {
t.Errorf("stdout = %q, want it to report one article", stdout.String())
}

for _, name := range []string{
"content/index.json",
"content/go-escape-analysis.json",
"sitemap.xml",
"feed.xml",
} {
if _, err := os.Stat(filepath.Join(outDir, filepath.FromSlash(name))); err != nil {
t.Errorf("expected %s: %v", name, err)
}
}
}

// TestPublishFailsOnInvalidSnapshot protects the build: content that violates
// the contract must stop the pipeline, not publish a partial site.
func TestPublishFailsOnInvalidSnapshot(t *testing.T) {
contentDir, outDir := corpus(t, map[string]string{
"good.md": snapshot,
"bad.md": "---\ntitle: No provenance\npublished_at: 2026-07-28\n---\nBody.\n",
})

var stdout, stderr bytes.Buffer
code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr)

if code != exitFailure {
t.Fatalf("exit = %d, want %d", code, exitFailure)
}
if !strings.Contains(stderr.String(), "source_path") {
t.Errorf("stderr = %q, want it to name the offending field", stderr.String())
}
if _, err := os.Stat(filepath.Join(outDir, "feed.xml")); err == nil {
t.Error("a feed was written despite an invalid snapshot")
}
}

// TestPublishRejectsBadInvocation separates "your content is wrong" from "your
// command is wrong", because only the first is worth opening the content for.
func TestPublishRejectsBadInvocation(t *testing.T) {
contentDir, outDir := corpus(t, map[string]string{"go-escape-analysis.md": snapshot})

tests := map[string][]string{
"unknown flag": {"-nonsense"},
"stray argument": {"-content", contentDir, "-out", outDir, "extra"},
"missing content dir": {"-content", filepath.Join(contentDir, "absent"), "-out", outDir},
"empty base URL": {"-content", contentDir, "-out", outDir, "-base-url", ""},
"base URL only a slash": {"-content", contentDir, "-out", outDir, "-base-url", "/"},
}

for name, args := range tests {
t.Run(name, func(t *testing.T) {
var stdout, stderr bytes.Buffer
if code := run(args, &stdout, &stderr); code != exitUsage {
t.Errorf("exit = %d, want %d (stderr: %s)", code, exitUsage, stderr.String())
}
})
}
}

// TestPublishEmptyCorpus is the starting state: no snapshots committed yet, and
// the build still succeeds and produces a valid empty site.
func TestPublishEmptyCorpus(t *testing.T) {
contentDir, outDir := corpus(t, nil)

var stdout, stderr bytes.Buffer
code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr)

if code != exitOK {
t.Fatalf("exit = %d, want %d (stderr: %s)", code, exitOK, stderr.String())
}
if !strings.Contains(stdout.String(), "published 0 article") {
t.Errorf("stdout = %q, want it to report an empty corpus", stdout.String())
}
if _, err := os.Stat(filepath.Join(outDir, "feed.xml")); err != nil {
t.Errorf("an empty corpus must still produce a feed: %v", err)
}
}

// TestPublishIsReproducible protects the build's determinism: the same content
// must produce the same bytes, or every build shows as a change in review.
func TestPublishIsReproducible(t *testing.T) {
contentDir, outDir := corpus(t, map[string]string{
"go-escape-analysis.md": snapshot,
"second.md": strings.Replace(snapshot, "Escape analysis", "Second", 1),
})

read := func() map[string]string {
t.Helper()
var stdout, stderr bytes.Buffer
if code := run([]string{"-content", contentDir, "-out", outDir}, &stdout, &stderr); code != exitOK {
t.Fatalf("exit = %d (stderr: %s)", code, stderr.String())
}
out := map[string]string{}
for _, name := range []string{"content/index.json", "sitemap.xml", "feed.xml"} {
body, err := os.ReadFile(filepath.Join(outDir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("reading %s: %v", name, err)
}
out[name] = string(body)
}
return out
}

first := read()
second := read()
for name, want := range first {
if second[name] != want {
t.Errorf("%s differs between builds of identical content", name)
}
}
}
Empty file added content/.gitkeep
Empty file.
Loading
Loading