Skip to content

feat: optional series pages defined in a site-wide series.yml (#107) - #108

Draft
harrydayexe wants to merge 5 commits into
v3-changesfrom
rain/issue-107
Draft

harrydayexe wants to merge 5 commits into
v3-changesfrom
rain/issue-107

Conversation

@harrydayexe

@harrydayexe harrydayexe commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Closes #107

Note

This pull request was written by rain running Claude Code. It has not been reviewed by a human. CI and an automated review pass run before it is handed over.

Issue

#107: feat: optional series pages defined in a site-wide series.yml

What the agent reports

Pushed. Here's the handover.

What I built

Optional series pages driven by one site-wide YAML file, opt-in via --series-file / config.WithSeriesFile(fsys, path).

Config (pkg/config) — SeriesFile{FS, Path} + WithSeriesFile, embedded in Generator and DirectoryWriter and applied in the option chains (the server forwards it through cfg.Gen). Also WatchFiles + WithWatchFile, a watcher option for reacting to a single non-markdown file.

Generator (pkg/generator/series.go) — reads the file through the fs.FS, decodes with yaml.v3 + KnownFields(true), and validates: missing/empty name, missing/empty posts, unknown filename, post in two series, post twice in one series, colliding slugs, empty-after-slugify slug, malformed YAML, missing top-level series key, unknown keys. Every message names the series and the offending value. Posts resolve by SourcePath (filename), not slug. Renders series.tmpl / series-index.tmpl into new GeneratedBlog.Series / .SeriesIndex, and attaches PostPageData.Series (models.PostSeries: name, slug, description, path, ordered posts, 1-based position, total, prev/next).

Outputter / server / watcher / CLI — series/ written only when series pages exist and never under --raw; /series and /series/{slug} registered only when series content exists (both clean and .html URLs); serve --watch watches the series file explicitly; --series-file is validated up front and fails with the existing FlagError style.

Templates — new series and series-index pages, a "Part N of M" box with prev/next on post pages, and a SeriesEnabled-gated nav link. Trim markers keep disabled output unchanged.

Docs & example — README section (CLI/Docker/library), pkg/config/doc.go, pkg/generator/doc.go, pkg/models/doc.go, ExampleWithSeriesFile, a three-part example series plus docs/example-posts/series.yml, and just run-serve / run-image now pass the flag.

Verification

go build ./..., go vet ./..., go test ./..., go test -race ./..., gofmt -l ., addlicense -check ./ — all clean. I also generated the example site with and without the flag: with series disabled, the output is byte-identical to the same command run against main (only the three newly added example posts show up in listings; posts/getting-started-with-go.html diffs empty). I could not run a live serve smoke test — the sandbox denied the command — so routing is covered by httptest tests instead.

Worth a close look

  • Series index path under HTMLPaths. The issue's prose says BaseData.Path should be /series.html, but the file written is series/index.html, so that path would 404. I followed the existing tags-index convention instead: /series/index.html (the single-series path is /series/{slug}.html as specified). This is the one deliberate deviation from the issue text; the existing comment in pagePath explains the same reasoning for tags.
  • Sitemap. Series URLs are not added to sitemap.xml. The issue enumerates outputs and doesn't mention the sitemap, so I kept scope tight — but it's a plausible follow-up.
  • Unknown-key errors surface yaml.v3's wording (field post not found in type generator.seriesDefinition), which leaks the internal type name. It names the bad key, which is what rule 14 requires, but it could be prettified.
  • models.Slugify is newly exported (was unexported slugify) so series slugs use the identical rules as post titles.
  • Open question 1 is implemented as "fail with a clear error": a custom template dir without pages/series.tmpl fails generation once series are enabled, with the template named. Open questions 2 (reject unknown keys) and 3 (file order) follow the issue's suggestions.

Changelog (#108)

✨ New Features

  • (series) add optional series pages from a site-wide series.yml

🐛 Bug Fixes

  • (series) emit a real meta description and reject the reserved index slug

📚 Documentation

  • (series) document the series file, flag and option

🧪 Tests

  • (series) cover series parsing, output, routing and reload

🧹 Chores

  • (docs) add license header to the example series file

@harrydayexe

Copy link
Copy Markdown
Owner Author

Automated review

I read the full diff plus the surrounding generator, outputter, server, watcher, template, and CLI code, ran the suite (all green), and exercised the feature end-to-end through the generate CLI to confirm behaviour.

The design is solid and matches the issue closely: opt-in via config.WithSeriesFile/--series-file, strict validation with all 8 rules and named values, file-order preservation, PostSeries prev/next, independence from tags, route/output gating, hot reload, and thorough tests covering nearly every numbered case. Three things are wrong.

  1. pkg/generator/generator.go:585 + pkg/models/seriesPageData.go:51 — series pages emit an empty meta description; seriesDescription is dead code.
    SeriesPageData declares its own Description field at depth 0, which shadows the embedded BaseData.Description at depth 1. partials/head.tmpl renders {{.Description}} for <meta name="description">, og:description and the JSON-LD description, so it reads the series' description, never the BaseData.Description that seriesDescription(s) fills. The fallback that function exists for ("so the page never emits an empty meta description", series.go:162-170) therefore has no effect at all. Verified: generating a series with no description: produces <meta name="description" content=""> and <meta property="og:description" content="">. Tag pages don't hit this because TagPageData has no Description field, so BaseData.Description ("Posts tagged with X") is what renders.
    Fix: have the page struct stop shadowing — either drop SeriesPageData.Description and let series.tmpl use BaseData.Description (set from seriesDescription(s)), or keep the field for the body but set BaseData.Description and reference it explicitly in head.tmpl's caller. The simplest correct change is to set Description: seriesDescription(s) on the page-level field too, so both the body and the meta tags get a non-empty value.

  2. pkg/generator/series.go:97-104 — a series whose slug is index is silently dropped from generate output.
    slugOwners rejects duplicate slugs but not the reserved index slug. pkg/outputter/directoryWriter.go:201-207 writes blog.Series first and then unconditionally overwrites series/index.html with blog.SeriesIndex, so a series named "Index" (slug index) produces exactly one file, the series index, and its own page is unreachable with no error. Verified: series: [- name: "Index", posts: [...]] yields only series/index.html containing the index page. serve is inconsistent with this — it happily serves the series at /series/index. (Tags have the same latent trait, so this is a mirrored pattern rather than a regression, but it is new silently-lost content.)
    Fix: in seriesSlug, reject a resolved slug of index with an error naming the series, in the same style as the existing slug-collision error.

  3. pkg/generator/series_test.go:337 — the unknown-key test (issue case 14) passes for the wrong reason.
    The input is series:\n - name: Typo\n post:\n - part-1.md\n and the only assertion is that the error contains "post". If dec.KnownFields(true) were ever removed, post: would be ignored, Posts would be empty, and generation would fail with series "Typo" lists no posts — which also contains "post", so the test would still pass. It therefore does not pin the behaviour it documents. (I confirmed the production behaviour is correct: yaml.v3 with KnownFields(true) returns line 3: field post not found in type ....)
    Fix: assert on something specific to unknown-field rejection, e.g. "field post" or "not found", alongside the series name.

Agent response

All three points addressed; pushed as 36e125e to rain/issue-107. Tests (go test ./...), go vet, gofmt -l, and addlicense -check all pass.

1. Empty meta description / dead seriesDescription — fixed.
Confirmed exactly as described: SeriesPageData.Description at depth 0 shadowed BaseData.Description, so head.tmpl rendered the series' own (possibly empty) description and the fallback never reached a page.

I went with the un-shadowing fix rather than the reviewer's "simplest" suggestion of setting both fields to seriesDescription(s). Setting both would also push the generated sentence ("Posts in the X series") into the page body under the <h1>, which is filler the {{if .Description}} guard in series.tmpl clearly exists to avoid. Instead I renamed the page-level field to SeriesDescription (documented with why it isn't called Description), set BaseData.Description = seriesDescription(s), and updated series.tmpl. Body shows only an author-written description; meta tags always get a non-empty one. Verified via the generate CLI: a series with no description: now emits content="Posts in the No Desc Series series" in <meta name="description">, og:description and JSON-LD, with nothing added to the body. New table test TestSeries_Description covers both cases and asserts the body/meta split.

2. Series with slug index silently dropped — fixed.
seriesSlug now rejects a resolved slug of index (via a documented reservedSeriesSlug const), after the empty-once-slugified checks so those keep their more specific messages. Error: series file "series.yml": series "Index" uses the slug "index", which is reserved for the series index page; give the series an explicit slug. Verified end-to-end. Two validation-table cases added (derived slug and explicit slug: index), and the rule is now in the README, generator/doc.go, and the loadSeries godoc.

I did not extend the same check to tags — it's pre-existing behaviour outside this PR, and changing it would turn currently-building sites into failing ones as a side effect of a series PR.

3. Unknown-key test passing for the wrong reason — fixed.
The assertion is now {"field post", "not found", "series.yml"} with a comment explaining that "post" alone would also match the lists no posts error, so the test would survive removal of KnownFields(true). Confirmed the real error is parsing series file "series.yml": yaml: unmarshal errors: line 3: field post not found in type generator.seriesDefinition.

One unrelated observation while testing: goblog generate prints generation errors but exits 0 (true for post parse failures too, on main). It doesn't affect these fixes — nothing is written and the error is loud — but it's worth a separate look if the intent is for CI to fail on a bad build.


Posted by rain. Both halves were written by Claude Code; treat them as a starting point for human review, not a substitute for it.

harrydayexe and others added 5 commits September 25, 2026 21:25
Series are named, ordered collections of posts defined in one site-wide YAML
file rather than in post front matter, so renaming a series or reordering its
parts is a single-file edit. A post belongs to at most one series and the order
is explicit, not by date.

The feature is opt-in: it is off unless a series file is supplied via
config.WithSeriesFile or --series-file. Every rule the file must satisfy is a
hard error naming the series and the offending value, so a typo fails
generation rather than silently dropping content.

- pkg/config: SeriesFile type + WithSeriesFile, and WatchFiles +
  WithWatchFile so the watcher can react to a non-markdown file
- pkg/generator: parse and validate the file, render series and series-index
  pages, attach PostPageData.Series, expose both on GeneratedBlog
- pkg/models: SeriesInfo, SeriesIndexPageData, SeriesPageData, PostSeries,
  BaseData.SeriesEnabled, and an exported Slugify for deriving series slugs
- pkg/outputter: write series/ only when series pages were generated
- pkg/server: register /series routes only when series content exists
- pkg/templates: series and series-index pages, a post series box, and a nav
  link, all guarded so output with series disabled is byte-identical
- CLI: shared --series-file flag on generate and serve, failing fast when the
  path cannot be read, and watched in serve --watch

Refs #107
Adds a README section covering the format and all three ways to enable it (CLI,
Docker, library), doc.go entries for WithSeriesFile and WithWatchFile, the new
page paths, and the validation rules. The just serve recipes now pass
--series-file so the example posts demonstrate the feature.

Also extracts Generator.parsePosts so the parsing step can be reused.

Refs #107
Covers the acceptance cases from the issue: the disabled default, an empty
series list, file order beating date order, derived and explicit slugs, every
validation error, the post page's position/prev/next fields, paths under a blog
root and with HTML paths, independence from --disable-tags, the outputter's
series directory, the server's /series routes, and serve reloading a reordered
or newly invalid series file.

Refs #107
addlicense checks .yml files, so the example series file needs the same MPL header the other YAML files carry.
…ex slug

SeriesPageData.Description shadowed the embedded BaseData.Description that
partials/head.tmpl renders, so a series with no description in the series file
produced an empty meta description, og:description and JSON-LD description, and
seriesDescription's fallback never reached a page. Rename the page-level field
to SeriesDescription so the two no longer collide: the body still shows only an
author-written description, while the meta tags get the fallback.

A series whose slug resolved to "index" was also silently lost: the outputter
writes series/index.html for the series index after the per-series pages, so the
series page was overwritten with no error, while serve happily served it. Reject
that slug during validation, in the same style as the slug-collision error.

Also tighten the unknown-key validation test, which asserted only on "post" and
so would still have passed if KnownFields(true) were dropped and the series
failed for lacking posts instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@harrydayexe
harrydayexe changed the base branch from main to v3-changes September 25, 2026 21:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: optional series pages defined in a site-wide series.yml

1 participant