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/CHANGELOG.md b/CHANGELOG.md index c571193..be79613 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,10 +40,10 @@ place ([Versioning](docs/spec/07-versioning.md)); layout follows `category` narrowed to how a brewer brews, and `pour-over-machine`, `drip` and `capsule` added to it. An all-in-one carries more than one role, which one member could not express. Registry data: nothing bumps. -- **Thirty-three gear entries and twenty-six varietals.** The gear baskets - collapse from eight per-dose slugs to four families, the dose moving to - `variant`. Every varietal entry now carries a `kind` and the `species` epithet - it is sold as, both optional. +- **Thirty-three gear entries and twenty-six varietals.** The four per-dose + basket slugs become two families, with the dose in `variant`. Every varietal + entry now carries a `kind` and the `species` epithet it is sold as, both + optional. ### Changed @@ -54,8 +54,21 @@ place ([Versioning](docs/spec/07-versioning.md)); layout follows authoring schema enforces the omission for registered ids and leaves it alone for an id the registry does not carry, which still needs the fallback. - **`gearLabel` in `@coffeejson/core` resolves a known id** against the - registry's own labels and renders `variant` beside it, rather than reading the - display string off the document. The bundled map is keyed by language tag. + registry's own labels and renders `variant` beside that label, rather than + reading the display string off the document. The bundled map is keyed by + language tag. For `custom` and an unknown id, it shows the producer's `label` + as written. Without a `label`, it shows `brand` and `model`, then `variant`. + +### Removed + +- **Four per-dose basket slugs and their aliases are retired** from the + [gear registry](docs/spec/06-vocabularies.md#gear-registry): + `ims-precision-18g` (`ims-precision-18`), `vst-precision-15g` (`vst-15g`), + `vst-precision-18g` (`vst-18g`) and `vst-precision-20g` (`vst-20g`). Emit + `ims-precision` or `vst-precision` with the dose in `variant`: + `{ "id": "vst-precision", "variant": "18 g" }`. The old slugs do not resolve + to a family, because the family does not identify the dose. A consumer + treats them as unknown ids. ### Fixed 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 => + /