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>
@@ -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 {
@@ -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})
}