code.pablomurad.com

patchwork

Publishes the revisions of your pages as a feed, with notes and readable diffs, written in Go

Date undated pages by their first mtime, space out word swaps, add a Dockerfile

Found while wiring Patchwork into moonflowersociety.org:

- a page with no date in its name or frontmatter took the day of the
  first scan as its original date, so starting Patchwork on an existing
  site dated every such page to the deploy; the baseline now uses the
  file's modification time and keeps it on later uploads
- removed words right before inserted ones ran together in feed readers
  and in colored terminal output ("modificationthe day"); they're now
  separated by a space
- Dockerfile: static binary on distroless nonroot, with a data directory
  a fresh volume can inherit
- .gitattributes: LF everywhere, so golden files match on Windows too

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
author
Pablo Murad
date
commit
60e53a163e238dde89d8cc9fdd59d6743399ce29
parent
973b474c5406570d567e9a1f6daebfef25bbeb74
changes
7 files +80 −9
@@ -0,0 +1,6 @@+.git
+.forgejo
+*.db
+*.db-wal
+*.db-shm
+dist/
@@ -1,3 +1,4 @@+# LF everywhere, on every OS: golden files are compared byte for byte.
+* text=auto eol=lf
 # Fixtures test line endings, BOMs and invalid bytes: keep them byte for byte.
 internal/engine/testdata/** -text
-*.go text eol=lf
@@ -0,0 +1,22 @@+# Patchwork as a container: one static binary on distroless, running as
+# nonroot. The database lives in /var/lib/patchwork; mount a volume there.
+
+FROM golang:1.26-alpine AS build
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+ARG VERSION=dev
+RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/patchwork ./cmd/patchwork
+# An empty data directory owned by nonroot (65532), so a fresh named volume
+# mounted here inherits that owner and the database can be created.
+RUN mkdir -p /out/data && chown 65532:65532 /out/data
+
+FROM gcr.io/distroless/static-debian12:nonroot
+COPY --from=build /out/patchwork /patchwork
+COPY --from=build --chown=65532:65532 /out/data /var/lib/patchwork
+ENV PATCHWORK_CONFIG=/etc/patchwork/patchwork.yaml
+EXPOSE 8080
+VOLUME ["/var/lib/patchwork"]
+ENTRYPOINT ["/patchwork"]
+CMD ["serve"]
@@ -115,7 +115,9 @@ res.Restored = page.RemovedAt != nil 		page.RemovedAt, page.MissingScans = nil, 0
 	}
 	page.Slug = e.slugRule(obs.Path)
-	page.OriginalDate = e.originalDate(d, obs.Path, page.FirstSeenAt)
+	// The fallback stays whatever the baseline settled on: later uploads
+	// change the mtime, not the day the page was planted.
+	page.OriginalDate = e.originalDate(d, obs.Path, page.OriginalDate)
 	res.Slug = page.Slug
 	if err := q.UpdatePage(ctx, page); err != nil {
 		return res, err
@@ -204,7 +206,7 @@ page := &store.Page{ 		UID: e.NewUID(), Source: e.Source, Path: obs.Path,
 		Slug: e.slugRule(obs.Path), FirstSeenAt: at,
 	}
-	page.OriginalDate = e.originalDate(d, obs.Path, at)
+	page.OriginalDate = e.originalDate(d, obs.Path, e.baselineDate(obs, at))
 	if page.Slug == "" {
 		res.Warnings = append(res.Warnings, "the file name produces an empty slug; the page has no URL")
 	}
@@ -281,9 +283,8 @@ return kind, maturity }
 
 // originalDate is the page's first publication date: frontmatter first,
-// then a date at the start of the file name, then the day Patchwork first
-// saw the file.
-func (e *Engine) originalDate(d *doc.Doc, relPath string, firstSeen time.Time) string {
+// then a date at the start of the file name, then fallback.
+func (e *Engine) originalDate(d *doc.Doc, relPath string, fallback string) string {
 	switch v := d.Frontmatter[e.Cfg.Frontmatter.DateKey].(type) {
 	case time.Time:
 		// YAML reads `date: 2026-06-21` as midnight UTC; that is a calendar
@@ -304,7 +305,20 @@ } 	if date, ok := slug.DatePrefix(path.Base(relPath)); ok {
 		return date
 	}
-	return firstSeen.In(e.loc).Format("2006-01-02")
+	return fallback
+}
+
+// baselineDate is the fallback original date of a page seen for the first
+// time: the file's modification time when it's older than the observation.
+// When Patchwork starts on an existing site, that is when each page was
+// written or uploaded; the first scan's own date would give every undated
+// page the day of the deploy.
+func (e *Engine) baselineDate(obs Observation, at time.Time) string {
+	t := at
+	if !obs.Mtime.IsZero() && obs.Mtime.Before(at) {
+		t = obs.Mtime
+	}
+	return t.In(e.loc).Format("2006-01-02")
 }
 
 func firstLine(s string) string {
@@ -268,6 +268,24 @@ t.Fatalf("slug %s", p.Slug) 	}
 }
 
+func TestOriginalDateFromMtimeStaysPut(t *testing.T) {
+	h := newHarness(t)
+	ctx := context.Background()
+	planted := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
+	obs := func(body string, mtime time.Time) {
+		if _, err := h.e.Ingest(ctx, Observation{Path: "undated.md", Raw: []byte(body), Mtime: mtime}, false); err != nil {
+			t.Fatal(err)
+		}
+	}
+	obs("First text.", planted)
+	h.after(48 * time.Hour)
+	obs("---\nrevision: Edited.\n---\nSecond text.", h.now.Add(-time.Minute))
+	p, _ := h.e.Store.PageByPath(ctx, "folder", "undated.md")
+	if p.OriginalDate != "2026-07-03" {
+		t.Fatalf("original date %s: should be the first mtime, kept after later uploads", p.OriginalDate)
+	}
+}
+
 func TestReconcile(t *testing.T) {
 	h := newHarness(t)
 	ctx := context.Background()
@@ -103,11 +103,16 @@ } 			return out.String()
 		}
 		var in strings.Builder
-		for _, s := range op.Segments {
+		for i, s := range op.Segments {
 			switch s.T {
 			case diff.SegDel:
 				in.WriteString("<del>" + esc(s.S) + "</del>")
 			case diff.SegIns:
+				// Removed and inserted words side by side would read as one
+				// word in a reader that strips styles.
+				if i > 0 && op.Segments[i-1].T == diff.SegDel {
+					in.WriteString(" ")
+				}
 				in.WriteString("<ins>" + esc(s.S) + "</ins>")
 			default:
 				in.WriteString(esc(s.S))
@@ -184,13 +184,18 @@ } 				break
 			}
 			runs := make([]run, 0, len(op.Segments))
-			for _, s := range op.Segments {
+			for i, s := range op.Segments {
 				st := plain
 				switch s.T {
 				case diff.SegDel:
 					st = red
 				case diff.SegIns:
 					st = green
+					// In color, removed words right before inserted ones
+					// would read as a single word.
+					if w.o.Color && i > 0 && op.Segments[i-1].T == diff.SegDel {
+						runs = append(runs, run{" ", plain})
+					}
 				}
 				runs = append(runs, run{s.S, st})
 			}