From d04279df4bf78993ab0674411e1cd7a3aed9ef1d Mon Sep 17 00:00:00 2001 From: Ken Tominaga Date: Thu, 10 Sep 2026 23:23:34 -0700 Subject: [PATCH 1/2] One module builds the links footer, and each sitemap URL has a lastmod The /beans/ footer linked Spec to /docs/, which does not exist, and one separator was missing. Both footer pages now use one module, and a test pins the links and the separators. Spec links to the spec on GitHub. Each sitemap URL gets its lastmod from the last commit to its source files. The build clock is never used. A shallow clone gives no dates, so the deploy and the site tests fetch full history. --- .github/workflows/deploy-pages.yml | 3 + .github/workflows/validate.yml | 3 + AGENTS.md | 4 +- apps/site/DESIGN.md | 3 + apps/site/src/lib/beans-body.ts | 9 +- apps/site/src/lib/footer.d.mts | 6 + apps/site/src/lib/footer.mjs | 33 +++++ apps/site/src/pages/implementations.ts | 10 +- apps/site/src/pages/landing.ts | 3 +- apps/site/tests/analytics.test.ts | 4 +- apps/site/tests/site-footer.test.ts | 70 ++++++++++ apps/site/tests/sitemap-lastmod.test.ts | 120 +++++++++++++++++ apps/site/tools/gen.d.mts | 27 +++- apps/site/tools/gen.mjs | 166 +++++++++++++++++++++++- 14 files changed, 437 insertions(+), 24 deletions(-) create mode 100644 apps/site/tests/site-footer.test.ts create mode 100644 apps/site/tests/sitemap-lastmod.test.ts diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 77ccbb7..71d51fc 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -18,7 +18,10 @@ jobs: build: runs-on: ubuntu-latest steps: + # The sitemap dates come from git history, so fetch all of it. - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 8c28436..3bb2a13 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -40,7 +40,10 @@ jobs: workspaces: runs-on: ubuntu-latest steps: + # The site tests read sitemap dates from git history, so fetch all of it. - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: diff --git a/AGENTS.md b/AGENTS.md index a28af6e..7f26fc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,9 @@ Four places, or it does not build: the shell at `apps/site//index.html`, the body at `apps/site/src/pages/.ts`, an entry in `tools/prerender.ts`, and an input in `vite.config.ts`. Add the path to `INDEXABLE_PATHS` in `tools/gen.mjs` as well — that one array feeds both the sitemap and the check -that robots.txt never blocks a URL the sitemap advertises. +that robots.txt never blocks a URL the sitemap advertises. Also add the page's +source files to `PAGE_SOURCES`. The sitemap `` comes from their git +history. Three more lists are hardcoded, and a page missing from them ships with **no test failing** — the silent gap, so add the page to all three: `PAGES` in diff --git a/apps/site/DESIGN.md b/apps/site/DESIGN.md index 622cfe2..0554e27 100644 --- a/apps/site/DESIGN.md +++ b/apps/site/DESIGN.md @@ -174,6 +174,9 @@ with a 22% radius, the iOS mask, so a square export reads as the icon it is. Implementations · Validator · Generate) over a 2px `on-surface` rule; the heavy rule is what makes it a masthead rather than chrome. The current page is bold with `aria-current`, not accent-colored. Brew-along strips it to the wordmark. +- **Links footer** — one row of links in a fixed order (Home · Browse · Showcase · Spec · + For AI agents · GitHub), separated by dots, above the license line. Only `siteFooter` + builds it. Spec links to GitHub because the site has no page at `/docs/`. - **Button** (`components.button`); **ghost** (`components.button-ghost`) is a modifier on it with a primary border. Hover shades the fill 15% toward `on-surface`, instantly. - **Chip** (`components.chip`, on-state `components.chip-on`) — facet filters and the diff --git a/apps/site/src/lib/beans-body.ts b/apps/site/src/lib/beans-body.ts index 46a6be3..cf927e1 100644 --- a/apps/site/src/lib/beans-body.ts +++ b/apps/site/src/lib/beans-body.ts @@ -2,8 +2,8 @@ import beans from "../generated/beans-index.json"; import { CORRECTIONS, LICENSE_SITE, - licenseLine, QUOTED_PROSE, + siteFooter, } from "./footer.mjs"; import { siteHeader } from "./site-header.mjs"; import { esc, slugify } from "./text.mjs"; @@ -76,10 +76,5 @@ export const beansBody = (selected: string | null = null): string => { ${shown.length ? shown.map(card).join("") : `

No bag matches that roaster.

`} - `; + ${siteFooter(LICENSE_SITE, QUOTED_PROSE, CORRECTIONS)}`; }; diff --git a/apps/site/src/lib/footer.d.mts b/apps/site/src/lib/footer.d.mts index 4678837..d5be896 100644 --- a/apps/site/src/lib/footer.d.mts +++ b/apps/site/src/lib/footer.d.mts @@ -12,3 +12,9 @@ export declare const CRAWLERS_UNCHANGED: string; export declare function licenseLine(...clauses: string[]): string; /** Joins the given license clauses with the privacy sentence, always last. */ export declare function footerHtml(...clauses: string[]): string; +/** The spec on GitHub. */ +export declare const SPEC_URL: string; +/** Footer links as `[href, label]`, in display order. */ +export declare const FOOTER_LINKS: readonly (readonly [string, string])[]; +/** The footer: the links row, then the license line. */ +export declare function siteFooter(...clauses: string[]): string; diff --git a/apps/site/src/lib/footer.mjs b/apps/site/src/lib/footer.mjs index 19687a8..2c161d1 100644 --- a/apps/site/src/lib/footer.mjs +++ b/apps/site/src/lib/footer.mjs @@ -3,6 +3,8 @@ // `tools/gen.mjs` is plain Node. A `.ts` module reaches only half the pages, and // the sentence below is then hand-copied into the other half. +import { GITHUB_URL } from "./site-header.mjs"; + /** License line for the hand-written pages, which cover more than the corpus. */ export const LICENSE_SITE = "Spec, schema and corpus: CC0 1.0 Universal — public domain."; @@ -57,3 +59,34 @@ export const licenseLine = (...clauses) => */ export const footerHtml = (...clauses) => `
${licenseLine(...clauses)}
`; + +/** + * The spec on GitHub. The site has no spec page at `/docs/`, so links for + * readers go here. + */ +export const SPEC_URL = `${GITHUB_URL}/tree/main/docs/spec`; + +/** The footer links, in display order. */ +export const FOOTER_LINKS = [ + ["/", "Home"], + ["/recipes/", "Browse"], + ["/showcase/", "Showcase"], + [SPEC_URL, "Spec"], + ["/agents/", "For AI agents"], + [GITHUB_URL, "GitHub"], +]; + +/** + * The links row and the license line. Every page with this footer calls this + * function, so the pages cannot drift apart. + * + * @param {...string} clauses + * @returns {string} + */ +export const siteFooter = (...clauses) => + `
${licenseLine(...clauses)}
`; diff --git a/apps/site/src/pages/implementations.ts b/apps/site/src/pages/implementations.ts index c73ba39..6c1ba05 100644 --- a/apps/site/src/pages/implementations.ts +++ b/apps/site/src/pages/implementations.ts @@ -2,14 +2,13 @@ // implement this. Where the format is used is `/showcase`. A page headed "built // with" listing one app reads as a claim. -import { LICENSE_SITE, licenseLine, PACKAGES } from "../lib/footer.mjs"; +import { LICENSE_SITE, PACKAGES, siteFooter } from "../lib/footer.mjs"; import { siteHeader } from "../lib/site-header.mjs"; // The site serves the docs only as raw Markdown at their exact paths, so a page // links the rendered copy on GitHub, the way the landing and showcase pages do. const REPO = "https://github.com/coffeejson-org/coffeejson"; const GUIDE = `${REPO}/blob/main/docs/integration-guide.md`; -const SPEC = `${REPO}/blob/main/docs/README.md`; /** The implementations body. Prerendered — see the note on `landingBody`. */ export const implementationsBody = (): string => ` @@ -62,9 +61,4 @@ export const implementationsBody = (): string => ` and it appears on the showcase. No approval step, nothing to sign.

- `; + ${siteFooter(LICENSE_SITE, PACKAGES)}`; diff --git a/apps/site/src/pages/landing.ts b/apps/site/src/pages/landing.ts index c4e1beb..ce0ce52 100644 --- a/apps/site/src/pages/landing.ts +++ b/apps/site/src/pages/landing.ts @@ -6,6 +6,7 @@ import { LICENSE_SITE, PACKAGES, QUOTED_PROSE, + SPEC_URL, } from "../lib/footer.mjs"; import { SAMPLE_DOC, SAMPLE_TEXT } from "../lib/sample"; import { siteHeader } from "../lib/site-header.mjs"; @@ -113,7 +114,7 @@ export const landingBody = (): string => `

Read the spec

  • Integration guide — the consumer and producer checklists
  • -
  • Specification — envelope, Recipe, Bean, Tasting, vocabularies
  • +
  • Specification — envelope, Recipe, Bean, Tasting, vocabularies
  • JSON Schema — draft 2020-12
  • Transport — file, share URL, QR
  • Fixture corpus — valid and invalid, checked in CI
  • diff --git a/apps/site/tests/analytics.test.ts b/apps/site/tests/analytics.test.ts index d13cf62..8ac5f67 100644 --- a/apps/site/tests/analytics.test.ts +++ b/apps/site/tests/analytics.test.ts @@ -72,7 +72,7 @@ describe("the retracted claim", () => { ...["src/pages", "src/lib"].flatMap((d) => readdirSync(join(site, d)).map((f) => `${d}/${f}`), ), - ].filter((f) => / / { expect(emitters.sort()).toEqual([ @@ -88,7 +88,7 @@ describe("the retracted claim", () => { }); it.each(emitters)("%s builds its footer from the shared source", (file) => { - expect(read(file)).toMatch(/\b(footerHtml|licenseLine)\(/); + expect(read(file)).toMatch(/\b(footerHtml|licenseLine|siteFooter)\(/); }); }); diff --git a/apps/site/tests/site-footer.test.ts b/apps/site/tests/site-footer.test.ts new file mode 100644 index 0000000..13cd41f --- /dev/null +++ b/apps/site/tests/site-footer.test.ts @@ -0,0 +1,70 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test } from "vitest"; +import { + FOOTER_LINKS, + LICENSE_SITE, + SPEC_URL, + siteFooter, +} from "../src/lib/footer.mjs"; +import { GITHUB_URL } from "../src/lib/site-header.mjs"; + +const site = fileURLToPath(new URL("..", import.meta.url)); +const read = (p: string) => readFileSync(join(site, p), "utf8"); + +// One module builds this row. Two hand-written copies of it drifted before. +const FOOTER_ORDER = [ + "Home", + "Browse", + "Showcase", + "Spec", + "For AI agents", + "GitHub", +]; + +const linksRow = (html: string): string => + /