Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
# The sitemap <lastmod> 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:
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ Four places, or it does not build: the shell at `apps/site/<name>/index.html`,
the body at `apps/site/src/pages/<name>.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 `<lastmod>` 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
Expand Down
25 changes: 19 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions apps/site/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 2 additions & 7 deletions apps/site/src/lib/beans-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -76,10 +76,5 @@ export const beansBody = (selected: string | null = null): string => {

${shown.length ? shown.map(card).join("") : `<p>No bag matches that roaster.</p>`}

<footer class="site-footer">
<a href="/">Home</a> · <a href="/recipes/">Browse</a><a href="/showcase/">Showcase</a> ·
<a href="/docs/">Spec</a> ·
<a href="https://github.com/coffeejson-org/coffeejson" rel="noopener">GitHub</a>
${licenseLine(LICENSE_SITE, QUOTED_PROSE, CORRECTIONS)}
</footer>`;
${siteFooter(LICENSE_SITE, QUOTED_PROSE, CORRECTIONS)}`;
};
6 changes: 6 additions & 0 deletions apps/site/src/lib/footer.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
33 changes: 33 additions & 0 deletions apps/site/src/lib/footer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down Expand Up @@ -57,3 +59,34 @@ export const licenseLine = (...clauses) =>
*/
export const footerHtml = (...clauses) =>
`<footer>${licenseLine(...clauses)}</footer>`;

/**
* 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) =>
`<footer class="site-footer"><nav aria-label="Site">${FOOTER_LINKS.map(
([href, label]) =>
href.startsWith("/")
? `<a href="${href}">${label}</a>`
: `<a href="${href}" rel="noopener">${label}</a>`,
).join(" · ")}</nav>${licenseLine(...clauses)}</footer>`;
10 changes: 2 additions & 8 deletions apps/site/src/pages/implementations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => `
Expand Down Expand Up @@ -62,9 +61,4 @@ export const implementationsBody = (): string => `
and it appears on <a href="/showcase/">the showcase</a>. No approval step,
nothing to sign.</p>

<footer class="site-footer">
<a href="/">Home</a> · <a href="/showcase/">Showcase</a> ·
<a href="${SPEC}">Spec</a> · <a href="/agents/">For AI agents</a> ·
<a href="https://github.com/coffeejson-org/coffeejson" rel="noopener">GitHub</a>
${licenseLine(LICENSE_SITE, PACKAGES)}
</footer>`;
${siteFooter(LICENSE_SITE, PACKAGES)}`;
3 changes: 2 additions & 1 deletion apps/site/src/pages/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -113,7 +114,7 @@ export const landingBody = (): string => `
<h2>Read the spec</h2>
<ul>
<li><a href="${GUIDE}" rel="noopener">Integration guide</a> — the consumer and producer checklists</li>
<li><a href="https://github.com/coffeejson-org/coffeejson/tree/main/docs/spec" rel="noopener">Specification</a> — envelope, Recipe, Bean, Tasting, vocabularies</li>
<li><a href="${SPEC_URL}" rel="noopener">Specification</a> — envelope, Recipe, Bean, Tasting, vocabularies</li>
<li><a href="/schema/1.0">JSON Schema</a> — draft 2020-12</li>
<li><a href="https://github.com/coffeejson-org/coffeejson/blob/main/docs/transport.md" rel="noopener">Transport</a> — file, share URL, QR</li>
<li><a href="https://github.com/coffeejson-org/coffeejson/tree/main/fixtures" rel="noopener">Fixture corpus</a> — valid and invalid, checked in CI</li>
Expand Down
4 changes: 2 additions & 2 deletions apps/site/tests/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ describe("the retracted claim", () => {
...["src/pages", "src/lib"].flatMap((d) =>
readdirSync(join(site, d)).map((f) => `${d}/${f}`),
),
].filter((f) => /<footer|footerHtml\(/.test(read(f)));
].filter((f) => /<footer|footerHtml\(|siteFooter\(/.test(read(f)));

it("every module that emits a footer is accounted for", () => {
expect(emitters.sort()).toEqual([
Expand All @@ -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)\(/);
});
});

Expand Down
70 changes: 70 additions & 0 deletions apps/site/tests/site-footer.test.ts
Original file line number Diff line number Diff line change
@@ -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 =>
/<footer class="site-footer"><nav[^>]*>(.*?)<\/nav>/s.exec(html)?.[1] ?? "";

test("the footer lists the same links in the same order", () => {
expect(FOOTER_LINKS.map(([, label]) => label)).toEqual(FOOTER_ORDER);
const labels = [...linksRow(siteFooter(LICENSE_SITE)).matchAll(/>([^<]+)</g)]
.map((m) => m[1]!)
.filter((t) => t.trim() !== "·");
expect(labels).toEqual(FOOTER_ORDER);
});

test("every pair of footer links is separated by a dot", () => {
const row = linksRow(siteFooter(LICENSE_SITE));
expect(row.replace(/<a [^>]*>[^<]*<\/a>/g, "|")).toBe(
Array(FOOTER_ORDER.length).fill("|").join(" · "),
);
});

test("every on-site footer link is a page the site serves", () => {
for (const [href] of FOOTER_LINKS.filter(([h]) => h.startsWith("/")))
expect(existsSync(join(site, href, "index.html")), href).toBe(true);
});

test("Spec points where the home page does, and the home page uses it", () => {
expect(SPEC_URL).toBe(`${GITHUB_URL}/tree/main/docs/spec`);
expect(FOOTER_LINKS).toContainEqual([SPEC_URL, "Spec"]);
expect(read("src/pages/landing.ts")).toContain(
'<a href="${SPEC_URL}" rel="noopener">Specification</a>',
);
});

test("the footer always carries the license line", () => {
expect(siteFooter(LICENSE_SITE)).toContain(
`<p class="muted">${LICENSE_SITE}`,
);
});

// A page that writes its own links row fails this test.
test("no source module hand-writes the links footer", () => {
const handWritten = ["src/pages", "src/lib"]
.flatMap((d) => readdirSync(join(site, d)).map((f) => `${d}/${f}`))
.concat("tools/gen.mjs")
.filter((f) => /<footer class="site-footer"/.test(read(f)));
expect(handWritten).toEqual(["src/lib/footer.mjs"]);
});
Loading
Loading