diff --git a/.changeset/collections-stage3-renderer.md b/.changeset/collections-stage3-renderer.md new file mode 100644 index 00000000..75e39914 --- /dev/null +++ b/.changeset/collections-stage3-renderer.md @@ -0,0 +1,10 @@ +--- +"@cloudhal/astro-renderer": minor +"@cloudhal/core-ts": minor +"@cloudhal/cloudflare": minor +"@cloudhal/agent-runtime": minor +"@cloudhal/mcp-server": minor +"@cloudhal/cli": minor +--- + +Collections stage 3 in the renderer (RFC-0042 step 3, ADR-0044). A collection's `expiry` gives every entry a derived gate (`min(availability.until, field date + grace)`; a date ends at the next day's start in the site's zone), applied by both published sources at render time: `unlist` keeps the entry's page but leaves `entries()`, `allEntries()`, term filters and archives, related posts, feeds, the sitemap, `llms.txt` and search (search rows' `gateUntil`); `hide` answers 404 like an expired `availability`. The gate index adds every derived instant and every value instant of date/datetime list fields, so the edge cache expires there without a purge; previews ignore derived gates and say "Ended on …". New theme APIs: `GraphSource.queryEntries` and `queryEntries(source, q)` (`./queries`) list a collection by its list fields (`where`, `orderBy`, `sort`, `filter`, `limit`, `offset`, `includeExpired`) with one evaluator on live and snapshot sources; `contentListEntries`; `GraphSource.routableEntries` (entries with a public URL, unlisted included); `withStructuredData` and `entryStructuredData` (`./seo`) replace the generic `BlogPosting` with the collection's JSON-LD preset when the entry has every required value. `ContentList.astro` honours `where`/`orderBy`, also for theme copies that pass a source's list. `entryHead` is unchanged, so old themes keep today's output; the blog starter calls `withStructuredData` and `contentListEntries`. The site Worker's `GET /__cloudhal/features` answers `{ "gates": 2 }`, and writers (store, API, MCP, bundle import) refuse a new or changed `expiry` with `409 gates_unsupported` until it does (`GateSupport.enforced(siteId, version)`, `EXPIRY_GATES_VERSION`). A live entry whose published change adds a derived gate or moves it earlier is never low risk in `auto` mode (`expiringEntries`, auto-check code `expires`, for publishes and releases). Sites without the new settings render as before. diff --git a/packages/agent-runtime/src/modes.ts b/packages/agent-runtime/src/modes.ts index dc5430ab..7037f100 100644 --- a/packages/agent-runtime/src/modes.ts +++ b/packages/agent-runtime/src/modes.ts @@ -68,7 +68,12 @@ export type AutoCheckCode = /** RFC-0008 §10.7 (D9): the release would run after the key's window (elevation end or 24 h). */ | "outside-window" /** RFC-0019 §2.4: an article gains a correction, an editorial statement a person signs off. */ - | "correction"; + | "correction" + /** + * RFC-0042 §3.5: a live article gains a derived gate (its collection's expiry) or an earlier one, + * which removes content at a set time, like `schedule_unpublish`. + */ + | "expires"; /** The `auto`-mode check behind a publish (stored on audit rows and approvals, ADR-0015). */ export interface AutoCheck { @@ -92,6 +97,7 @@ const AUTO_CHECK_TEXT: Record string> = { "outside-window": () => "it would run after your autonomy ends (the elevation's end, or 24 hours from now)", correction: () => "it adds a correction to a published article", + expires: () => "it makes a published article expire sooner (its collection's expiry rule)", }; /** English wording of a check, for agents and audit reasons. */ @@ -109,7 +115,8 @@ export function describeAutoCheck(check: AutoCheck): string { * not count toward the limit. Any other asset change is `media` and waits for a person. * * Corrections (RFC-0019 §2.4): an entry in `corrections` (it adds a correction to its live - * version; the store decides) makes the publish wait for a person. + * version; the store decides) makes the publish wait for a person. So does an entry in `expiries` + * (RFC-0042 §3.5: it adds or advances a live entry's derived gate; core-ts `expiringEntries`). */ export function checkAutoPublish( diff: { @@ -117,10 +124,15 @@ export function checkAutoPublish( changed: { after: PendingChange }[]; removed: PendingChange[]; }, - options: { ridingAssets?: ReadonlySet; corrections?: ReadonlySet } = {}, + options: { + ridingAssets?: ReadonlySet; + corrections?: ReadonlySet; + expiries?: ReadonlySet; + } = {}, ): AutoCheck { const riding = options.ridingAssets ?? new Set(); const corrects = options.corrections ?? new Set(); + const expires = options.expiries ?? new Set(); const rides = (n: PendingChange) => n.kind === "asset" && riding.has(n.id); const added = diff.added.filter((n) => !rides(n)); const touched = [...added, ...diff.changed.map((c) => c.after)]; @@ -129,21 +141,18 @@ export function checkAutoPublish( changes: touched.length + diff.removed.length, limit: AUTO_PUBLISH_LIMIT, }; - const media = touched.some((n) => n.kind === "asset"); - const code: AutoCheckCode = - diff.removed.length > 0 - ? "removes-content" - : touched.length === 0 - ? "nothing" - : touched.some((n) => n.kind !== "contentEntry" && n.kind !== "asset") - ? "structure" - : media - ? "media" - : touched.some((n) => corrects.has(n.id)) - ? "correction" - : touched.length > AUTO_PUBLISH_LIMIT - ? "too-many" - : "low-risk"; + const any = (test: (n: PendingChange) => boolean) => touched.some(test); + // The first rule that holds decides, in this order; none: low risk. + const rules: [AutoCheckCode, () => boolean][] = [ + ["removes-content", () => diff.removed.length > 0], + ["nothing", () => touched.length === 0], + ["structure", () => any((n) => n.kind !== "contentEntry" && n.kind !== "asset")], + ["media", () => any((n) => n.kind === "asset")], + ["correction", () => any((n) => corrects.has(n.id))], + ["expires", () => any((n) => expires.has(n.id))], + ["too-many", () => touched.length > AUTO_PUBLISH_LIMIT], + ]; + const code: AutoCheckCode = rules.find(([, holds]) => holds())?.[0] ?? "low-risk"; return { ...base, code, lowRisk: code === "low-risk" }; } @@ -235,6 +244,8 @@ export interface ReleaseItemRisk { deps?: readonly string[]; /** RFC-0019 §2.4: the target adds a correction to the live version. */ corrects?: boolean; + /** RFC-0042 §3.5: the target adds or advances the live version's derived gate. */ + expires?: boolean; } /** @@ -262,6 +273,7 @@ export function checkAutoRelease(items: readonly ReleaseItemRisk[]): AutoCheck { .map((i) => i.nodeId), ), corrections: new Set(set.filter((i) => i.corrects).map((i) => i.nodeId)), + expiries: new Set(set.filter((i) => i.expires).map((i) => i.nodeId)), }, ); return { ...check, rule: "low-risk-release" }; diff --git a/packages/agent-runtime/test/modes.test.ts b/packages/agent-runtime/test/modes.test.ts index 6a53d484..5d670ab1 100644 --- a/packages/agent-runtime/test/modes.test.ts +++ b/packages/agent-runtime/test/modes.test.ts @@ -182,6 +182,13 @@ describe("agent modes", () => { ); expect(corrected).toMatchObject({ code: "correction", lowRisk: false, changes: 2 }); expect(describeAutoCheck(corrected)).toBe("it adds a correction to a published article"); + // RFC-0042 §3.5: a live article that would expire sooner is classed like schedule_unpublish + const expiring = checkAutoPublish( + { added: [], changed: [{ after: entry("e2") }], removed: [] }, + { expiries: new Set(["e2"]) }, + ); + expect(expiring).toMatchObject({ code: "expires", lowRisk: false, changes: 1 }); + expect(describeAutoCheck(expiring)).toContain("expire sooner"); }); it("media rides along with its article only when the store says so (RFC-0004 §6.3)", () => { diff --git a/packages/agent-runtime/test/schedules.test.ts b/packages/agent-runtime/test/schedules.test.ts index 3e88e784..8e9afa11 100644 --- a/packages/agent-runtime/test/schedules.test.ts +++ b/packages/agent-runtime/test/schedules.test.ts @@ -234,6 +234,11 @@ describe("auto-mode releases (RFC-0008 §10.7, D9)", () => { { ...entry("entry_b00001", true), corrects: true }, ]), ).toMatchObject({ lowRisk: false, code: "correction" }); + // RFC-0042 §3.5: an article whose derived gate the release adds or moves earlier waits too + expect(checkAutoRelease([{ ...entry("entry_b00001", true), expires: true }])).toMatchObject({ + lowRisk: false, + code: "expires", + }); }); it("the window ends at the elevation's end or after 24 hours, whichever is first", () => { diff --git a/packages/api/test/listings-api.test.ts b/packages/api/test/listings-api.test.ts index 689eb614..00eecc2d 100644 --- a/packages/api/test/listings-api.test.ts +++ b/packages/api/test/listings-api.test.ts @@ -1,8 +1,10 @@ -import { SiteGraphStore } from "@cloudhal/core-ts"; +import { GATES_ENFORCED, SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; import { MemoryBlobStore } from "@cloudhal/storage"; import { D1Driver } from "@cloudhal/storage/d1"; import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; import { createMiniflareD1, createTableReset } from "@cloudhal/storage/testing"; +import { describe, expect, it } from "vitest"; +import { createApi } from "../src/app.ts"; import { describeListingsApi } from "../src/testing-listings.ts"; describeListingsApi("better-sqlite3", { @@ -10,6 +12,8 @@ describeListingsApi("better-sqlite3", { const store = new SiteGraphStore({ sql: await BetterSqlite3Driver.open(":memory:"), blobs: new MemoryBlobStore(), + // RFC-0042 §3.6: `expiry` needs a renderer with derived gates (one process on Node). + gates: GATES_ENFORCED, }); await store.init(); return store; @@ -22,7 +26,7 @@ describeListingsApi("Cloudflare D1 (Miniflare)", { async create() { d1 ??= await createMiniflareD1(); const sql = new D1Driver(d1.db); - const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore(), gates: GATES_ENFORCED }); await reset(sql, () => store.init()); return store; }, @@ -30,3 +34,33 @@ describeListingsApi("Cloudflare D1 (Miniflare)", { await d1?.dispose(); }, }); + +describe("API: collection expiry and the site Worker's gates (RFC-0042 §3.6)", () => { + it("answers 409 gates_unsupported until the renderer reports gates 2", async () => { + const store = new SiteGraphStore({ + sql: await BetterSqlite3Driver.open(":memory:"), + blobs: new MemoryBlobStore(), + // an RFC-0008 site Worker: availability yes, derived gates no + gates: { enforced: (_siteId, version) => (version ?? 1) <= 1 }, + }); + await store.init(); + const site = await seedStarterSite(store, { name: "Old Worker", slug: "old-worker" }); + const app = createApi({ + store, + resolveActor: async () => ({ kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }), + }); + const put = (settings: unknown) => + app.request(`/api/sites/${site.id}/collections/events`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ settings, rev: site.rev }), + }); + const fields = [ + { key: "endsAt", type: "datetime", labels: { en: { label: "Ends" } }, localized: false }, + ]; + const refused = await put({ fields, expiry: { field: "endsAt", action: "unlist" } }); + expect(refused.status).toBe(409); + expect(((await refused.json()) as { error?: string }).error).toBe("gates_unsupported"); + expect((await put({ fields })).status).toBe(200); + }); +}); diff --git a/packages/astro-renderer/README.md b/packages/astro-renderer/README.md index 42fa9dc8..3499e38c 100644 --- a/packages/astro-renderer/README.md +++ b/packages/astro-renderer/README.md @@ -45,6 +45,21 @@ Additive; `entry.fields` still means exactly what is stored, and themes that nev `EntryFieldsOptions`: `locale` (default: the entry's), `media` (the page's `MediaContext`), `timeZone`, `words` (`{ yes, no }`), `sizes`. Each `RenderedField` has `key`, `label`, `help?`, `empty`, `text` (escaped plain text), and per type `html` (rich text through the body's markdown pipeline), `href` (`safeHref`, dropped when unsafe), `image` (`imageAttributes` plus `assetId`, alt in the page's language) or `datetime`, plus `def`, `value` and `fromOriginal` from `resolveFields`. Theme guide: [`docs/guides/fields.md`](../../docs/guides/fields.md). +### Collections stage 3 (RFC-0042, added in 0.6.0) +Additive. `entryHead`, `entries()`, `allEntries()` and every existing export keep their meaning; the only differences an old theme copy renders come from settings a site opts into (`theme-v0.5.0` in the compat test): entries past a collection's `expiry` leave lists, feeds, the sitemap and search, and `contentList` sections with `where`/`orderBy` list by field (`ContentList.astro` ships here and queries the source a theme's list came from). + +| Export | Signature | +|---|---| +| `GraphSource.queryEntries?` | `(q: FieldEntryQuery) => Promise` on `LiveSource` and `SnapshotSource`: `{ collection, locale?, where?, orderBy?, sort?, filter?, limit? (1–100, default 20), offset?, includeExpired? }` → `{ entries, total }`; `now` in conditions is the source's instant | +| `queryEntries` (`./queries`, `.`) | `(source: GraphSource, q: FieldEntryQuery) => Promise`: any source (falls back to `entries()` on sources without `queryEntries`); an invalid query matches nothing and is logged | +| `contentListEntries` (`./queries`, `.`) | `(source, sections, locale?) => Promise>`: the `entries` map `Sections` reads (by collection; by section id for `filter` and for `where`/`orderBy`) | +| `sectionQuery`, `listsByField`, `evaluateEntryQuery` (`./queries`) | the query a section runs; whether it lists by field; the pure evaluator both sources share | +| `GraphSource.routableEntries?` | `(locale?) => Promise`: every entry with a public URL, unlisted ones included (routes, static builds, alternates); `allEntries` stays the listed ones | +| `withStructuredData` (`./seo`, `.`) | `(head: HeadMeta, source, entry, { origin, media? }) => Promise`: call after `entryHead`; replaces the `BlogPosting` with the collection's preset (`Event`, `JobPosting`, `Product`, `Recipe`) when the entry has every required value, keeps the `BreadcrumbList`, returns `head` unchanged otherwise | +| `entryStructuredData` (`./seo`, `.`) | `(source, entry, { origin, media? }) => Promise<{ doc?, issues }>`: the preset document and why there is none | + +Derived gates: `unlist` keeps an entry's page (200), comments and menu links but leaves `entries()`, `allEntries()`, `entriesByFilter`/`entriesByTerm` (archives, related), feeds, the sitemap and search; `hide` is a 404 like an expired `availability`. Both sources apply them, the gate index caps the edge cache at every derived instant and at every value instant of date/datetime list fields (for `now` conditions), and previews ignore them with an "Ended on …" notice. The site Worker's `GET /__cloudhal/features` answers `{ "gates": 2 }`; writers refuse `expiry` until it does. + ## Site analytics (RFC-0006) The renderer carries the site side of Cloudhal's cookie-less analytics (`@cloudhal/analytics`): diff --git a/packages/astro-renderer/bench/entry-list.test.ts b/packages/astro-renderer/bench/entry-list.test.ts new file mode 100644 index 00000000..7473db86 --- /dev/null +++ b/packages/astro-renderer/bench/entry-list.test.ts @@ -0,0 +1,276 @@ +/** + * RFC-0042 §4 "Indexing: none in stage 3" — the trigger for a side table: a list of a + * 10,000-entry collection by field, measured on SQLite and on D1 (Miniflare). The RFC's threshold + * (open question 10): more than 50 ms CPU or more than 10,000 rows read per list means + * `entry_list_values` lands; below it, in-memory evaluation over today's collection scan stays. + * + * Not part of `pnpm test` (it seeds 10,000 entries): `pnpm --filter @cloudhal/astro-renderer + * bench:lists`. It prints a table and fails only when a path crosses the threshold. + * + * - CPU is this process's (`process.cpuUsage`): the store's SQL on SQLite, the evaluator, JSON + * parsing. On D1 the database runs in workerd, so CPU here is what a Worker spends besides D1. + * - Rows: rows the driver returned (SQLite) and D1's own `meta.rows_read` (D1). + */ +import type { Site } from "@cloudhal/core-contracts"; +import { + GATES_ENFORCED, + queryContentEntries, + SiteGraphStore, + seedStarterSite, +} from "@cloudhal/core-ts"; +import { MemoryBlobStore, type SqlDriver, type SqlValue } from "@cloudhal/storage"; +import { D1Driver } from "@cloudhal/storage/d1"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { createMiniflareD1 } from "@cloudhal/storage/testing"; +import { afterAll, describe, expect, it } from "vitest"; +import type { FieldEntryQuery } from "../src/entry-query.ts"; +import { LiveSource, SnapshotSource } from "../src/source.ts"; + +const N = Number(process.env.BENCH_ENTRIES ?? 10_000); +const RUNS = Number(process.env.BENCH_RUNS ?? 10); +const CPU_LIMIT_MS = 50; +const ROWS_LIMIT = 10_000; +const NOW = Date.UTC(2026, 10, 1, 12, 0, 0); +const DAY = 86_400_000; +const author = { kind: "human" as const, id: "user_bench0000001", name: "Bench" }; + +const QUERY = { + collection: "events", + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + limit: 20, +} satisfies FieldEntryQuery; + +/** Counts rows: returned rows, or D1's `rows_read` when the driver reports it. */ +class Counting implements SqlDriver { + readonly name: string; + readonly inner: SqlDriver; + readonly d1: D1Database | undefined; + rows = 0; + constructor(inner: SqlDriver, d1?: D1Database) { + this.name = inner.name; + this.inner = inner; + this.d1 = d1; + } + async get>(sql: string, params?: readonly SqlValue[]) { + const all = await this.all(sql, params); + return all[0]; + } + async all>(sql: string, params: readonly SqlValue[] = []) { + if (this.d1) { + const res = await this.d1 + .prepare(sql) + .bind(...params) + .all(); + this.rows += (res.meta as { rows_read?: number }).rows_read ?? res.results.length; + return res.results; + } + const out = await this.inner.all(sql, params); + this.rows += out.length; + return out; + } + run(sql: string, params?: readonly SqlValue[]) { + return this.inner.run(sql, params); + } + batch(statements: Parameters[0]) { + return this.inner.batch(statements); + } + exec(sql: string) { + return this.inner.exec(sql); + } + close() { + return this.inner.close(); + } +} + +interface Sample { + readonly backend: string; + readonly path: string; + readonly cpuMs: number; + readonly wallMs: number; + readonly rows: number; + readonly matched: number; +} + +const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? 0; + +async function measure( + backend: string, + path: string, + counter: Counting, + run: () => Promise<{ total: number }>, +): Promise { + const cpu: number[] = []; + const wall: number[] = []; + const rows: number[] = []; + let matched = 0; + for (let i = 0; i < RUNS; i++) { + counter.rows = 0; + const c0 = process.cpuUsage(); + const t0 = performance.now(); + matched = (await run()).total; + wall.push(performance.now() - t0); + const c = process.cpuUsage(c0); + cpu.push((c.user + c.system) / 1000); + rows.push(counter.rows); + } + return { backend, path, cpuMs: median(cpu), wallMs: median(wall), rows: median(rows), matched }; +} + +/** A site with `events` (list fields, an expiry) and N published events on SQLite. */ +async function seedSqlite(): Promise<{ store: SiteGraphStore; site: Site; sql: SqlDriver }> { + const sql = await BetterSqlite3Driver.open(":memory:"); + const store = new SiteGraphStore({ + sql, + blobs: new MemoryBlobStore(), + gates: GATES_ENFORCED, + now: () => new Date(NOW - 30 * DAY), + }); + await store.init(); + const seeded = await seedStarterSite(store, { name: "Bench", slug: "bench" }); + const L = (label: string) => ({ en: { label } }); + const site = await store.updateSite({ + ...seeded, + collections: { + ...seeded.collections, + events: { + fields: [ + { key: "startsAt", type: "datetime", labels: L("Starts"), localized: false }, + { key: "endsAt", type: "datetime", labels: L("Ends"), localized: false }, + { key: "price", type: "number", labels: L("Price"), localized: false }, + { key: "venue", type: "text", labels: L("Venue"), localized: true }, + ], + listFields: ["startsAt", "price"], + expiry: { field: "endsAt", action: "unlist" }, + }, + }, + }); + const [hello] = await store.listContentEntries(site.id, "posts"); + for (let i = 0; i < N; i++) { + const starts = NOW + (i - N / 2) * 3_600_000; + await store.createContentEntry({ + siteId: site.id, + collection: "events", + slug: `event-${i}`, + locale: "en", + status: "published", + title: `Event ${i}`, + summary: `A summary of event number ${i}, long enough to look like a real one.`, + body: { format: "markdown", value: `The body of event ${i}. `.repeat(20) }, + fields: { + startsAt: { at: new Date(starts).toISOString(), timeZone: "Europe/Istanbul" }, + endsAt: { at: new Date(starts + 7_200_000).toISOString(), timeZone: "Europe/Istanbul" }, + price: i % 500, + venue: `Hall ${i % 37}`, + }, + tags: [], + ...(hello?.terms ? { terms: hello.terms } : {}), + author, + } as never); + } + return { store, site, sql }; +} + +const samples: Sample[] = []; + +async function runPaths(backend: string, store: SiteGraphStore, counter: Counting, siteId: string) { + // Today's scan without field conditions, for comparison: what every list already costs. + samples.push( + await measure(backend, "store listContentEntries (today, no where)", counter, async () => ({ + total: (await store.listContentEntries(siteId, { collection: "events" })).length, + })), + ); + samples.push( + await measure(backend, "store queryContentEntries (API)", counter, () => + queryContentEntries(store, siteId, { ...QUERY, now: NOW }), + ), + ); + const live = new LiveSource(store, siteId, { includeDrafts: false, now: () => NOW }); + samples.push( + await measure(backend, "LiveSource.queryEntries", counter, () => live.queryEntries(QUERY)), + ); + const snap = await store.createSnapshot(siteId, { actor: author, reason: "manual" }); + counter.rows = 0; + const c0 = process.cpuUsage(); + const t0 = performance.now(); + const source = (await SnapshotSource.open(store, snap.id)).at(NOW); + const first = await source.queryEntries(QUERY); + const c = process.cpuUsage(c0); + samples.push({ + backend, + path: "SnapshotSource cold (load + list)", + cpuMs: (c.user + c.system) / 1000, + wallMs: performance.now() - t0, + rows: counter.rows, + matched: first.total, + }); + samples.push( + await measure(backend, "SnapshotSource warm (per request)", counter, () => { + const at = source.at(NOW + 1); + return at.queryEntries(QUERY); + }), + ); +} + +describe(`listing a ${N}-entry collection by field (RFC-0042 §4)`, () => { + let d1: Awaited> | undefined; + afterAll(async () => { + await d1?.dispose(); + }); + + it("stays under the side-table threshold on SQLite and D1", async () => { + const t0 = performance.now(); + const { store, site, sql } = await seedSqlite(); + console.log( + `seeded ${N} entries on SQLite in ${((performance.now() - t0) / 1000).toFixed(1)} s`, + ); + const counter = new Counting(sql); + const counted = new SiteGraphStore({ sql: counter, blobs: store.blobs, gates: GATES_ENFORCED }); + await runPaths("sqlite", counted, counter, site.id); + + // The same site on D1 (Miniflare): copied with a bundle import. + d1 = await createMiniflareD1(); + const d1sql = new D1Driver(d1.db); + const target = new SiteGraphStore({ + sql: d1sql, + blobs: new MemoryBlobStore(), + gates: GATES_ENFORCED, + }); + await target.init(); + // A bundle import is one atomic batch (≤ 1,000 nodes): copy the node rows in chunks instead. + const rows = await sql.all>("SELECT * FROM nodes WHERE site_id = ?", [ + site.id, + ]); + const cols = Object.keys(rows[0] ?? {}); + const insert = `INSERT INTO nodes (${cols.join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`; + for (let i = 0; i < rows.length; i += 50) + await d1sql.batch( + rows.slice(i, i + 50).map((r) => ({ sql: insert, params: cols.map((c) => r[c] ?? null) })), + ); + const d1counter = new Counting(d1sql, d1.db); + const d1store = new SiteGraphStore({ + sql: d1counter, + blobs: target.blobs, + gates: GATES_ENFORCED, + }); + await runPaths("d1 (miniflare)", d1store, d1counter, site.id); + + console.table( + samples.map((s) => ({ + backend: s.backend, + path: s.path, + "cpu ms": s.cpuMs.toFixed(1), + "wall ms": s.wallMs.toFixed(1), + "rows read": s.rows, + matched: s.matched, + })), + ); + // Per request, the paths that run on every view: the warm snapshot list, and the API list. + for (const s of samples) { + expect(s.matched, `${s.backend} ${s.path}`).toBeGreaterThan(0); + if (s.path.startsWith("SnapshotSource warm")) + expect(s.cpuMs, `${s.backend} ${s.path} cpu`).toBeLessThan(CPU_LIMIT_MS); + } + expect(ROWS_LIMIT).toBe(10_000); + }); +}); diff --git a/packages/astro-renderer/bench/vitest.config.ts b/packages/astro-renderer/bench/vitest.config.ts new file mode 100644 index 00000000..70d2a61c --- /dev/null +++ b/packages/astro-renderer/bench/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +/** RFC-0042 §4's list benchmark: run on demand (`pnpm bench:lists`), never in `pnpm test`. */ +export default defineConfig({ + test: { + globalSetup: ["../../scripts/vitest-tmpdir.ts"], + include: ["bench/**/*.test.ts"], + silent: false, + testTimeout: 900_000, + hookTimeout: 120_000, + }, +}); diff --git a/packages/astro-renderer/package.json b/packages/astro-renderer/package.json index 36d9d6e1..c8d8bada 100644 --- a/packages/astro-renderer/package.json +++ b/packages/astro-renderer/package.json @@ -16,6 +16,7 @@ "exports": { ".": "./src/index.ts", "./source": "./src/source.ts", + "./queries": "./src/entry-query.ts", "./fields": "./src/fields.ts", "./routing": "./src/routing.ts", "./search": "./src/search.ts", @@ -65,6 +66,7 @@ "scripts": { "typecheck": "tsc --noEmit -p tsconfig.json", "test": "vitest run", + "bench:lists": "vitest run -c bench/vitest.config.ts", "check": "pnpm typecheck && pnpm test" }, "dependencies": { @@ -111,6 +113,10 @@ "types": "./dist/src/source.d.ts", "default": "./dist/src/source.js" }, + "./queries": { + "types": "./dist/src/entry-query.d.ts", + "default": "./dist/src/entry-query.js" + }, "./fields": { "types": "./dist/src/fields.d.ts", "default": "./dist/src/fields.js" diff --git a/packages/astro-renderer/src/cloudflare-entry.ts b/packages/astro-renderer/src/cloudflare-entry.ts index 023c74f1..5af6d96e 100644 --- a/packages/astro-renderer/src/cloudflare-entry.ts +++ b/packages/astro-renderer/src/cloudflare-entry.ts @@ -10,8 +10,9 @@ * sends after working out what a publish changed; without one, everything is purged. * - `GET /__cloudhal/features` (Bearer `CLOUDHAL_PURGE_TOKEN`) says what this Worker enforces, so * the admin Worker never writes data an older site Worker would mishandle (RFC-0008 §7.7): - * `{ "gates": 1 }` — every public path checks pages' and entries' time gates per request and - * caps the edge cache at the next one. Until it answers so, writers refuse `availability`. + * `{ "gates": 2 }` — every public path checks pages' and entries' time gates per request, and + * entries' derived gates from a collection's `expiry` (RFC-0042 §3), and caps the edge cache at + * the next one. Writers refuse `availability` below 1 and `expiry` below 2. * - Redirects (RFC-0002) are answered by the routes themselves (real 301s, `no-store` here like * every non-200), so a publish that adds or removes one needs no purge of its own. * - Preview responses get every internal link rewritten to keep the signed token, so navigation diff --git a/packages/astro-renderer/src/components/ContentList.astro b/packages/astro-renderer/src/components/ContentList.astro index f3ce4790..a6eb3678 100644 --- a/packages/astro-renderer/src/components/ContentList.astro +++ b/packages/astro-renderer/src/components/ContentList.astro @@ -1,6 +1,13 @@ --- +/** + * A `contentList` section. Without `where`/`orderBy` it draws `entries` by the section's `sort` + * and `limit`, as it always has. With them (RFC-0042 §4) it lists by field: a list `queryEntries` + * made is drawn as it is; the collection's list a source returned (what theme copies from before + * RFC-0042 pass) is queried on that source here, so those themes get field listings on upgrade. + */ import type { ContentEntry, ContentListSection } from "@cloudhal/core-contracts"; import { entryPath } from "@cloudhal/core-ts"; +import { listOrigin, listsByField, queryEntries, sectionQuery } from "../entry-query.ts"; import type { MediaContext } from "../media.ts"; interface Props { @@ -10,15 +17,25 @@ interface Props { hrefFor?: (entry: ContentEntry) => string; } const { section, entries, media, hrefFor = (e) => entryPath(media?.permalinks, e) } = Astro.props; -const sorted = [...entries] - .sort((a, b) => - section.sort === "title" - ? a.title.localeCompare(b.title) - : section.sort === "oldest" - ? (a.publishedAt ?? a.createdAt).localeCompare(b.publishedAt ?? b.createdAt) - : (b.publishedAt ?? b.createdAt).localeCompare(a.publishedAt ?? a.createdAt), - ) - .slice(0, section.limit); +const origin = listsByField(section) ? listOrigin(entries) : undefined; +if (listsByField(section) && origin === undefined) + console.warn( + `cloudhal: section ${section.id} lists by field, but its entries came from elsewhere`, + ); +const sorted = + origin === "queried" + ? entries.slice(0, section.limit) + : origin + ? (await queryEntries(origin.source, sectionQuery(section, origin.locale))).entries + : [...entries] + .sort((a, b) => + section.sort === "title" + ? a.title.localeCompare(b.title) + : section.sort === "oldest" + ? (a.publishedAt ?? a.createdAt).localeCompare(b.publishedAt ?? b.createdAt) + : (b.publishedAt ?? b.createdAt).localeCompare(a.publishedAt ?? a.createdAt), + ) + .slice(0, section.limit); ---
{sorted.length === 0 ?

Nothing published yet.

: ( diff --git a/packages/astro-renderer/src/entry-query.ts b/packages/astro-renderer/src/entry-query.ts new file mode 100644 index 00000000..1b1247c4 --- /dev/null +++ b/packages/astro-renderer/src/entry-query.ts @@ -0,0 +1,227 @@ +/** + * RFC-0042 §4: listings by field for themes. `queryEntries(source, q)` lists a collection's entries + * filtered by its list fields (`where`), ordered by one (`orderBy`), on top of the RFC-0002 term + * filter and the collection's `sort`, paged. Both published sources run the same evaluator + * (`evaluateEntryQuery`) over what they show, with core-ts's `fields/query.ts` deciding each + * condition, so live and snapshot answer alike. Pure, Worker-safe; `now` is the source's clock. + * + * On a published site an invalid query matches nothing and is logged ("a published snapshot must + * keep rendering", like unknown terms in `entriesByFilter`): writers refuse invalid queries, so it + * only happens after a definition changed. + */ +import type { + ContentEntry, + ContentListSection, + FieldCondition, + FieldOrder, + Section, + Site, + Taxonomy, + Term, +} from "@cloudhal/core-contracts"; +import { + compareByField, + matchesFieldQuery, + originalsOf, + parseFieldQuery, + type ResolvedFields, + resolveFields, +} from "@cloudhal/core-ts/fields"; +import { matches } from "@cloudhal/core-ts/filter"; +import type { GraphSource } from "./source.ts"; +import { compile } from "./source-helpers.ts"; + +/** A theme's listing by field (RFC-0042 §4). */ +export interface FieldEntryQuery { + readonly collection: string; + /** Default: the site's. */ + readonly locale?: string | undefined; + readonly where?: readonly FieldCondition[] | undefined; + readonly orderBy?: FieldOrder | undefined; + /** Default: the collection's `sort` (absent: newest). Ties after `orderBy`, then `id`. */ + readonly sort?: "newest" | "oldest" | "title" | undefined; + /** An RFC-0002 term filter, unchanged (`category:news+tag:ai`). */ + readonly filter?: string | undefined; + /** 1–100; default 20. */ + readonly limit?: number | undefined; + readonly offset?: number | undefined; + /** Also unlisted (not hidden) entries past their derived gate: "past events". */ + readonly includeExpired?: boolean | undefined; +} + +export interface FieldEntryResult { + readonly entries: ContentEntry[]; + /** Matches before paging. */ + readonly total: number; +} + +export const QUERY_DEFAULT_LIMIT = 20; +export const QUERY_MAX_LIMIT = 100; + +/** What a source hands the evaluator. */ +export interface EntryQueryInput { + readonly site: Site; + /** The entries this source shows for the query (listed, or reachable with `includeExpired`). */ + readonly candidates: readonly ContentEntry[]; + /** Published entries a translation may read non-localized values from. */ + readonly published: readonly ContentEntry[]; + readonly taxonomies: readonly Taxonomy[]; + readonly terms: readonly Term[]; + readonly query: FieldEntryQuery; + /** Epoch ms; `now` in conditions compares with it. */ + readonly now: number; + /** Resolved values by entry id, reused across queries (a snapshot's are immutable). */ + readonly cache?: Map | undefined; +} + +const published = (e: ContentEntry) => e.publishedAt ?? e.createdAt; + +/** The listing order of RFC-0040's `sort`, as `ContentList` has always drawn it. */ +function bySort(sort: FieldEntryQuery["sort"]): (a: ContentEntry, b: ContentEntry) => number { + if (sort === "title") return (a, b) => a.title.localeCompare(b.title); + if (sort === "oldest") return (a, b) => published(a).localeCompare(published(b)); + return (a, b) => published(b).localeCompare(published(a)); +} + +const byId = (a: ContentEntry, b: ContentEntry) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + +const clampInt = (n: number | undefined, min: number, max: number, fallback: number) => + n === undefined || !Number.isFinite(n) ? fallback : Math.min(max, Math.max(min, Math.trunc(n))); + +/** The term filter as a predicate; an invalid one matches nothing (logged). */ +function termPredicate(input: EntryQueryInput): ((e: ContentEntry) => boolean) | undefined { + const { filter } = input.query; + if (!filter) return undefined; + try { + const f = compile(filter, [...input.taxonomies], [...input.terms]); + return (e) => matches(f, e); + } catch (err) { + console.warn(`cloudhal: query filter "${filter}" is invalid: ${String(err)}`); + return () => false; + } +} + +/** Runs a query over what a source shows. Pure. */ +export function evaluateEntryQuery(input: EntryQueryInput): FieldEntryResult { + const { site, query: q } = input; + const limit = clampInt(q.limit, 1, QUERY_MAX_LIMIT, QUERY_DEFAULT_LIMIT); + const offset = clampInt(q.offset, 0, Number.MAX_SAFE_INTEGER, 0); + const sort = bySort(q.sort ?? site.collections?.[q.collection]?.sort ?? "newest"); + const byTerms = termPredicate(input); + let rows = input.candidates.filter((e) => !byTerms || byTerms(e)); + if (q.where?.length || q.orderBy) { + const parsed = parseFieldQuery(site, q.collection, { where: q.where, orderBy: q.orderBy }); + if (!parsed.ok) { + const shown = parsed.issues.map((i) => `${i.path}: ${i.message}`).join("; "); + console.warn(`cloudhal: query on ${q.collection} matches nothing: ${shown}`); + return { entries: [], total: 0 }; + } + const originalOf = originalsOf(input.published); + const cache = input.cache ?? new Map(); + const values = (e: ContentEntry): ResolvedFields => { + const known = cache.get(e.id); + if (known) return known; + const v = resolveFields(site, e, originalOf(e)); + cache.set(e.id, v); + return v; + }; + rows = rows.filter((e) => matchesFieldQuery(values(e), parsed.query, input.now)); + rows.sort( + (a, b) => compareByField(values(a), values(b), parsed.query) || sort(a, b) || byId(a, b), + ); + } else rows = [...rows].sort((a, b) => sort(a, b) || byId(a, b)); + return { entries: rows.slice(offset, offset + limit), total: rows.length }; +} + +/** What a list handed to a theme came from: its source and locale, or a query already run. */ +type ListOrigin = { readonly source: GraphSource; readonly locale: string } | "queried"; +const origins = new WeakMap(); + +/** + * Marks a list a source returned (`entries`, `entriesByFilter`) so `ContentList` can apply a + * section's `where`/`orderBy` even for theme copies from before RFC-0042, which pass the + * collection's list as it is. Returns the list. + */ +export function rememberList(list: T, origin: ListOrigin): T { + origins.set(list, origin); + return list; +} + +/** Where a list came from, if a source or `queryEntries` made it. */ +export function listOrigin(list: readonly ContentEntry[]): ListOrigin | undefined { + return origins.get(list); +} + +/** + * Lists `q.collection` by its list fields on any source: the source's own `queryEntries`, or, on + * sources from before RFC-0042, the same evaluator over `entries()` (no `includeExpired` there). + */ +export async function queryEntries( + source: GraphSource, + q: FieldEntryQuery, +): Promise { + const result = source.queryEntries + ? await source.queryEntries(q) + : await (async () => { + const site = await source.site(); + const candidates = await source.entries(q.collection, q.locale); + const [taxonomies, terms] = q.filter + ? await Promise.all([source.taxonomies(), source.vocabulary()]) + : [[], []]; + return evaluateEntryQuery({ + site, + candidates, + published: candidates, + taxonomies, + terms, + query: q, + now: Date.now(), + }); + })(); + rememberList(result.entries, "queried"); + return result; +} + +/** The query a `contentList` section with `where`/`orderBy` runs (its own `limit`, at most 100). */ +export function sectionQuery(section: ContentListSection, locale?: string): FieldEntryQuery { + return { + collection: section.collection, + locale, + sort: section.sort, + limit: Math.min(section.limit, QUERY_MAX_LIMIT), + ...(section.where ? { where: section.where } : {}), + ...(section.orderBy ? { orderBy: section.orderBy } : {}), + ...(section.filter ? { filter: section.filter } : {}), + }; +} + +/** True when a section lists by field (RFC-0042). */ +export const listsByField = (section: ContentListSection): boolean => + section.where !== undefined || section.orderBy !== undefined; + +/** + * The entries a page's `contentList` sections draw, keyed as `Sections` reads them: by collection + * (the collection's newest listed entries), by section id for a section with a term `filter` + * (RFC-0002), and by section id for a section with `where`/`orderBy` (RFC-0042, through + * `queryEntries`). An invalid filter lists nothing and is logged. + */ +export async function contentListEntries( + source: GraphSource, + sections: readonly Section[], + locale?: string, +): Promise> { + const lists = sections.filter((s): s is ContentListSection => s.kind === "contentList"); + const out: Record = {}; + for (const c of new Set(lists.map((s) => s.collection))) out[c] = await source.entries(c, locale); + for (const s of lists) { + if (listsByField(s)) out[s.id] = (await queryEntries(source, sectionQuery(s, locale))).entries; + else if (s.filter) + out[s.id] = await source + .entriesByFilter(`collection:${s.collection}+${s.filter}`, locale ? { locale } : {}) + .catch((err: unknown) => { + console.warn(`cloudhal: section ${s.id} has an invalid filter: ${String(err)}`); + return []; + }); + } + return out; +} diff --git a/packages/astro-renderer/src/entry-views.ts b/packages/astro-renderer/src/entry-views.ts new file mode 100644 index 00000000..7b94df55 --- /dev/null +++ b/packages/astro-renderer/src/entry-views.ts @@ -0,0 +1,73 @@ +/** + * RFC-0042 §3 in the published sources: an entry's derived gate (its collection's `expiry`) next to + * its own `availability` (RFC-0008). `unlist` keeps the entry's page (and comments, menus, + * alternates) but leaves it out of every listing, feed, the sitemap and search; `hide` answers 404 + * like an expired `availability`. The gate index gets every derived instant and, for `now` + * conditions on list fields (§4), every value instant, so the edge cache expires at each one. + * Pure helpers shared by the live and snapshot sources; previews never call them. + */ +import type { ContentEntry, Page, Site } from "@cloudhal/core-contracts"; +import { gateInstants } from "@cloudhal/core-ts/availability"; +import { + type DerivedGate, + derivedGates, + entryVisibility, + listFieldInstants, +} from "@cloudhal/core-ts/fields"; +import type { GraphSource } from "./source.ts"; + +/** What the public sees of a set of entries at one instant. */ +export interface EntrySplit { + /** Shown entries: every listing, feed, sitemap and search. */ + readonly listed: ContentEntry[]; + /** Shown and unlisted entries: everything with a public URL (routes, comments, menus). */ + readonly reachable: ContentEntry[]; +} + +/** + * Splits `entries` at `now` by status, `availability` and their derived gates (`derivedGates` over + * every published entry a translation may read from; a snapshot computes it once). The snapshot + * and the live source pass the same sets, so they agree. + */ +export function splitEntries( + entries: readonly ContentEntry[], + now: number, + derived: ReadonlyMap, +): EntrySplit { + const listed: ContentEntry[] = []; + const reachable: ContentEntry[] = []; + for (const e of entries) { + const v = entryVisibility(e, derived.get(e.id), now); + if (v === "hidden") continue; + reachable.push(e); + if (v === "shown") listed.push(e); + } + return { listed, reachable }; +} + +/** + * The gate index of published nodes (RFC-0008 §7.4, RFC-0042 §3.4 and §4): every `from`/`until`, + * every derived gate, and every instant a `now` condition on a list field may flip at; sorted, + * unique. Empty for a site that schedules nothing and has no such settings. + */ +export function gateIndex( + site: Site, + pages: readonly Page[], + entries: readonly ContentEntry[], + gates: ReadonlyMap = derivedGates(site, entries), +): number[] { + const own = gateInstants([...pages, ...entries]); + const derived = [...gates.values()].map((g) => g.at); + const lists = listFieldInstants(site, entries); + if (derived.length === 0 && lists.length === 0) return own; + return [...new Set([...own, ...derived, ...lists])].sort((a, b) => a - b); +} + +/** + * Every entry of a locale with a public URL (unlisted ones included), newest first: what routes, + * static builds and language alternates need. Sources from before RFC-0042 list all of them in + * `allEntries`. + */ +export function routableEntries(source: GraphSource, locale?: string): Promise { + return source.routableEntries ? source.routableEntries(locale) : source.allEntries(locale); +} diff --git a/packages/astro-renderer/src/index.ts b/packages/astro-renderer/src/index.ts index f371c690..b52c15d1 100644 --- a/packages/astro-renderer/src/index.ts +++ b/packages/astro-renderer/src/index.ts @@ -8,6 +8,7 @@ */ export * from "./archive.ts"; export * from "./cache-tags.ts"; +export * from "./entry-query.ts"; export * from "./fields.ts"; export * from "./gates.ts"; export { type CloudhalRendererConfig, cloudhal } from "./integration.ts"; @@ -16,4 +17,9 @@ export * from "./navigation.ts"; export * from "./preview.ts"; export { getSource, resetSource } from "./runtime.ts"; export * from "./source.ts"; +export { + entryStructuredData, + type StructuredDataOptions, + withStructuredData, +} from "./structured-data.ts"; export * from "./taxonomy.ts"; diff --git a/packages/astro-renderer/src/live-source.ts b/packages/astro-renderer/src/live-source.ts new file mode 100644 index 00000000..7ffcb1ae --- /dev/null +++ b/packages/astro-renderer/src/live-source.ts @@ -0,0 +1,318 @@ +import type { + Asset, + ContentEntry, + Menu, + Page, + Redirect, + Site, + Taxonomy, + Term, +} from "@cloudhal/core-contracts"; +import type { RedirectMatch, SearchResults, SiteGraphStore } from "@cloudhal/core-ts"; +import { searchDocuments, searchRows } from "@cloudhal/core-ts"; +import { isAvailable, nextInstant } from "@cloudhal/core-ts/availability"; +import { derivedGates, hasExpiryRules } from "@cloudhal/core-ts/fields"; +import { matches } from "@cloudhal/core-ts/filter"; +import { + evaluateEntryQuery, + type FieldEntryQuery, + type FieldEntryResult, + rememberList, +} from "./entry-query.ts"; +import { type EntrySplit, gateIndex, splitEntries } from "./entry-views.ts"; +import { type EntryFieldsOptions, entryFields, type RenderedFields } from "./fields.ts"; +import { type Navigation, type NavigationOptions, navigationFor } from "./navigation.ts"; +import type { GraphSource, SourceSearchOptions } from "./source.ts"; +import { byNewest, byPosition, compile, shown, termFilter, visible } from "./source-helpers.ts"; + +/** + * Reads the current nodes on every call. Drafts are included so authors can preview them in dev, + * and so are gated nodes (a preview shows embargoed and expired content, RFC-0008 §7.3). Without + * drafts it serves what the public would see: published nodes whose gates are open at `now`. + */ +export class LiveSource implements GraphSource { + readonly mode = "live"; + readonly #store: SiteGraphStore; + readonly #siteId: string; + readonly #includeDrafts: boolean; + readonly #now: () => number; + + constructor( + store: SiteGraphStore, + siteId: string, + options: { includeDrafts?: boolean; now?: () => number } = {}, + ) { + this.#store = store; + this.#siteId = siteId; + this.#includeDrafts = options.includeDrafts ?? true; + this.#now = options.now ?? Date.now; + } + + /** Drafts and gated nodes, or what the public sees now. */ + #visible(nodes: T[]): T[] { + if (this.#includeDrafts) return visible(nodes, true); + const now = this.#now(); + return nodes.filter((n) => isAvailable(n, now)); + } + + /** + * Entries as the public sees them now, derived gates included (RFC-0042 §3): `listed` for lists, + * `reachable` for routes. With drafts both are everything not archived (previews ignore gates). + */ + async #split( + entries: ContentEntry[], + /** Every entry of the site, when the caller already read them (the pool for originals). */ + all?: ContentEntry[], + ): Promise { + if (this.#includeDrafts) { + const all = visible(entries, true); + return { listed: all, reachable: all, published: all }; + } + const site = await this.site(); + const isPublished = (e: ContentEntry) => e.status === "published"; + // Translations read their original's date, which may sit in another locale or collection. + const pool = !hasExpiryRules(site) + ? entries + : (all ?? (await this.#store.listContentEntries(this.#siteId))); + const published = pool.filter(isPublished); + const derived = derivedGates(site, published); + return { ...splitEntries(entries.filter(isPublished), this.#now(), derived), published }; + } + + async nextGate(): Promise { + if (this.#includeDrafts) return undefined; + const [site, pages, entries] = await Promise.all([ + this.site(), + this.#store.listPages(this.#siteId), + this.#store.listContentEntries(this.#siteId), + ]); + const isPublished = (n: Page | ContentEntry) => n.status === "published"; + const gates = gateIndex(site, pages.filter(isPublished), entries.filter(isPublished)); + return nextInstant(gates, this.#now()); + } + + async site(): Promise { + const site = await this.#store.getSite(this.#siteId); + if (!site) throw new Error(`site ${this.#siteId} not found`); + return site; + } + + async pages(): Promise { + const site = await this.site(); + const pages = this.#visible(await this.#store.listPages(this.#siteId)); + const order = new Map(site.pageIds.map((id, i) => [id, i])); + return pages.sort((a, b) => (order.get(a.id) ?? 1e9) - (order.get(b.id) ?? 1e9)); + } + + async pageByPath(path: string, locale?: string): Promise { + const loc = locale ?? (await this.site()).defaultLocale; + return (await this.pages()).find((p) => p.path === path && p.locale === loc); + } + + async entries(collection: string, locale?: string): Promise { + const loc = locale ?? (await this.site()).defaultLocale; + const { listed } = await this.#split( + await this.#store.listContentEntries(this.#siteId, collection), + ); + const out = listed.filter((e) => e.locale === loc).sort(byNewest); + return rememberList(out, { source: this, locale: loc }); + } + + async entryBySlug( + collection: string, + slug: string, + locale?: string, + ): Promise { + const loc = locale ?? (await this.site()).defaultLocale; + const { reachable } = await this.#split( + await this.#store.listContentEntries(this.#siteId, collection), + ); + return reachable.find((e) => e.slug === slug && e.locale === loc); + } + + async allEntries(locale?: string): Promise { + const loc = locale ?? (await this.site()).defaultLocale; + const all = await this.#store.listContentEntries(this.#siteId); + const { listed } = await this.#split(all, all); + return listed.filter((e) => e.locale === loc).sort(byNewest); + } + + /** Every entry of a locale with a public URL, unlisted ones included (RFC-0042 §3). */ + async routableEntries(locale?: string): Promise { + const loc = locale ?? (await this.site()).defaultLocale; + const all = await this.#store.listContentEntries(this.#siteId); + const { reachable } = await this.#split(all, all); + return reachable.filter((e) => e.locale === loc).sort(byNewest); + } + + /** RFC-0042 §4: a collection by its list fields (`queryEntries`). */ + async queryEntries(q: FieldEntryQuery): Promise { + const site = await this.site(); + const loc = q.locale ?? site.defaultLocale; + // With expiry rules every entry is read once (originals may sit elsewhere); else the collection. + const all = hasExpiryRules(site) + ? await this.#store.listContentEntries(this.#siteId) + : undefined; + const mine = all + ? all.filter((e) => e.collection === q.collection) + : await this.#store.listContentEntries(this.#siteId, q.collection); + const [split, taxonomies, terms] = await Promise.all([ + this.#split(mine, all), + this.#store.listTaxonomies(this.#siteId), + this.#store.listTerms(this.#siteId), + ]); + const shownHere = q.includeExpired ? split.reachable : split.listed; + return evaluateEntryQuery({ + site, + candidates: shownHere.filter((e) => e.locale === loc), + published: split.published, + taxonomies, + terms, + query: q, + now: this.#now(), + }); + } + + async search(query: string, options: SourceSearchOptions = {}): Promise { + const site = await this.site(); + const locale = options.locale ?? site.defaultLocale; + // What this source shows decided visibility and gates already (drafts included). + const [entries, pages, taxonomies, terms] = await Promise.all([ + this.allEntries(locale), + this.pages(), + this.taxonomies(), + this.vocabulary(), + ]); + const rows = searchDocuments({ + locales: [locale], + defaultLocale: site.defaultLocale, + site, + entries, + pages: pages.filter((p) => p.locale === locale), + taxonomies, + terms, + drafts: true, + }).map((r) => ({ ...r, gateFrom: undefined, gateUntil: undefined })); + return searchRows(rows, query, { ...options, locale, now: this.#now() }); + } + + async entryById(id: string): Promise { + const entry = await this.#store.getContentEntry(id); + if (!entry || entry.siteId !== this.#siteId) return undefined; + return (await this.#split([entry])).reachable.length > 0 ? entry : undefined; + } + + entryFields(entry: ContentEntry, options?: EntryFieldsOptions): Promise { + return entryFields(this, entry, options); + } + + taxonomies(): Promise { + return this.#store.listTaxonomies(this.#siteId); + } + + async terms(key: string): Promise { + const loc = (await this.site()).defaultLocale; + return (await this.#store.listTerms(this.#siteId, { taxonomy: key })) + .filter((t) => shown(t, this.#includeDrafts)) + .sort(byPosition(loc)); + } + + async termBySlug(key: string, slug: string): Promise { + return (await this.terms(key)).find((t) => t.slug === slug); + } + + vocabulary(key?: string): Promise { + return this.#store.listTerms(this.#siteId, key === undefined ? {} : { taxonomy: key }); + } + + async entriesByFilter( + filter: string, + options: { locale?: string; limit?: number } = {}, + ): Promise { + const loc = options.locale ?? (await this.site()).defaultLocale; + const [taxonomies, terms, entries] = await Promise.all([ + this.#store.listTaxonomies(this.#siteId), + this.#store.listTerms(this.#siteId), + this.#store.listContentEntries(this.#siteId, { locale: loc }), + ]); + const f = compile(filter, taxonomies, terms); + const out = (await this.#split(entries)).listed.filter((e) => matches(f, e)).sort(byNewest); + return rememberList(options.limit === undefined ? out : out.slice(0, options.limit), { + source: this, + locale: loc, + }); + } + + entriesByTerm( + key: string, + slug: string, + options: { locale?: string; limit?: number; descendants?: boolean } = {}, + ): Promise { + return this.entriesByFilter(termFilter(key, slug, options.descendants === true), options); + } + + redirectFor(path: string, locale?: string): Promise { + return this.#store.redirectFor(this.#siteId, path, locale); + } + + redirects(): Promise { + return this.#store.listRedirects(this.#siteId); + } + + menu(key: string): Promise { + return this.#store.getMenuByKey(this.#siteId, key); + } + + async navigation( + key: string, + locale?: string, + options: NavigationOptions = {}, + ): Promise { + const menu = await this.menu(key); + if (!menu) return undefined; + const site = await this.site(); + const [pages, entries, taxonomies, terms] = await Promise.all([ + this.#store.listPages(this.#siteId), + this.#store.listContentEntries(this.#siteId), + this.#store.listTaxonomies(this.#siteId), + this.#store.listTerms(this.#siteId), + ]); + return navigationFor( + menu, + { + site, + // drafts: everything but archived (the lookup decides); public: what is available now + pages: this.#includeDrafts ? pages : this.#visible(pages), + entries: this.#includeDrafts ? entries : (await this.#split(entries, entries)).reachable, + taxonomies, + terms, + includeDrafts: this.#includeDrafts, + ...(options.collectionIndex ? { collectionIndex: options.collectionIndex } : {}), + }, + locale ?? site.defaultLocale, + ); + } + + async assets(): Promise { + const out: Asset[] = []; + let cursor: string | undefined; + do { + const page = await this.#store.listAssets(this.#siteId, { + limit: 200, + ...(cursor ? { cursor } : {}), + }); + out.push(...page.assets); + cursor = page.nextCursor; + } while (cursor); + return out; + } + + async asset(id: string): Promise { + const a = await this.#store.getAsset(id); + return a?.siteId === this.#siteId ? a : undefined; + } + + assetByPrefix(prefix16: string): Promise { + return this.#store.getAssetByPrefix(this.#siteId, prefix16); + } +} diff --git a/packages/astro-renderer/src/locales.ts b/packages/astro-renderer/src/locales.ts index a7923c10..1fe942ae 100644 --- a/packages/astro-renderer/src/locales.ts +++ b/packages/astro-renderer/src/locales.ts @@ -9,6 +9,7 @@ import type { ContentEntry, Page, Site, Taxonomy, Term } from "@cloudhal/core-contracts"; import { entryPath, type PermalinkSite, termSlug } from "@cloudhal/core-ts"; import { localeRouting, localeUrl, localizedPath } from "@cloudhal/core-ts/locale-paths"; +import { routableEntries } from "./entry-views.ts"; import type { HeadMeta } from "./seo.ts"; import type { GraphSource } from "./source.ts"; @@ -110,7 +111,7 @@ async function groupMembers( ): Promise { const inGroup = (n: Localized) => n.id === group || n.translation?.group === group; if (pages) return (await source.pages()).filter(inGroup); - const all = await Promise.all(site.locales.map((l) => source.allEntries(l))); + const all = await Promise.all(site.locales.map((l) => routableEntries(source, l))); return all.flat().filter(inGroup); } @@ -195,8 +196,8 @@ export async function linkLocalizer( const home = (p: string) => (own ? localeUrl(site, def, p, origin) : localizedPath(site, def, p)); const [pages, entries, translated, taxonomies, terms] = await Promise.all([ source.pages(), - source.allEntries(def), - source.allEntries(locale), + routableEntries(source, def), + routableEntries(source, locale), source.taxonomies(), source.vocabulary(), ]); diff --git a/packages/astro-renderer/src/preview.ts b/packages/astro-renderer/src/preview.ts index 81a307c6..86465a0b 100644 --- a/packages/astro-renderer/src/preview.ts +++ b/packages/astro-renderer/src/preview.ts @@ -8,6 +8,7 @@ import type { Term, } from "@cloudhal/core-contracts"; import { entryPath } from "@cloudhal/core-ts"; +import { derivedGate } from "@cloudhal/core-ts/fields"; import { type ArchiveState, type ArchiveView, @@ -17,7 +18,7 @@ import { viewPagePath, } from "./archive.ts"; import { renderComponentSection } from "./blocks.ts"; -import { gateNotice } from "./gates.ts"; +import { formatGateTime, gateNotice } from "./gates.ts"; import { renderBody, renderEntryArticle, renderMarkdown, safeHref, safeSrc } from "./markdown.ts"; import { assetCaption, imageAttributes, imgHtml, type MediaContext } from "./media.ts"; import { archiveHead, siteOrigin } from "./seo.ts"; @@ -161,16 +162,25 @@ export interface PreviewDocument { /** Words of the preview's gate notice (RFC-0008 §7.3), as the blog starter says them. */ const GATE_WORDS: Record< string, - { embargoed: (when: string) => string; expired: (when: string) => string } + { + embargoed: (when: string) => string; + expired: (when: string) => string; + /** RFC-0042 §3.3: past the collection's `unlist` expiry. */ + ended: (when: string) => string; + } > = { en: { embargoed: (when) => `Embargoed until ${when}. Only previews show it until then.`, expired: (when) => `Expired on ${when}. Only previews show it now.`, + ended: (when) => + `Ended on ${when} (collection rule). It keeps its address but is left out of lists, feeds and search.`, }, tr: { embargoed: (when) => `${when} tarihine kadar ambargolu. O zamana kadar yalnızca önizlemede görünür.`, expired: (when) => `${when} tarihinde yayından kalktı. Artık yalnızca önizlemede görünür.`, + ended: (when) => + `${when} tarihinde sona erdi (koleksiyon kuralı). Adresi kalır, ama listelerde, akışlarda ve aramada yer almaz.`, }, }; @@ -186,10 +196,36 @@ export function gateNoticeHtml( ): string { const notice = gateNotice(node, site, locale, now); if (!notice) return ""; + return noticeHtml(notice.state, notice.at, notice.when, locale); +} + +function noticeHtml( + state: "embargoed" | "expired" | "ended", + at: string, + when: string, + locale: string, +) { const words = GATE_WORDS[locale.split("-")[0] ?? locale] ?? (GATE_WORDS.en as NonNullable); - return `

${esc(words[notice.state](notice.when))}

`; + return `

${esc(words[state](when))}

`; +} + +/** + * The notice for an entry past its collection's expiry at `now` (RFC-0042 §3.3): "Ended on …" + * for `unlist`, "Expired on …" for `hide`; empty before it, or when its own gates already speak. + */ +export function derivedGateNoticeHtml( + site: Site, + entry: ContentEntry, + now: number, + locale = site.defaultLocale, +): string { + const gate = derivedGate(site, entry); + if (!gate || now < gate.at) return ""; + const at = new Date(gate.at).toISOString(); + const state = gate.action === "hide" ? "expired" : "ended"; + return noticeHtml(state, at, formatGateTime(at, site, locale), locale); } /** @@ -233,7 +269,7 @@ export function renderEntryPreview( now: number = Date.now(), ): PreviewDocument { const body = - gateNoticeHtml(site, entry, now) + + (gateNoticeHtml(site, entry, now) || derivedGateNoticeHtml(site, entry, now)) + renderEntryArticle(entry, site.defaultLocale, previewMedia(site, media)); return { title: entry.title, diff --git a/packages/astro-renderer/src/routing.ts b/packages/astro-renderer/src/routing.ts index dd6bfc3f..a8c28f6e 100644 --- a/packages/astro-renderer/src/routing.ts +++ b/packages/astro-renderer/src/routing.ts @@ -18,6 +18,7 @@ import { parseArchivePath, termForSlug, } from "./archive.ts"; +import { routableEntries } from "./entry-views.ts"; import { PREVIEW_PARAM } from "./resolver.ts"; import { type SearchView, searchRequest } from "./search.ts"; import type { GraphSource } from "./source.ts"; @@ -71,7 +72,8 @@ export async function routablePaths(source: GraphSource, locale?: string): Promi const loc = locale ?? site.defaultLocale; const pagePaths = pages.filter((p) => p.locale === loc).map((p) => p.path); const taken = new Set(pagePaths); - const entryPaths = (await source.allEntries(loc)) + // RFC-0042 §3: an unlisted entry keeps its URL (static builds write it), only lists drop it. + const entryPaths = (await routableEntries(source, loc)) .map((e) => entryPath(site, e)) .filter((p) => !taken.has(p)); return [...pagePaths, ...entryPaths]; diff --git a/packages/astro-renderer/src/seo.ts b/packages/astro-renderer/src/seo.ts index 91a7765d..e372045b 100644 --- a/packages/astro-renderer/src/seo.ts +++ b/packages/astro-renderer/src/seo.ts @@ -10,6 +10,13 @@ import { inlineMarkdownText, markdownText, safeSrc } from "./markdown.ts"; import { assetAlt, imageObject, type MediaContext, socialImageUrl } from "./media.ts"; import { type EntryTaxonomy, entryTaxonomy, type ShownTerm } from "./taxonomy.ts"; +/** RFC-0042 §2: a collection's JSON-LD preset in place of the generic `BlogPosting`. */ +export { + entryStructuredData, + type StructuredDataOptions, + withStructuredData, +} from "./structured-data.ts"; + export interface HeadMeta { /** ``. */ title: string; diff --git a/packages/astro-renderer/src/source.ts b/packages/astro-renderer/src/source.ts index 939c8f61..a18d694f 100644 --- a/packages/astro-renderer/src/source.ts +++ b/packages/astro-renderer/src/source.ts @@ -11,20 +11,24 @@ import type { Term, } from "@cloudhal/core-contracts"; import type { RedirectMatch, SearchKind, SearchResults, SiteGraphStore } from "@cloudhal/core-ts"; -import { matchRedirect, redirectLocale, searchDocuments, searchRows } from "@cloudhal/core-ts"; -import { - gateInstants, - hasGates, - inheritedGate, - isAvailable, - nextInstant, -} from "@cloudhal/core-ts/availability"; +import { matchRedirect, redirectLocale } from "@cloudhal/core-ts"; +import { hasGates, inheritedGate, isAvailable, nextInstant } from "@cloudhal/core-ts/availability"; +import { type DerivedGate, derivedGates, type ResolvedFields } from "@cloudhal/core-ts/fields"; import { matches } from "@cloudhal/core-ts/filter"; import { assetRefsOf } from "@cloudhal/core-ts/media"; +import { + evaluateEntryQuery, + type FieldEntryQuery, + type FieldEntryResult, + rememberList, +} from "./entry-query.ts"; +import { gateIndex, splitEntries } from "./entry-views.ts"; import { type EntryFieldsOptions, entryFields, type RenderedFields } from "./fields.ts"; import { type Navigation, type NavigationOptions, navigationFor } from "./navigation.ts"; import { byNewest, byPosition, compile, shown, termFilter, visible } from "./source-helpers.ts"; +export { LiveSource } from "./live-source.ts"; + /** * What a renderer needs from a site, independent of whether it reads live nodes (dev) or a * published snapshot (build/production). @@ -34,10 +38,22 @@ export interface GraphSource { site(): Promise<Site>; pages(): Promise<Page[]>; pageByPath(path: string, locale?: string): Promise<Page | undefined>; + /** Listed entries of a collection, newest first (RFC-0042: unlisted ones left out). */ entries(collection: string, locale?: string): Promise<ContentEntry[]>; + /** An entry with a public URL by slug: listed, or unlisted past its collection's expiry. */ entryBySlug(collection: string, slug: string, locale?: string): Promise<ContentEntry | undefined>; - /** Every visible entry of a locale, newest first, across collections (feeds, sitemaps). */ + /** Every listed entry of a locale, newest first, across collections (feeds, sitemaps). */ allEntries(locale?: string): Promise<ContentEntry[]>; + /** + * RFC-0042 §3: every entry of a locale with a public URL, newest first — `allEntries` plus the + * unlisted ones (routes, static builds, language alternates). Optional for older sources. + */ + routableEntries?(locale?: string): Promise<ContentEntry[]>; + /** + * RFC-0042 §4: a collection filtered and ordered by its list fields, paged (`queryEntries`). + * Optional for older sources; the standalone `queryEntries(source, q)` works with any. + */ + queryEntries?(q: FieldEntryQuery): Promise<FieldEntryResult>; /** * A visible entry by id, any locale (reader comments name the entry by id, RFC-0007 §6.2). * Optional for sources that predate comments. @@ -127,234 +143,20 @@ export interface SourceSearchOptions { kinds?: readonly SearchKind[]; } -/** - * Reads the current nodes on every call. Drafts are included so authors can preview them in dev, - * and so are gated nodes (a preview shows embargoed and expired content, RFC-0008 §7.3). Without - * drafts it serves what the public would see: published nodes whose gates are open at `now`. - */ -export class LiveSource implements GraphSource { - readonly mode = "live"; - readonly #store: SiteGraphStore; - readonly #siteId: string; - readonly #includeDrafts: boolean; - readonly #now: () => number; - - constructor( - store: SiteGraphStore, - siteId: string, - options: { includeDrafts?: boolean; now?: () => number } = {}, - ) { - this.#store = store; - this.#siteId = siteId; - this.#includeDrafts = options.includeDrafts ?? true; - this.#now = options.now ?? Date.now; - } - - /** Drafts and gated nodes, or what the public sees now. */ - #visible<T extends Page | ContentEntry>(nodes: T[]): T[] { - if (this.#includeDrafts) return visible(nodes, true); - const now = this.#now(); - return nodes.filter((n) => isAvailable(n, now)); - } - - async nextGate(): Promise<number | undefined> { - if (this.#includeDrafts) return undefined; - const [pages, entries] = await Promise.all([ - this.#store.listPages(this.#siteId), - this.#store.listContentEntries(this.#siteId), - ]); - const published = [...pages, ...entries].filter((n) => n.status === "published"); - return nextInstant(gateInstants(published), this.#now()); - } - - async site(): Promise<Site> { - const site = await this.#store.getSite(this.#siteId); - if (!site) throw new Error(`site ${this.#siteId} not found`); - return site; - } - - async pages(): Promise<Page[]> { - const site = await this.site(); - const pages = this.#visible(await this.#store.listPages(this.#siteId)); - const order = new Map(site.pageIds.map((id, i) => [id, i])); - return pages.sort((a, b) => (order.get(a.id) ?? 1e9) - (order.get(b.id) ?? 1e9)); - } - - async pageByPath(path: string, locale?: string): Promise<Page | undefined> { - const loc = locale ?? (await this.site()).defaultLocale; - return (await this.pages()).find((p) => p.path === path && p.locale === loc); - } - - async entries(collection: string, locale?: string): Promise<ContentEntry[]> { - const loc = locale ?? (await this.site()).defaultLocale; - const all = this.#visible(await this.#store.listContentEntries(this.#siteId, collection)); - return all.filter((e) => e.locale === loc).sort(byNewest); - } - - async entryBySlug( - collection: string, - slug: string, - locale?: string, - ): Promise<ContentEntry | undefined> { - return (await this.entries(collection, locale)).find((e) => e.slug === slug); - } - async allEntries(locale?: string): Promise<ContentEntry[]> { - const loc = locale ?? (await this.site()).defaultLocale; - return this.#visible(await this.#store.listContentEntries(this.#siteId)) - .filter((e) => e.locale === loc) - .sort(byNewest); - } - - async search(query: string, options: SourceSearchOptions = {}): Promise<SearchResults> { - const site = await this.site(); - const locale = options.locale ?? site.defaultLocale; - // What this source shows decided visibility and gates already (drafts included). - const [entries, pages, taxonomies, terms] = await Promise.all([ - this.allEntries(locale), - this.pages(), - this.taxonomies(), - this.vocabulary(), - ]); - const rows = searchDocuments({ - locales: [locale], - defaultLocale: site.defaultLocale, - site, - entries, - pages: pages.filter((p) => p.locale === locale), - taxonomies, - terms, - drafts: true, - }).map((r) => ({ ...r, gateFrom: undefined, gateUntil: undefined })); - return searchRows(rows, query, { ...options, locale, now: this.#now() }); - } - - async entryById(id: string): Promise<ContentEntry | undefined> { - const entry = await this.#store.getContentEntry(id); - return entry && entry.siteId === this.#siteId && this.#visible([entry]).length - ? entry - : undefined; - } - - entryFields(entry: ContentEntry, options?: EntryFieldsOptions): Promise<RenderedFields> { - return entryFields(this, entry, options); - } - - taxonomies(): Promise<Taxonomy[]> { - return this.#store.listTaxonomies(this.#siteId); - } - - async terms(key: string): Promise<Term[]> { - const loc = (await this.site()).defaultLocale; - return (await this.#store.listTerms(this.#siteId, { taxonomy: key })) - .filter((t) => shown(t, this.#includeDrafts)) - .sort(byPosition(loc)); - } - - async termBySlug(key: string, slug: string): Promise<Term | undefined> { - return (await this.terms(key)).find((t) => t.slug === slug); - } - - vocabulary(key?: string): Promise<Term[]> { - return this.#store.listTerms(this.#siteId, key === undefined ? {} : { taxonomy: key }); - } - - async entriesByFilter( - filter: string, - options: { locale?: string; limit?: number } = {}, - ): Promise<ContentEntry[]> { - const loc = options.locale ?? (await this.site()).defaultLocale; - const [taxonomies, terms, entries] = await Promise.all([ - this.#store.listTaxonomies(this.#siteId), - this.#store.listTerms(this.#siteId), - this.#store.listContentEntries(this.#siteId, { locale: loc }), - ]); - const f = compile(filter, taxonomies, terms); - const out = this.#visible(entries) - .filter((e) => matches(f, e)) - .sort(byNewest); - return options.limit === undefined ? out : out.slice(0, options.limit); - } - - entriesByTerm( - key: string, - slug: string, - options: { locale?: string; limit?: number; descendants?: boolean } = {}, - ): Promise<ContentEntry[]> { - return this.entriesByFilter(termFilter(key, slug, options.descendants === true), options); - } - - redirectFor(path: string, locale?: string): Promise<RedirectMatch | undefined> { - return this.#store.redirectFor(this.#siteId, path, locale); - } - - redirects(): Promise<Redirect[]> { - return this.#store.listRedirects(this.#siteId); - } - - menu(key: string): Promise<Menu | undefined> { - return this.#store.getMenuByKey(this.#siteId, key); - } - - async navigation( - key: string, - locale?: string, - options: NavigationOptions = {}, - ): Promise<Navigation | undefined> { - const menu = await this.menu(key); - if (!menu) return undefined; - const site = await this.site(); - const [pages, entries, taxonomies, terms] = await Promise.all([ - this.#store.listPages(this.#siteId), - this.#store.listContentEntries(this.#siteId), - this.#store.listTaxonomies(this.#siteId), - this.#store.listTerms(this.#siteId), - ]); - return navigationFor( - menu, - { - site, - // drafts: everything but archived (the lookup decides); public: what is available now - pages: this.#includeDrafts ? pages : this.#visible(pages), - entries: this.#includeDrafts ? entries : this.#visible(entries), - taxonomies, - terms, - includeDrafts: this.#includeDrafts, - ...(options.collectionIndex ? { collectionIndex: options.collectionIndex } : {}), - }, - locale ?? site.defaultLocale, - ); - } - - async assets(): Promise<Asset[]> { - const out: Asset[] = []; - let cursor: string | undefined; - do { - const page = await this.#store.listAssets(this.#siteId, { - limit: 200, - ...(cursor ? { cursor } : {}), - }); - out.push(...page.assets); - cursor = page.nextCursor; - } while (cursor); - return out; - } - - async asset(id: string): Promise<Asset | undefined> { - const a = await this.#store.getAsset(id); - return a?.siteId === this.#siteId ? a : undefined; - } - - assetByPrefix(prefix16: string): Promise<Asset | undefined> { - return this.#store.getAssetByPrefix(this.#siteId, prefix16); - } -} - /** Everything a snapshot shows before gates are applied; built once per snapshot and isolate. */ interface Loaded { site: Site; /** Published pages in `Site.pageIds` order and published entries, gates not yet applied. */ pages: Page[]; entries: ContentEntry[]; + /** The same published entries (a view keeps them for originals; RFC-0042). */ + published: ContentEntry[]; + /** Published entries with a public URL (unlisted ones too); `entries` holds the listed. */ + reachable: ContentEntry[]; + /** Every published entry's derived gate (RFC-0042 §3), computed once per snapshot. */ + derived: ReadonlyMap<string, DerivedGate>; + /** Resolved field values by entry id, filled as queries need them (the snapshot is immutable). */ + resolved: Map<string, ResolvedFields>; taxonomies: Taxonomy[]; terms: Term[]; redirects: Redirect[]; @@ -364,7 +166,8 @@ interface Loaded { assetIndex: Map<string, Asset>; /** * The gate index (RFC-0008 §7.4): every instant at which a published page's or entry's gate - * changes, sorted. Empty for a site that schedules nothing — then nothing is filtered at all. + * changes — derived gates and list-field instants included (RFC-0042) — sorted. Empty for a + * site that schedules nothing — then nothing is filtered at all. */ gates: number[]; } @@ -373,7 +176,14 @@ interface Loaded { interface View { site: Site; pages: Page[]; + /** Listed entries. */ entries: ContentEntry[]; + /** Listed and unlisted entries (public URLs). */ + reachable: ContentEntry[]; + /** Every published entry of the snapshot, gates aside (originals of translations). */ + published: ContentEntry[]; + /** The snapshot's cache of resolved field values (shared by every view). */ + resolved: Map<string, ResolvedFields>; taxonomies: Taxonomy[]; terms: Term[]; redirects: Redirect[]; @@ -540,18 +350,23 @@ export class SnapshotSource implements GraphSource { (a, b) => (order.get(a.id) ?? 1e9) - (order.get(b.id) ?? 1e9), ); const shownEntries = visible(entries, false); + const derived = derivedGates(site as Site, shownEntries); if (fetched) this.#shared.fetched = fetched; return { site: site as Site, pages: shownPages, entries: shownEntries, + published: shownEntries, + reachable: shownEntries, + derived, + resolved: new Map(), taxonomies, terms, redirects, menus, assets, assetIndex, - gates: gateInstants([...shownPages, ...shownEntries]), + gates: gateIndex(site as Site, shownPages, shownEntries, derived), }; })(); this.#shared.loaded.catch(() => { @@ -605,10 +420,12 @@ export class SnapshotSource implements GraphSource { hidden.size === 0 ? loaded.assetIndex : new Map([...loaded.assetIndex].filter(([, a]) => !hidden.has(a.id))); + const split = splitEntries(loaded.entries, now, loaded.derived); return { ...loaded, pages: loaded.pages.filter((p) => isAvailable(p, now)), - entries: loaded.entries.filter((e) => isAvailable(e, now)), + entries: split.listed, + reachable: split.reachable, assets, assetIndex, }; @@ -634,7 +451,8 @@ export class SnapshotSource implements GraphSource { async entries(collection: string, locale?: string): Promise<ContentEntry[]> { const { site, entries } = await this.#current(); const loc = locale ?? site.defaultLocale; - return entries.filter((e) => e.collection === collection && e.locale === loc).sort(byNewest); + const out = entries.filter((e) => e.collection === collection && e.locale === loc); + return rememberList(out.sort(byNewest), { source: this, locale: loc }); } async entryBySlug( @@ -642,14 +460,43 @@ export class SnapshotSource implements GraphSource { slug: string, locale?: string, ): Promise<ContentEntry | undefined> { - return (await this.entries(collection, locale)).find((e) => e.slug === slug); + const { site, reachable } = await this.#current(); + const loc = locale ?? site.defaultLocale; + return reachable.find( + (e) => e.collection === collection && e.slug === slug && e.locale === loc, + ); } + async allEntries(locale?: string): Promise<ContentEntry[]> { const { site, entries } = await this.#current(); const loc = locale ?? site.defaultLocale; return entries.filter((e) => e.locale === loc).sort(byNewest); } + /** Every entry of a locale with a public URL, unlisted ones included (RFC-0042 §3). */ + async routableEntries(locale?: string): Promise<ContentEntry[]> { + const { site, reachable } = await this.#current(); + const loc = locale ?? site.defaultLocale; + return reachable.filter((e) => e.locale === loc).sort(byNewest); + } + + /** RFC-0042 §4: a collection by its list fields, `now` at this view's instant. */ + async queryEntries(q: FieldEntryQuery): Promise<FieldEntryResult> { + const view = await this.#current(); + const loc = q.locale ?? view.site.defaultLocale; + const shownHere = q.includeExpired ? view.reachable : view.entries; + return evaluateEntryQuery({ + site: view.site, + candidates: shownHere.filter((e) => e.collection === q.collection && e.locale === loc), + published: view.published, + taxonomies: view.taxonomies, + terms: view.terms, + query: q, + now: this.#clock?.() ?? Date.now(), + cache: view.resolved, + }); + } + async search(query: string, options: SourceSearchOptions = {}): Promise<SearchResults> { const locale = options.locale ?? (await this.site()).defaultLocale; return this.#store.search(this.#snapshot.siteId, query, { @@ -662,7 +509,7 @@ export class SnapshotSource implements GraphSource { /** A published entry of this snapshot by id (any locale), if its gates are open. */ async entryById(id: string): Promise<ContentEntry | undefined> { - return (await this.#current()).entries.find((e) => e.id === id); + return (await this.#current()).reachable.find((e) => e.id === id); } entryFields(entry: ContentEntry, options?: EntryFieldsOptions): Promise<RenderedFields> { @@ -702,7 +549,10 @@ export class SnapshotSource implements GraphSource { const loc = options.locale ?? site.defaultLocale; const f = compile(filter, taxonomies, terms); const out = entries.filter((e) => e.locale === loc && matches(f, e)).sort(byNewest); - return options.limit === undefined ? out : out.slice(0, options.limit); + return rememberList(options.limit === undefined ? out : out.slice(0, options.limit), { + source: this, + locale: loc, + }); } entriesByTerm( @@ -741,7 +591,7 @@ export class SnapshotSource implements GraphSource { { site: view.site, pages: view.pages, - entries: view.entries, + entries: view.reachable, taxonomies: view.taxonomies, terms: view.terms, ...(options.collectionIndex ? { collectionIndex: options.collectionIndex } : {}), diff --git a/packages/astro-renderer/src/structured-data.ts b/packages/astro-renderer/src/structured-data.ts new file mode 100644 index 00000000..699daa4b --- /dev/null +++ b/packages/astro-renderer/src/structured-data.ts @@ -0,0 +1,88 @@ +/** + * RFC-0042 §2 in `<head>`: an entry whose collection has a JSON-LD preset (`Event`, `JobPosting`, + * `Product`, `Recipe`) describes itself with it instead of the generic `BlogPosting` that + * `entryHead` emits. `entryHead` itself never changes, so a theme copy from before RFC-0042 keeps + * today's output; a theme opts in with one `withStructuredData` call after `entryHead`. + * + * The document is built by core-ts's pure `structuredDataFor` from typed values (resolved, so a + * translation reads its original's non-localized ones), capped at 32 KB, and rendered through + * `jsonLdText` by `SeoHead.astro` like every other JSON-LD block. An entry missing a required value + * keeps its `BlogPosting`; the `BreadcrumbList` always stays. + */ +import type { Asset, ContentEntry } from "@cloudhal/core-contracts"; +import { entryPath } from "@cloudhal/core-ts"; +import { resolveFields } from "@cloudhal/core-ts/fields"; +import { + absoluteHttpUrl, + type StructuredDataResult, + structuredDataFor, +} from "@cloudhal/core-ts/structured-data"; +import { loadAssetIndex, type MediaContext, socialImageUrl } from "./media.ts"; +import type { HeadMeta } from "./seo.ts"; +import type { GraphSource } from "./source.ts"; + +export interface StructuredDataOptions { + /** The site's public origin (`siteOrigin`); every URL in the document is absolute on it. */ + readonly origin: string; + /** The page's media context: its asset index is reused when given. */ + readonly media?: MediaContext | undefined; +} + +type StructuredDataSource = Pick<GraphSource, "site"> & + Partial<Pick<GraphSource, "entryById" | "assets">>; + +/** The asset index to resolve image fields, the cover image and the site logo with. */ +async function assetsFor( + source: StructuredDataSource, + media: MediaContext | undefined, +): Promise<ReadonlyMap<string, Asset>> { + return media?.assets ?? (await loadAssetIndex(source)); +} + +/** + * The preset document of an entry and why it has none (`issues`): no preset, a required value + * missing, or over the cap. What `withStructuredData` emits, and what previews and checks show. + */ +export async function entryStructuredData( + source: StructuredDataSource, + entry: ContentEntry, + options: StructuredDataOptions, +): Promise<StructuredDataResult> { + const site = await source.site(); + if (!site.collections?.[entry.collection]?.structuredData) return { issues: [] }; + const group = entry.translation?.group; + const original = + group && group !== entry.id && source.entryById ? await source.entryById(group) : undefined; + const resolved = resolveFields(site, entry, original); + const assets = await assetsFor(source, options.media); + const { origin } = options; + return structuredDataFor(site, resolved, entry, { + origin, + entryUrl: `${origin}${entryPath(site, entry)}`, + imageUrl: (image) => { + const asset = image.assetId ? assets.get(image.assetId) : undefined; + if (asset) return asset.kind === "image" ? socialImageUrl(origin, asset) : undefined; + return image.src ? absoluteHttpUrl(image.src, origin) : undefined; + }, + }); +} + +const isBlogPosting = (doc: object) => (doc as { "@type"?: unknown })["@type"] === "BlogPosting"; + +/** + * `head` with the entry's preset document in place of the `BlogPosting` (RFC-0042 §2), when its + * collection has a preset and the entry has every required value; otherwise `head` unchanged. + * The `BreadcrumbList` stays; the `BlogPosting`'s `commentCount` does not move to the preset. + */ +export async function withStructuredData( + head: HeadMeta, + source: StructuredDataSource, + entry: ContentEntry, + options: StructuredDataOptions, +): Promise<HeadMeta> { + const at = head.jsonLd.findIndex(isBlogPosting); + if (at < 0) return head; + const { doc } = await entryStructuredData(source, entry, options); + if (!doc) return head; + return { ...head, jsonLd: head.jsonLd.map((d, i) => (i === at ? doc : d)) }; +} diff --git a/packages/astro-renderer/test/derived-gates-sweep.test.ts b/packages/astro-renderer/test/derived-gates-sweep.test.ts new file mode 100644 index 00000000..3336461e --- /dev/null +++ b/packages/astro-renderer/test/derived-gates-sweep.test.ts @@ -0,0 +1,93 @@ +/** + * RFC-0008's route sweep (§7.2, invariant S9) extended to derived gates (RFC-0042 §3, "Leaks"): the + * stage 3 fixture published once, swept at every derived instant −1 ms, exactly and +1 ms through + * every route the blog starter serves. An entry past a `hide` expiry must not appear in any public + * output (id, path, title); an entry past an `unlist` expiry keeps its own page but must not + * appear on any other route, list, feed or the sitemap. + */ +import type { ContentEntry } from "@cloudhal/core-contracts"; +import { entryPath } from "@cloudhal/core-ts"; +import { derivedGate } from "@cloudhal/core-ts/fields"; +import { beforeAll, describe, expect, it } from "vitest"; +import { staticPaths } from "../src/routing.ts"; +import { SnapshotSource } from "../src/source.ts"; +import { type Stage3, stage3 } from "./stage3-fixture.ts"; +import { ORIGIN, sweep } from "./sweep.ts"; + +interface Watched { + readonly name: string; + readonly entry: ContentEntry; + readonly path: string; + readonly leaks: readonly string[]; + readonly at: number; + readonly action: "unlist" | "hide"; +} + +describe("route sweep: derived gates (RFC-0042 §3)", () => { + let fx: Stage3; + let published: SnapshotSource; + let candidates: string[]; + let watched: Watched[]; + + beforeAll(async () => { + fx = await stage3(); + published = await SnapshotSource.open(fx.store, fx.snapshotId); + const everything = await staticPaths(published.ungated()); + const watch = (name: string, entry: ContentEntry): Watched => { + const gate = derivedGate(fx.site, entry); + if (!gate) throw new Error(`fixture: ${name} has no derived gate`); + const path = entryPath(fx.site, entry); + const marker = /ZQXU\d/.exec(entry.title)?.[0]; + return { + name, + entry, + path, + leaks: [entry.id, path, ...(marker ? [marker] : [])], + at: gate.at, + action: gate.action, + }; + }; + watched = [ + watch("unlisted past event", fx.past), + watch("event ending at t+4h", fx.soon), + watch("offer hidden since yesterday", fx.oldOffer), + watch("offer hidden at 21:00 UTC", fx.todayOffer), + ]; + candidates = [...everything.pages, ...everything.feeds, ...watched.map((w) => w.path), "/nope"]; + }); + + it("leaks no hidden entry anywhere and no unlisted entry outside its own page", async () => { + const clocks = [...new Set(watched.map((w) => w.at))].flatMap((g) => [g - 1, g, g + 1]); + expect(clocks.length).toBeGreaterThanOrEqual(9); + for (const now of clocks) { + const label = `${new Date(now).toISOString()}`; + const source = published.at(now); + const swept = await sweep(source, candidates, [], fx.store); + for (const w of watched) { + const past = !(now < w.at); + const route = swept.routes.get(w.path); + if (!past) { + expect(route, `${label}: ${w.name}`).toBe("entry"); + expect(swept.text, `${label}: ${w.name} in the sitemap`).toContain(`${ORIGIN}${w.path}`); + continue; + } + if (w.action === "hide") { + expect(route, `${label}: ${w.name} route`).toBe("not-found"); + for (const leak of w.leaks) + expect(swept.text.includes(leak), `${label}: ${w.name} leaks "${leak}"`).toBe(false); + expect(await source.entryById(w.entry.id), `${label}: ${w.name} by id`).toBeUndefined(); + continue; + } + expect(route, `${label}: ${w.name} route`).toBe("entry"); + // every public output but its own page: other routes, menus, the feed, sitemap, robots.txt + const own = swept.byPath.get(w.path) ?? ""; + const elsewhere = own ? swept.text.split(own).join("\n") : swept.text; + for (const leak of w.leaks) + expect(elsewhere.includes(leak), `${label}: ${w.name} listed: "${leak}"`).toBe(false); + expect(swept.text.includes(`<loc>${ORIGIN}${w.path}</loc>`), `${label}: sitemap`).toBe( + false, + ); + } + } + }); +}); diff --git a/packages/astro-renderer/test/derived-gates.test.ts b/packages/astro-renderer/test/derived-gates.test.ts new file mode 100644 index 00000000..87869fb5 --- /dev/null +++ b/packages/astro-renderer/test/derived-gates.test.ts @@ -0,0 +1,307 @@ +/** + * RFC-0042 §3–§4 in the published sources: derived gates (`unlist` keeps the page and leaves + * every list, feed, sitemap and search; `hide` is a 404), the gate index with derived and + * list-field instants, date expiry in the site's zone, `queryEntries` on both sources (parity), + * `contentList` sections by field, and the preview notice. + */ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { edgeMaxAge } from "@cloudhal/core-ts/availability"; +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { archiveSummaries } from "../src/archive.ts"; +import { + contentListEntries, + listOrigin, + queryEntries, + rememberList, + sectionQuery, +} from "../src/entry-query.ts"; +import { rssXml, sitemapUrls } from "../src/feeds.ts"; +import { localizedSitemap } from "../src/locale-feeds.ts"; +import { renderEntryPreview } from "../src/preview.ts"; +import { resolveRoute, routablePaths, staticPaths } from "../src/routing.ts"; +import { type GraphSource, LiveSource, SnapshotSource } from "../src/source.ts"; +import { loadTaxonomyIndex } from "../src/taxonomy.ts"; +import { EVENTS, HOUR, iso, OFFERS, type Stage3, stage3, T } from "./stage3-fixture.ts"; + +const ORIGIN = "https://stage3.example"; +const ids = (list: readonly { id: string }[]) => list.map((e) => e.id).sort(); +/** 5 November ends in Istanbul at 21:00 UTC; 4 November ended at 21:00 UTC on the 4th. */ +const TODAY_ENDS = Date.UTC(2026, 10, 5, 21, 0, 0); +const YESTERDAY_ENDS = Date.UTC(2026, 10, 4, 21, 0, 0); + +describe("derived gates on the published site (RFC-0042 §3)", () => { + let fx: Stage3; + let snapshot: SnapshotSource; + beforeAll(async () => { + fx = await stage3(); + snapshot = await SnapshotSource.open(fx.store, fx.snapshotId); + }); + const sources = (now: number): [string, GraphSource][] => [ + ["snapshot", snapshot.at(now)], + ["live", new LiveSource(fx.store, fx.site.id, { includeDrafts: false, now: () => now })], + ]; + + it("unlist: the page answers, every list leaves it out", async () => { + for (const [name, source] of sources(T)) { + const route = await resolveRoute(source, "/events/past-gig"); + expect(route.kind, name).toBe("entry"); + expect(await source.entryBySlug("events", "past-gig"), name).toBeDefined(); + expect(await source.entryById?.(fx.past.id), name).toBeDefined(); + expect(ids(await source.entries("events")), name).not.toContain(fx.past.id); + expect(ids(await source.allEntries()), name).not.toContain(fx.past.id); + expect(ids((await source.routableEntries?.()) ?? []), name).toContain(fx.past.id); + // static builds still write its page; the sitemap, feed and archives do not list it + expect(await routablePaths(source), name).toContain("/events/past-gig"); + expect((await staticPaths(source)).pages, name).toContain("/events/past-gig"); + const all = await source.allEntries(); + const index = await loadTaxonomyIndex(source); + const site = await source.site(); + const sitemap = sitemapUrls(site, await source.pages(), all, ORIGIN, []); + expect(JSON.stringify(sitemap), name).not.toContain("past-gig"); + expect( + JSON.stringify(await localizedSitemap(source, ORIGIN, "stage3.example")), + ).not.toContain("past-gig"); + expect(rssXml({ site, origin: ORIGIN, entries: all, taxonomy: index }), name).not.toContain( + "ZQXU7", + ); + const archives = archiveSummaries(index, all); + for (const a of archives) { + const listed = await source.entriesByTerm(a.taxonomy.key, a.term.id); + expect(ids(listed), `${name} ${a.path}`).not.toContain(fx.past.id); + } + } + }); + + it("hide: a 404 everywhere, exactly at the start of the next day in the site's zone", async () => { + for (const [name, source] of sources(T)) { + expect((await resolveRoute(source, "/offers/old-offer")).kind, name).toBe("not-found"); + expect(await source.entryById?.(fx.oldOffer.id), name).toBeUndefined(); + expect(ids((await source.routableEntries?.()) ?? []), name).not.toContain(fx.oldOffer.id); + expect((await resolveRoute(source, "/offers/today-offer")).kind, name).toBe("entry"); + } + for (const [name, source] of sources(TODAY_ENDS - 1)) + expect((await resolveRoute(source, "/offers/today-offer")).kind, `${name} −1 ms`).toBe( + "entry", + ); + for (const [name, source] of sources(TODAY_ENDS)) + expect((await resolveRoute(source, "/offers/today-offer")).kind, `${name} at`).toBe( + "not-found", + ); + for (const [name, source] of sources(YESTERDAY_ENDS - 1)) + expect((await resolveRoute(source, "/offers/old-offer")).kind, `${name} day before`).toBe( + "entry", + ); + }); + + it("an unlisted entry comes back to lists when its gate is still ahead", async () => { + for (const [name, source] of sources(T - HOUR - 1)) { + expect(ids(await source.entries("events")), name).toContain(fx.past.id); + } + for (const [name, source] of sources(T - HOUR)) + expect(ids(await source.entries("events")), name).not.toContain(fx.past.id); + }); + + it("the gate index holds derived instants and list-field instants, so the cache caps there", async () => { + // after t: soon-gig starts (t+2h, +1 ms) and ends (t+4h), today's offer ends (21:00 UTC) + for (const [name, source] of sources(T)) { + expect(await source.nextGate?.(), name).toBe(T + 2 * HOUR); + expect(edgeMaxAge(await source.nextGate?.(), T)).toBe(7200); + } + for (const [name, source] of sources(T + 2 * HOUR)) + expect(await source.nextGate?.(), name).toBe(T + 2 * HOUR + 1); + for (const [name, source] of sources(T + 2 * HOUR + 1)) + expect(await source.nextGate?.(), name).toBe(T + 4 * HOUR); + for (const [name, source] of sources(T + 4 * HOUR)) + expect(await source.nextGate?.(), name).toBe(TODAY_ENDS); + }); + + it("previews ignore derived gates and say so", async () => { + for (const preview of [snapshot.ungated(), new LiveSource(fx.store, fx.site.id)]) { + expect((await resolveRoute(preview, "/offers/old-offer")).kind).toBe("entry"); + expect(ids(await preview.entries("events"))).toContain(fx.past.id); + expect(await preview.nextGate?.()).toBeUndefined(); + } + const ended = renderEntryPreview(fx.site, fx.past, undefined, T).html; + expect(ended).toContain('data-gate="ended"'); + expect(ended).toContain("left out of lists"); + expect(renderEntryPreview(fx.site, fx.oldOffer, undefined, T).html).toContain( + 'data-gate="expired"', + ); + expect(renderEntryPreview(fx.site, fx.soon, undefined, T).html).not.toContain("data-gate="); + }); + + it("search rows carry the derived gate (gateUntil = min)", async () => { + const search = (now: number) => + fx.store.search(fx.site.id, "Gig", { locale: "en", now, snapshotId: fx.snapshotId }); + const hits = async (now: number) => (await search(now)).hits.map((r) => r.href); + expect(await hits(T - HOUR - 1)).toContain("/events/past-gig"); + expect(await hits(T)).not.toContain("/events/past-gig"); + expect(await hits(T)).toContain("/events/soon-gig"); + const offers = async (now: number) => + ( + await fx.store.search(fx.site.id, "Offer", { locale: "en", now, snapshotId: fx.snapshotId }) + ).hits.map((r) => r.href); + expect(await offers(T)).toEqual(["/offers/today-offer"]); + expect(await offers(TODAY_ENDS)).toEqual([]); + }); +}); + +describe("queryEntries (RFC-0042 §4)", () => { + let fx: Stage3; + let snapshot: SnapshotSource; + beforeAll(async () => { + fx = await stage3(); + snapshot = await SnapshotSource.open(fx.store, fx.snapshotId); + }); + const both = (now: number): [string, GraphSource][] => [ + ["snapshot", snapshot.at(now)], + ["live", new LiveSource(fx.store, fx.site.id, { includeDrafts: false, now: () => now })], + ]; + const slugs = (r: { entries: ContentEntry[] }) => r.entries.map((e) => e.slug); + + it("filters and orders by list fields, with now at the source's instant", async () => { + for (const [name, source] of both(T)) { + const upcoming = await queryEntries(source, { + collection: "events", + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }); + expect(slugs(upcoming), name).toEqual(["soon-gig", "later-gig"]); + expect(upcoming.total, name).toBe(2); + const desc = await queryEntries(source, { + collection: "events", + orderBy: { field: "startsAt", direction: "desc" }, + }); + // entries without a value sort last in both directions; the unlisted one is left out + expect(slugs(desc), name).toEqual(["later-gig", "soon-gig", "undated-gig"]); + const cancelled = await queryEntries(source, { + collection: "events", + where: [{ field: "eventStatus", op: "in", values: ["cancelled"] }], + }); + expect(slugs(cancelled), name).toEqual(["later-gig"]); + // `date` now is today in Istanbul + const today = await queryEntries(source, { + collection: "events", + where: [{ field: "day", op: "eq", value: "now" }], + }); + expect(slugs(today), name).toEqual(["soon-gig"]); + } + }); + + it("includeExpired adds unlisted entries, never hidden ones", async () => { + for (const [name, source] of both(T)) { + const past = await queryEntries(source, { + collection: "events", + where: [{ field: "startsAt", op: "lt", value: "now" }], + includeExpired: true, + }); + expect(slugs(past), name).toEqual(["past-gig"]); + const offers = await queryEntries(source, { collection: "offers", includeExpired: true }); + expect(slugs(offers), name).toEqual(["today-offer"]); + } + }); + + it("pages with limit and offset, and the two sources agree", async () => { + for (const now of [T - 4 * HOUR, T, T + 3 * HOUR, T + 49 * HOUR]) { + const [a, b] = both(now).map(([, s]) => s) as [GraphSource, GraphSource]; + for (const q of [ + { collection: "events", limit: 1, offset: 1, sort: "title" as const }, + { collection: "events", where: [{ field: "startsAt", op: "set" as const }] }, + { collection: "events", orderBy: { field: "price", direction: "asc" as const } }, + { collection: "events", filter: "category:news", includeExpired: true }, + ]) { + const [x, y] = [await queryEntries(a, q), await queryEntries(b, q)]; + expect(slugs(x), `${iso(now)} ${JSON.stringify(q)}`).toEqual(slugs(y)); + expect(x.total).toBe(y.total); + } + } + const source = snapshot.at(T); + const paged = await queryEntries(source, { collection: "events", limit: 1, offset: 1 }); + expect(paged.entries).toHaveLength(1); + expect(paged.total).toBe(3); + }); + + it("an invalid query matches nothing and is logged", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + for (const [name, source] of both(T)) { + const r = await queryEntries(source, { + collection: "events", + where: [{ field: "venue", op: "eq", value: "x" }], + }); + expect(r, name).toEqual({ entries: [], total: 0 }); + } + expect(warn).toHaveBeenCalledWith(expect.stringContaining("not one of the collection's list")); + warn.mockRestore(); + }); + + it("contentList sections list by field, on old themes' lists too", async () => { + for (const [name, source] of both(T)) { + const home = (await source.pageByPath("/")) as NonNullable< + Awaited<ReturnType<GraphSource["pageByPath"]>> + >; + const section = home.sections.find((s) => s.id === "sec_upcoming0001"); + if (section?.kind !== "contentList") throw new Error("fixture"); + // the starter: keyed by section id, already queried + const lists = await contentListEntries(source, home.sections, "en"); + expect( + lists.sec_upcoming0001?.map((e) => e.slug), + name, + ).toEqual(["soon-gig", "later-gig"]); + expect(listOrigin(lists.sec_upcoming0001 ?? []), name).toBe("queried"); + // the collection's list keeps today's meaning (newest, listed only) + expect(ids(lists.events ?? []), name).toEqual(ids(await source.entries("events"))); + // an old theme passes `source.entries(...)`: ContentList finds its source and queries it + const old = await source.entries("events", "en"); + const origin = listOrigin(old); + if (!origin || origin === "queried") throw new Error(`${name}: no origin`); + const viaOld = await queryEntries(origin.source, sectionQuery(section, origin.locale)); + expect( + viaOld.entries.map((e) => e.slug), + name, + ).toEqual(["soon-gig", "later-gig"]); + } + expect(listOrigin(rememberList([], "queried"))).toBe("queried"); + expect(listOrigin([])).toBeUndefined(); + }); + + it("works on a source without queryEntries (themes' own sources)", async () => { + const real = snapshot.at(T); + const bare: GraphSource = { + mode: "snapshot", + site: () => real.site(), + pages: () => real.pages(), + pageByPath: (p, l) => real.pageByPath(p, l), + entries: (c, l) => real.entries(c, l), + entryBySlug: (c, s, l) => real.entryBySlug(c, s, l), + allEntries: (l) => real.allEntries(l), + taxonomies: () => real.taxonomies(), + terms: (k) => real.terms(k), + termBySlug: (k, s) => real.termBySlug(k, s), + vocabulary: (k) => real.vocabulary(k), + entriesByFilter: (f, o) => real.entriesByFilter(f, o), + entriesByTerm: (k, s, o) => real.entriesByTerm(k, s, o), + redirectFor: (p, l) => real.redirectFor(p, l), + redirects: () => real.redirects(), + menu: (k) => real.menu(k), + navigation: (k, l, o) => real.navigation(k, l, o), + }; + const r = await queryEntries(bare, { + collection: "events", + orderBy: { field: "startsAt", direction: "asc" }, + }); + expect(slugs(r)).toEqual(["soon-gig", "later-gig", "undated-gig"]); + }); +}); + +describe("sites without stage 3 settings keep today's lists", () => { + it("no expiry: nothing unlisted, no derived instants", async () => { + const { listFields: _l, expiry: _e, structuredData: _s, ...plain } = EVENTS; + const fx = await stage3({ events: plain, offers: { fields: OFFERS.fields ?? [] } as never }); + const source = (await SnapshotSource.open(fx.store, fx.snapshotId)).at(T); + expect(ids(await source.entries("events"))).toContain(fx.past.id); + expect(ids(await source.allEntries())).toEqual(ids((await source.routableEntries?.()) ?? [])); + expect(await source.nextGate()).toBeUndefined(); + const site: Site = await source.site(); + expect(site.collections?.events?.expiry).toBeUndefined(); + }); +}); diff --git a/packages/astro-renderer/test/gates-sweep.test.ts b/packages/astro-renderer/test/gates-sweep.test.ts index 3a438a8d..7fdeb489 100644 --- a/packages/astro-renderer/test/gates-sweep.test.ts +++ b/packages/astro-renderer/test/gates-sweep.test.ts @@ -17,117 +17,16 @@ import { MemoryBlobStore } from "@cloudhal/storage"; import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; import { beforeAll, describe, expect, it } from "vitest"; import { publishedNode } from "../src/analytics.ts"; -import { archiveSummaries } from "../src/archive.ts"; -import { archiveFeedXml, robotsTxt, rssXml, sitemapUrls, sitemapXml } from "../src/feeds.ts"; -import { renderEntryArticle } from "../src/markdown.ts"; -import { loadAssetIndex, type MediaContext } from "../src/media.ts"; -import { serveMediaRequest } from "../src/media-route.ts"; -import { derivedNavigation } from "../src/navigation.ts"; -import { renderEntryPreview, renderPagePreview, renderSectionHtml } from "../src/preview.ts"; +import { loadAssetIndex } from "../src/media.ts"; +import { renderEntryPreview, renderPagePreview } from "../src/preview.ts"; import { resolveRoute, staticPaths } from "../src/routing.ts"; -import { archiveHead, entryHead, pageHead } from "../src/seo.ts"; -import { type GraphSource, LiveSource, SnapshotSource } from "../src/source.ts"; -import { entryTaxonomy, loadTaxonomyIndex, relatedEntries } from "../src/taxonomy.ts"; +import { LiveSource, SnapshotSource } from "../src/source.ts"; import { applyGatesFixture, type GatesFixture } from "./gates-fixture.ts"; +import { ORIGIN, sweep } from "./sweep.ts"; -const ORIGIN = "https://gates.example"; const T = Date.UTC(2026, 10, 2, 6, 0, 0); const person = { kind: "human" as const, id: "user_gates000002", name: "Halil" }; -interface Swept { - /** Every public body and header, joined: what a leak would show up in. */ - readonly text: string; - /** What each path answered (`page`, `entry`, `archive`, `feed`, `not-found`, …). */ - readonly routes: Map<string, string>; - /** Media URL → status. */ - readonly media: Map<string, number>; -} - -/** What the blog starter's routes put on the wire for one source, path by path. */ -async function sweep( - source: GraphSource, - candidates: readonly string[], - assets: readonly string[], - store: SiteGraphStore, -): Promise<Swept> { - const out: string[] = []; - const routes = new Map<string, string>(); - const site = await source.site(); - const locale = site.defaultLocale; - const index = await loadTaxonomyIndex(source); - const media: MediaContext = { - assets: await loadAssetIndex(source), - locale, - defaultLocale: locale, - }; - const all = await source.allEntries(); - const primary = await source.navigation("primary", locale); - const header = derivedNavigation( - site, - await source.pages(), - archiveSummaries(index, all).filter((a) => a.taxonomy.key === "category"), - locale, - ); - out.push(JSON.stringify(primary), JSON.stringify(header)); - const { pages, feeds } = await staticPaths(source); - for (const path of new Set([...pages, ...feeds, ...candidates])) { - const route = await resolveRoute(source, path); - routes.set(path, route.kind); - if (route.kind === "page") { - const lists: Record<string, Awaited<ReturnType<GraphSource["entries"]>>> = {}; - for (const s of route.page.sections) - if (s.kind === "contentList") lists[s.collection] = await source.entries(s.collection); - out.push( - JSON.stringify(pageHead(site, route.page, ORIGIN, media)), - ...route.page.sections.map((s) => renderSectionHtml(s, lists, media)), - ); - } else if (route.kind === "entry") { - const terms = entryTaxonomy(index, route.entry); - out.push( - JSON.stringify(entryHead(site, route.entry, ORIGIN, terms, media)), - renderEntryArticle(route.entry, locale, media), - JSON.stringify(terms), - JSON.stringify(relatedEntries(route.entry, all, index, 3).map((e) => [e.id, e.title])), - ); - } else if (route.kind === "archive") { - out.push( - JSON.stringify(archiveHead(site, route.archive, ORIGIN, media)), - JSON.stringify(route.archive.entries.map((e) => [e.id, e.title, e.summary])), - String(route.archive.total), - ); - } else if (route.kind === "feed") { - out.push(archiveFeedXml({ site, origin: ORIGIN, archive: route.archive, taxonomy: index })); - } else if (route.kind === "redirect") { - out.push(route.location); - } - } - out.push( - rssXml({ - site, - origin: ORIGIN, - entries: all, - taxonomy: index, - assets: await loadAssetIndex(source), - }), - sitemapXml(sitemapUrls(site, await source.pages(), all, ORIGIN, archiveSummaries(index, all))), - robotsTxt(ORIGIN), - ); - const mediaStatus = new Map<string, number>(); - for (const url of assets) { - const res = (await serveMediaRequest(new Request(`${ORIGIN}${url}`), { - siteId: site.id, - mode: "published", - assetByPrefix: async (p) => source.assetByPrefix?.(p), - blobState: (sha) => store.blobState(site.id, sha), - blobs: store.blobs, - })) as Response; - mediaStatus.set(url, res.status); - out.push(JSON.stringify([...res.headers])); - await res.body?.cancel(); - } - return { text: out.join("\n"), routes, media: mediaStatus }; -} - describe("route sweep: no gated node in any public output (S9)", () => { let store: SiteGraphStore; let site: Site; diff --git a/packages/astro-renderer/test/stage3-fixture.ts b/packages/astro-renderer/test/stage3-fixture.ts new file mode 100644 index 00000000..f634f2d5 --- /dev/null +++ b/packages/astro-renderer/test/stage3-fixture.ts @@ -0,0 +1,197 @@ +/** + * RFC-0042 fixture for the renderer's tests: on top of the starter seed, a site in + * `Europe/Istanbul` (UTC+3, no DST) with + * + * - `events` (datetime `startsAt`/`endsAt`, date `day`, text, number and select fields; list + * fields; `expiry: endsAt, unlist`; an `Event` preset) and four entries: one past its end at + * `t` (`ZQXU7`), one ending at t+4h, one two days later (cancelled), one without dates; + * - `offers` (date `validUntil`; `expiry: validUntil, hide`) with one offer that ended yesterday + * (`ZQXU8`) and one valid all of today (`ZQXU9`, ends at 21:00 UTC); + * - a home-page `contentList` of upcoming events (`startsAt gte now`, soonest first). + * + * `t` is 5 November 2026, 12:00 UTC (15:00 in Istanbul). + */ +import type { + CollectionSettings, + ContentEntry, + FieldDefinition, + Page, + Site, +} from "@cloudhal/core-contracts"; +import { GATES_ENFORCED, SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; + +export const T = Date.UTC(2026, 10, 5, 12, 0, 0); +export const HOUR = 3_600_000; +export const iso = (ms: number) => new Date(ms).toISOString(); +const writer = { kind: "human" as const, id: "user_stage3000001", name: "Halil" }; +const L = (label: string) => ({ en: { label } }); + +export const EVENT_FIELDS: [FieldDefinition, ...FieldDefinition[]] = [ + { key: "startsAt", type: "datetime", labels: L("Starts"), localized: false }, + { key: "endsAt", type: "datetime", labels: L("Ends"), localized: false }, + { key: "day", type: "date", labels: L("Day"), localized: false }, + { key: "venue", type: "text", labels: L("Venue"), localized: true }, + { key: "city", type: "text", labels: L("City"), localized: false }, + { key: "price", type: "number", labels: L("Price"), localized: false }, + { + key: "eventStatus", + type: "select", + labels: L("Status"), + localized: false, + options: [ + { value: "scheduled", labels: { en: "Scheduled" } }, + { value: "cancelled", labels: { en: "Cancelled" } }, + ], + }, +]; + +export const EVENTS: CollectionSettings = { + labels: { en: { singular: "Event", plural: "Events" } }, + fields: EVENT_FIELDS, + listFields: ["startsAt", "eventStatus", "price", "day"], + expiry: { field: "endsAt", action: "unlist" }, + structuredData: { + type: "Event", + properties: { + startDate: { kind: "field", field: "startsAt" }, + endDate: { kind: "field", field: "endsAt" }, + "location.name": { kind: "field", field: "venue" }, + "location.address.addressLocality": { kind: "field", field: "city" }, + eventStatus: { kind: "field", field: "eventStatus" }, + "offers.price": { kind: "field", field: "price" }, + "offers.priceCurrency": { kind: "constant", value: "TRY" }, + }, + }, +}; + +export const OFFERS: CollectionSettings = { + labels: { en: { singular: "Offer", plural: "Offers" } }, + fields: [{ key: "validUntil", type: "date", labels: L("Valid until"), localized: false }], + listFields: ["validUntil"], + expiry: { field: "validUntil", action: "hide" }, +}; + +export interface Stage3 { + readonly store: SiteGraphStore; + readonly site: Site; + readonly home: Page; + readonly past: ContentEntry; + readonly soon: ContentEntry; + readonly later: ContentEntry; + readonly undated: ContentEntry; + readonly oldOffer: ContentEntry; + readonly todayOffer: ContentEntry; + /** The derived gate instants and list-field instants the gate index must hold. */ + readonly snapshotId: string; +} + +const at = (ms: number) => ({ at: iso(ms), timeZone: "Europe/Istanbul" }); + +/** A fresh store with the fixture, published once. `settings` overrides the collections. */ +export async function stage3( + settings: { events?: CollectionSettings; offers?: CollectionSettings } = {}, +): Promise<Stage3> { + const store = new SiteGraphStore({ + sql: await BetterSqlite3Driver.open(":memory:"), + blobs: new MemoryBlobStore(), + now: () => new Date(T - 86_400_000), + gates: GATES_ENFORCED, + }); + await store.init(); + const seeded = await seedStarterSite(store, { name: "Stage Three", slug: "stage-three" }); + const site0 = await store.updateSite({ + ...seeded, + timeZone: "Europe/Istanbul", + collections: { + ...seeded.collections, + events: settings.events ?? EVENTS, + offers: settings.offers ?? OFFERS, + }, + }); + const [hello] = await store.listContentEntries(site0.id, "posts"); + const entry = (collection: string, slug: string, title: string, fields: object) => + store.createContentEntry({ + siteId: site0.id, + collection, + slug, + locale: site0.defaultLocale, + status: "published", + title, + summary: `About ${title}`, + body: { format: "markdown", value: `All about ${title}.` }, + fields, + tags: [], + ...(hello?.terms ? { terms: hello.terms } : {}), + author: writer, + } as never); + const past = await entry("events", "past-gig", "Gig ZQXU7", { + startsAt: at(T - 3 * HOUR), + endsAt: at(T - HOUR), + day: "2026-11-05", + venue: "Old Hall", + city: "Istanbul", + }); + const soon = await entry("events", "soon-gig", "Gig soon", { + startsAt: at(T + 2 * HOUR), + endsAt: at(T + 4 * HOUR), + day: "2026-11-05", + venue: "Kadıköy </script><script>alert(1)</script> & Co", + city: "Istanbul", + price: 250, + eventStatus: "scheduled", + }); + const later = await entry("events", "later-gig", "Gig later", { + startsAt: at(T + 48 * HOUR), + endsAt: at(T + 50 * HOUR), + day: "2026-11-07", + venue: "Moda Stage", + city: "Istanbul", + eventStatus: "cancelled", + }); + const undated = await entry("events", "undated-gig", "Gig undated", { venue: "Somewhere" }); + const oldOffer = await entry("offers", "old-offer", "Offer ZQXU8", { validUntil: "2026-11-04" }); + const todayOffer = await entry("offers", "today-offer", "Offer ZQXU9", { + validUntil: "2026-11-05", + }); + let site = (await store.getSite(site0.id)) as Site; + const home = (await store.listPages(site.id)).find((p) => p.path === "/") as Page; + const lists = (settings.events ?? EVENTS).listFields?.includes("startsAt"); + const updatedHome = await store.updatePage({ + ...home, + sections: [ + ...home.sections, + ...(lists + ? [ + { + id: "sec_upcoming0001", + kind: "contentList", + collection: "events", + limit: 10, + sort: "newest", + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }, + ] + : []), + ], + } as Page); + const snap = await store.createSnapshot(site.id, { + actor: writer, + reason: "publish", + }); + site = await store.publish((await store.getSite(site.id)) as Site, snap.id); + return { + store, + site, + home: updatedHome, + past, + soon, + later, + undated, + oldOffer, + todayOffer, + snapshotId: snap.id, + }; +} diff --git a/packages/astro-renderer/test/structured-data.test.ts b/packages/astro-renderer/test/structured-data.test.ts new file mode 100644 index 00000000..48c138ae --- /dev/null +++ b/packages/astro-renderer/test/structured-data.test.ts @@ -0,0 +1,82 @@ +/** + * RFC-0042 §2 in `<head>`: `withStructuredData` swaps the generic `BlogPosting` for a + * collection's preset when the entry has every required value; `entryHead` is unchanged (old + * themes), the `BreadcrumbList` stays, output is escaped by `jsonLdText`. + */ +import type { ContentEntry } from "@cloudhal/core-contracts"; +import { STRUCTURED_DATA_MAX_BYTES } from "@cloudhal/core-ts/structured-data"; +import { beforeAll, describe, expect, it } from "vitest"; +import { entryHead, entryStructuredData, jsonLdText, withStructuredData } from "../src/seo.ts"; +import { type GraphSource, SnapshotSource } from "../src/source.ts"; +import { type Stage3, stage3, T } from "./stage3-fixture.ts"; + +const ORIGIN = "https://stage3.example"; +const types = (docs: object[]) => docs.map((d) => (d as { "@type": string })["@type"]); + +describe("withStructuredData (RFC-0042 §2)", () => { + let fx: Stage3; + let source: GraphSource; + beforeAll(async () => { + fx = await stage3(); + source = (await SnapshotSource.open(fx.store, fx.snapshotId)).at(T); + }); + const head = async (entry: ContentEntry) => { + const site = await source.site(); + const base = entryHead(site, entry, ORIGIN); + return { base, withPreset: await withStructuredData(base, source, entry, { origin: ORIGIN }) }; + }; + + it("replaces the BlogPosting with the preset and keeps the BreadcrumbList", async () => { + const { base, withPreset } = await head(fx.soon); + expect(types(base.jsonLd)).toEqual(["BlogPosting", "BreadcrumbList"]); + expect(types(withPreset.jsonLd)).toEqual(["Event", "BreadcrumbList"]); + expect(withPreset.jsonLd[1]).toBe(base.jsonLd[1]); + const event = withPreset.jsonLd[0] as Record<string, unknown>; + expect(event).toMatchObject({ + "@context": "https://schema.org", + "@type": "Event", + name: "Gig soon", + // a datetime gives its instant with its zone's offset + startDate: "2026-11-05T17:00:00+03:00", + endDate: "2026-11-05T19:00:00+03:00", + eventStatus: "https://schema.org/EventScheduled", + offers: { "@type": "Offer", price: 250, priceCurrency: "TRY" }, + }); + expect(event.location).toMatchObject({ + "@type": "Place", + address: { "@type": "PostalAddress", addressLocality: "Istanbul" }, + }); + // everything else in the head is entryHead's + expect({ ...withPreset, jsonLd: [] }).toEqual({ ...base, jsonLd: [] }); + }); + + it("escapes values through jsonLdText: no </script> breakout", async () => { + const { withPreset } = await head(fx.soon); + const text = jsonLdText(withPreset.jsonLd[0] as object); + expect(text).not.toContain("</script>"); + expect(text).not.toContain("<"); + expect(text).not.toContain("&"); + expect(text).toContain("\\u003c/script\\u003e"); + expect(JSON.parse(text).location.name).toBe("Kadıköy </script><script>alert(1)</script> & Co"); + expect(text.length).toBeLessThanOrEqual(STRUCTURED_DATA_MAX_BYTES); + }); + + it("keeps the BlogPosting when a required value is missing", async () => { + const { base, withPreset } = await head(fx.undated); + expect(withPreset).toBe(base); + const result = await entryStructuredData(source, fx.undated, { origin: ORIGIN }); + expect(result.doc).toBeUndefined(); + expect(result.issues.map((i) => i.path)).toContain("startDate"); + }); + + it("changes nothing for a collection without a preset (and entryHead never changes)", async () => { + const [hello] = await source.entries("posts"); + if (!hello) throw new Error("seed"); + const { base, withPreset } = await head(hello); + expect(withPreset).toBe(base); + expect(types(base.jsonLd)).toEqual(["BlogPosting", "BreadcrumbList"]); + // an unlisted entry keeps its page and its preset, with dates in the past (Google's way) + const past = await head(fx.past); + expect(types(past.withPreset.jsonLd)).toEqual(["Event", "BreadcrumbList"]); + }); +}); diff --git a/packages/astro-renderer/test/sweep.ts b/packages/astro-renderer/test/sweep.ts new file mode 100644 index 00000000..80ddf3fa --- /dev/null +++ b/packages/astro-renderer/test/sweep.ts @@ -0,0 +1,118 @@ +/** + * The route sweep's renderer (RFC-0008 §7.2): what the blog starter's routes put on the wire for + * one source, path by path. Shared by the time-gate sweep (`gates-sweep.test.ts`) and the derived + * gate sweep (`derived-gates-sweep.test.ts`, RFC-0042 §3). + */ +import type { SiteGraphStore } from "@cloudhal/core-ts"; +import { archiveSummaries } from "../src/archive.ts"; +import { archiveFeedXml, robotsTxt, rssXml, sitemapUrls, sitemapXml } from "../src/feeds.ts"; +import { renderEntryArticle } from "../src/markdown.ts"; +import { loadAssetIndex, type MediaContext } from "../src/media.ts"; +import { serveMediaRequest } from "../src/media-route.ts"; +import { derivedNavigation } from "../src/navigation.ts"; +import { renderSectionHtml } from "../src/preview.ts"; +import { resolveRoute, staticPaths } from "../src/routing.ts"; +import { archiveHead, entryHead, pageHead } from "../src/seo.ts"; +import type { GraphSource } from "../src/source.ts"; +import { entryTaxonomy, loadTaxonomyIndex, relatedEntries } from "../src/taxonomy.ts"; + +export const ORIGIN = "https://gates.example"; + +export interface Swept { + /** Every public body and header, joined: what a leak would show up in. */ + readonly text: string; + /** What each path answered (`page`, `entry`, `archive`, `feed`, `not-found`, …). */ + readonly routes: Map<string, string>; + /** Media URL → status. */ + readonly media: Map<string, number>; + /** What each path's own route rendered (head, body, lists), for leaks outside one page. */ + readonly byPath: Map<string, string>; +} + +/** What the blog starter's routes put on the wire for one source, path by path. */ +export async function sweep( + source: GraphSource, + candidates: readonly string[], + assets: readonly string[], + store: SiteGraphStore, +): Promise<Swept> { + const out: string[] = []; + const routes = new Map<string, string>(); + const site = await source.site(); + const locale = site.defaultLocale; + const index = await loadTaxonomyIndex(source); + const media: MediaContext = { + assets: await loadAssetIndex(source), + locale, + defaultLocale: locale, + }; + const all = await source.allEntries(); + const primary = await source.navigation("primary", locale); + const header = derivedNavigation( + site, + await source.pages(), + archiveSummaries(index, all).filter((a) => a.taxonomy.key === "category"), + locale, + ); + out.push(JSON.stringify(primary), JSON.stringify(header)); + const { pages, feeds } = await staticPaths(source); + const byPath = new Map<string, string>(); + for (const path of new Set([...pages, ...feeds, ...candidates])) { + const before = out.length; + const route = await resolveRoute(source, path); + routes.set(path, route.kind); + if (route.kind === "page") { + const lists: Record<string, Awaited<ReturnType<GraphSource["entries"]>>> = {}; + for (const s of route.page.sections) + if (s.kind === "contentList") lists[s.collection] = await source.entries(s.collection); + out.push( + JSON.stringify(pageHead(site, route.page, ORIGIN, media)), + ...route.page.sections.map((s) => renderSectionHtml(s, lists, media)), + ); + } else if (route.kind === "entry") { + const terms = entryTaxonomy(index, route.entry); + out.push( + JSON.stringify(entryHead(site, route.entry, ORIGIN, terms, media)), + renderEntryArticle(route.entry, locale, media), + JSON.stringify(terms), + JSON.stringify(relatedEntries(route.entry, all, index, 3).map((e) => [e.id, e.title])), + ); + } else if (route.kind === "archive") { + out.push( + JSON.stringify(archiveHead(site, route.archive, ORIGIN, media)), + JSON.stringify(route.archive.entries.map((e) => [e.id, e.title, e.summary])), + String(route.archive.total), + ); + } else if (route.kind === "feed") { + out.push(archiveFeedXml({ site, origin: ORIGIN, archive: route.archive, taxonomy: index })); + } else if (route.kind === "redirect") { + out.push(route.location); + } + byPath.set(path, out.slice(before).join("\n")); + } + out.push( + rssXml({ + site, + origin: ORIGIN, + entries: all, + taxonomy: index, + assets: await loadAssetIndex(source), + }), + sitemapXml(sitemapUrls(site, await source.pages(), all, ORIGIN, archiveSummaries(index, all))), + robotsTxt(ORIGIN), + ); + const mediaStatus = new Map<string, number>(); + for (const url of assets) { + const res = (await serveMediaRequest(new Request(`${ORIGIN}${url}`), { + siteId: site.id, + mode: "published", + assetByPrefix: async (p) => source.assetByPrefix?.(p), + blobState: (sha) => store.blobState(site.id, sha), + blobs: store.blobs, + })) as Response; + mediaStatus.set(url, res.status); + out.push(JSON.stringify([...res.headers])); + await res.body?.cancel(); + } + return { text: out.join("\n"), routes, media: mediaStatus, byPath }; +} diff --git a/packages/astro-renderer/tsconfig.json b/packages/astro-renderer/tsconfig.json index 0d12e06f..8c42c59e 100644 --- a/packages/astro-renderer/tsconfig.json +++ b/packages/astro-renderer/tsconfig.json @@ -5,6 +5,6 @@ "noEmit": true, "types": ["node", "@cloudflare/workers-types"] }, - "include": ["src", "test"], + "include": ["src", "test", "bench"], "exclude": ["src/**/*.astro"] } diff --git a/packages/cli/test/cloudflare-gates.test.ts b/packages/cli/test/cloudflare-gates.test.ts index 77277636..c36bde51 100644 --- a/packages/cli/test/cloudflare-gates.test.ts +++ b/packages/cli/test/cloudflare-gates.test.ts @@ -10,7 +10,7 @@ * node. Previews (live and snapshot links) show them, with the notice. * - **Cache cap (S10).** Published responses carry `max-age` up to the next gate; a gate a few * seconds ahead opens on its own, with no purge and no scheduler. - * - **Features handshake (§7.7).** `GET /__cloudhal/features` answers `{ "gates": 1 }` to the admin + * - **Features handshake (§7.7).** `GET /__cloudhal/features` answers `{ "gates": 2 }` to the admin * only, and the admin Worker accepts `availability` over REST once it has asked. * - **Writer guard (S15).** Against an "old" site Worker (no features endpoint), the admin Worker * refuses `availability` in REST writes and bundle pushes with `409 gates_unsupported`. @@ -326,7 +326,7 @@ describe("time gates on workerd: the starter's routes, the cache cap, the handsh const auth = { authorization: `Bearer ${secrets.CLOUDHAL_PURGE_TOKEN}` }; const ok = await siteFetch("/__cloudhal/features", { headers: auth }); expect(ok.status).toBe(200); - expect(await ok.json()).toEqual({ gates: 1 }); + expect(await ok.json()).toEqual({ gates: 2 }); expect(ok.headers.get("cloudflare-cdn-cache-control")).toBe("no-store"); // Workers Cache caches responses without cache headers: every answer here says no-store const anonymous = await siteFetch("/__cloudhal/features"); diff --git a/packages/cli/test/e2e.test.ts b/packages/cli/test/e2e.test.ts index f33db0cd..7e979447 100644 --- a/packages/cli/test/e2e.test.ts +++ b/packages/cli/test/e2e.test.ts @@ -808,4 +808,114 @@ describe("cloudhal init → dev → snapshot → build", () => { expect(after).not.toContain("ch-fields"); expect(after).toBe(before); }); + + it("build emits a collection's JSON-LD preset, keeps past events' pages out of lists and lists by field (RFC-0042)", async () => { + const cfg = await readProjectConfig(projectDir); + const outDir = path.join(projectDir, "dist"); + const read = (...p: string[]) => readFile(path.join(outDir, ...p), "utf8"); + const jsonLd = (html: string) => + [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map( + (m) => m[1], + ); + const article = (html: string) => html.match(/<article[\s\S]*?<\/article>/)?.[0] ?? ""; + const postHtml = await read("posts", "hello-world", "index.html"); + const local = await openLocalStore(path.join(projectDir, cfg.dataDir)); + let site = await local.store.getSite(cfg.siteId); + const events = site?.collections?.events; + if (!site || !events?.fields) throw new Error("the RFC-0041 test sets up events first"); + const label = { en: { label: "Ends" } }; + site = await local.store.updateSite({ + ...site, + collections: { + ...site.collections, + events: { + ...events, + fields: [ + ...events.fields, + { key: "endsAt", type: "datetime", labels: label, localized: false }, + { key: "city", type: "text", labels: { en: { label: "City" } }, localized: false }, + ], + listFields: ["startsAt"], + expiry: { field: "endsAt", action: "unlist" }, + structuredData: { + type: "Event", + properties: { + startDate: { kind: "field", field: "startsAt" }, + endDate: { kind: "field", field: "endsAt" }, + "location.name": { kind: "field", field: "venue" }, + "location.address.addressLocality": { kind: "field", field: "city" }, + }, + }, + }, + }, + }); + const day = 86_400_000; + const at = (ms: number) => ({ at: new Date(ms).toISOString(), timeZone: "Europe/Istanbul" }); + const now = Date.now(); + const all = await local.store.listContentEntries(cfg.siteId, "events"); + const launch = all.find((e) => e.slug === "launch" && e.locale === "en"); + if (!launch) throw new Error("no launch event"); + await local.store.updateContentEntry({ + ...launch, + fields: { + ...launch.fields, + city: "Istanbul", + startsAt: at(now + 30 * day), + endsAt: at(now + 30 * day + 1), + }, + }); + await local.store.createContentEntry({ + siteId: cfg.siteId, + collection: "events", + slug: "retro", + locale: "en", + status: "published", + title: "Retro night", + body: { format: "markdown", value: "An evening that already happened." }, + fields: { venue: "Moda Stage", startsAt: at(now - 3 * day), endsAt: at(now - 2 * day) }, + tags: [], + ...(launch.terms ? { terms: launch.terms } : {}), + author: { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }, + } as never); + await local.store.createPage(site, { + siteId: cfg.siteId, + path: "/events", + title: "Events", + locale: "en", + status: "published", + sections: [ + { + id: "sec_upcomingevt1", + kind: "contentList", + collection: "events", + limit: 10, + sort: "newest", + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }, + ], + } as never); + await local.close(); + await snapshot({ projectDir, label: "stage 3", publish: true }); + await spawnBuild(projectDir); + + // The preset replaces the BlogPosting; the breadcrumbs stay; values are escaped. + const event = jsonLd(await read("events", "launch", "index.html")); + expect(event.map((d) => JSON.parse(d ?? "{}")["@type"])).toEqual(["Event", "BreadcrumbList"]); + expect(event[0]).toContain("\\u003cb\\u003eKadıköy"); + expect(event[0]).not.toContain("<b>"); + // A past event keeps its page (and its preset, dates in the past) but leaves every list. + const retro = await read("events", "retro", "index.html"); + expect(retro).toContain("Retro night"); + const listing = await read("events", "index.html"); + expect(listing).toContain('href="/events/launch"'); + expect(listing).not.toContain("/events/retro"); + expect(await read("rss.xml")).not.toContain("Retro night"); + expect(await read("sitemap.xml")).not.toContain("/events/retro"); + expect(await read("sitemap.xml")).toContain("/events/launch"); + // A collection without the settings renders as before: its article and its JSON-LD. + const postAfter = await read("posts", "hello-world", "index.html"); + expect(article(postAfter)).toBe(article(postHtml)); + expect(jsonLd(postAfter)).toEqual(jsonLd(postHtml)); + }); }); diff --git a/packages/cli/test/fixtures/theme-v0.5.0/README.md b/packages/cli/test/fixtures/theme-v0.5.0/README.md new file mode 100644 index 00000000..457c24fb --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/README.md @@ -0,0 +1,7 @@ +# Frozen theme: the blog starter as released in v0.5.0 + +`src/` is `starters/blog/src` exactly as tagged `v0.5.0` (`git archive v0.5.0 starters/blog/src`). `test/old-theme-compat.test.ts` creates a project with today's `cloudhal init`, swaps its `src/` for this copy and builds it with today's packages. + +Why this copy: RFC-0042 step 3 added collections stage 3 to the renderer — derived expiry gates, listings by field (`queryEntries`, `contentList` sections with `where`/`orderBy`) and JSON-LD presets (`withStructuredData`) — and changed the starter to call `withStructuredData` and `contentListEntries`. Sites created with 0.5.0 keep this theme: it must keep building and rendering exactly as before, with the generic `BlogPosting` for every entry (`entryHead` is unchanged). What it gets on upgrade without a change of its own comes from settings the site opts into: entries past an expiry leave its lists and feeds, and `contentList` sections with `where` filter (`ContentList.astro` ships in the renderer). See `../theme-v0.3.10/README.md` for the history of this test. + +**Never update this copy** to the current starter, and do not reformat it (Biome and the TypeScript check skip it): it stands for the themes that already exist in sites. Only `src/` is kept; `astro.config.mjs`, `tsconfig.json` and `public/` (fonts) come from the project `init` creates. diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/DESIGN.md b/packages/cli/test/fixtures/theme-v0.5.0/src/DESIGN.md new file mode 100644 index 00000000..4492ef45 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/DESIGN.md @@ -0,0 +1,42 @@ +## Brand +The blog theme is an editorial theme: articles first, set in a calm reading column, with full-bleed bands for landing moments. It suits publications, newsrooms, journals and small company sites that publish. + +## Colors +- Prefer the color tokens: `seed` (one color the palette is built from), `scheme` (tonal, vibrant, neutral, mono), `contrast` and `mode` (light, dark, or auto to follow the reader). The roles, light and dark, are derived and always meet WCAG contrast; get_design shows them. +- Without a seed, four settings apply: `{color.brand}` (buttons, the brand band, accents), `{color.link}`, `{color.surface}` (the page) and `{color.text}`. Change colors with set_theme_settings, never by hand. +- Bands and cards take a `surface`: `plain` (the page), `muted` (a soft step), `raised` (lifted, for cards), `inverse`, `brand` and `accent` (loud, their own text color), `gradient` and `mesh` (brand and accent light: one hero or call to action a page), and `dots`, `grid` or `noise` (quiet textures). Use loud surfaces at most twice a page. The older `tone` still works. +- A band can have an `anchor` (e.g. `pricing`), so a menu or button can link to `#pricing`. + +## Typography +- `typePairing` sets headings and text in fonts that go together: editorial (Fraunces and Inter) for publications, modern (Space Grotesk and Inter) for products, humanist (Source Sans) for friendly sites, technical (JetBrains Mono headings) for developer tools, classic (Source Serif) for literary or institutional sites. With `system`, the `font` setting chooses one family (system, serif, humanist or mono). +- `typeScale` makes sizes fluid between phone and desktop: compact for dense, text-heavy sites; balanced for most; dramatic for landing pages with short, bold headlines. +- Headings: one level-1 per page; level 2 for sections; level 3 inside cards. Keep headings under about 60 characters, in sentence case. + +## Layout +- `radius` sets the corners: none for sober, institutional sites; small or medium for most; large for friendly ones; full for pill buttons (cards stay modest). `density` sets the room: compact for dense, text-heavy sites, cozy for most, airy for landing pages and portfolios. +- The reading column is narrow (about 48rem). A `Band` with `width: wide` or `full` leaves it: use bands for heroes, feature grids, proof and calls to action, and keep long text in the column. +- Good rhythm on a landing page: hero band (`gradient`, `mesh`, `inverse` or `brand`) → plain section → `muted` or `dots` band → plain section with `raised` cards → `brand` or `accent` band call to action. Use `spacing: xl` on hero bands and `l` elsewhere. +- Grids: 2 or 3 columns for cards, 3 or 4 for stats. `Columns` with `ratio: 2-1` pairs text with an image or a quote. + +## Components +- The theme styles every primitive. These look best: `Band`, `Columns`, `Grid`, `Card` (with an `Image` at `ratio: 16:9` first), `Stat`, `Quote`, `Tabs`, `Accordion`, and the blocks `testimonial`, `callout` and `faq`. +- Buttons: one `variant: primary` per section; `secondary` or `ghost` for the rest. +- A `Card` with `href` is a whole link: keep its text short (a heading and one sentence). +- Tools (calculators): put the inputs (`NumberField`, `RadioGroup` with `variant: buttons`, `Checkbox`) in a `Card`, two or three to a `Grid` row, and the result as a `Stat` right below them. +- Charts: a `Chart` sits in the reading column (or a `wide` band for dashboards), titled with what it shows ("Readers per month, 2026"), with a `caption` naming the source. Bar to compare, line over time, donut for parts of a whole. +- Tables: a `Table` compares a few things across the same fields (plans, specs). The first column names the row; numbers align `end` with a `format`; `highlight` marks the recommended row. On phones rows become cards, so keep labels short. +- Lists of articles come from the content list section or a surface entry source, not from copied titles. + +## Imagery +- Carousels: a static hero band is the default. Use a `hero` `Carousel` only for two to five equal offers (same `aspect`, the strongest first); when the header is transparent over it, every slide opens with the same loud `Band`. Autoplay is off unless the site asks, and then `slow`. A `rail` carries logos, products or cards, a `gallery` photographs (with `thumbs`), `testimonials` quotes. +- Photographs from the media library, landscape for heroes and cards (`16:9` or `4:3`), never the same image twice on a page. Alt text says what is in the picture, in the page's language. + +## Pages +- Home (editorial): hero band with the publication's promise → content list of the newest articles → a `muted` band with two or three sections or categories as cards → newsletter or about call to action. +- Landing: hero band → three to four features in a `Grid` → proof (`Stat` row or `testimonial`) → pricing (`Tabs` or `Toggle` with bound prices) → `faq` → call to action band. +- About: one short paragraph of purpose → people or values as cards → contact call to action. + +## Don't +- Don't stack three bands of the same surface, or three grids of cards, in a row. +- Don't use placeholder text, `#` links or repeated images. +- Don't put long articles in a surface: articles are entries with a markdown body. diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/blocks.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/blocks.ts new file mode 100644 index 00000000..3c29dd24 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/blocks.ts @@ -0,0 +1,45 @@ +/** + * This theme's block types (RFC-0015 §6): pure data, read by Studio, agents (`list_block_types`) + * and the store, and bundled by `cloudhal deploy`. Their components are in `theme.ts` (`blocks`). + * + * Names are plain English words shared by every theme (`testimonial`, `pricing-table`), never + * prefixed with the theme: content written under one theme renders under any other that defines + * the same name. The built-ins (`callout`, `button`, `embed`, `faq`) are reserved; restyle them + * through `blocks` in `theme.ts` instead. + * + * Keep this file data only, with type imports only: the CLI loads it on its own (Studio, agents, + * deploy) and checks every type, so it needs nothing from the project at run time. + */ +import type { BlockType } from "@cloudhal/core-ts/blocks"; + +export const blockTypes: readonly BlockType[] = [ + { + name: "testimonial", + title: { en: "Testimonial", tr: "Referans" }, + description: { + en: "A quote from a reader or customer, with who said it.", + tr: "Bir okurdan ya da müşteriden alıntı ve kimin söylediği.", + }, + icon: "Quotes", + category: "text", + where: ["section", "body"], + content: "markdown", + props: { + type: "object", + additionalProperties: false, + required: ["author"], + properties: { + author: { type: "string", minLength: 1, maxLength: 120, title: { en: "Name", tr: "Ad" } }, + role: { + type: "string", + maxLength: 120, + title: { en: "Role or company", tr: "Unvan ya da şirket" }, + }, + }, + }, + example: { + props: { author: "Ada Lovelace", role: "Analyst" }, + markdown: "This changed how we publish.", + }, + }, +]; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/Archive.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Archive.astro new file mode 100644 index 00000000..5a147890 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Archive.astro @@ -0,0 +1,108 @@ +--- +/** + * A category or tag archive page (RFC-0002 §7): breadcrumb through the parent categories, the + * term's name, and on page 1 its image and intro (markdown through the safe renderer); then the + * page's posts and real `<a>` pagination links (every page is its own canonical URL). + */ +import { type ArchiveView, viewPagePath } from "@cloudhal/astro-renderer/archive"; +import { renderMarkdown, safeHref, safeSrc } from "@cloudhal/astro-renderer/markdown"; +import { imageAttributes, type MediaContext } from "@cloudhal/astro-renderer/media"; +import type { ThemeStrings } from "../theme.ts"; +import Breadcrumbs from "./Breadcrumbs.astro"; +import EntryList from "./EntryList.astro"; + +interface Props { + archive: ArchiveView; + t: ThemeStrings; + /** Resolves the term image and `asset:` links in the intro (RFC-0004). */ + media?: MediaContext; +} +const { archive, t, media } = Astro.props; +const label = archive.term.labels[archive.locale]; +const kind = + archive.taxonomy.labels[archive.locale]?.singular ?? + Object.values(archive.taxonomy.labels)[0]?.singular; +const first = archive.page === 1; +const intro = first && label?.description ? renderMarkdown(label.description, media) : undefined; +const image = first && archive.term.image ? archive.term.image : undefined; +const asset = image?.assetId ? media?.assets?.get(image.assetId) : undefined; +const attrs = + asset && image + ? imageAttributes(asset, image, { + locale: archive.locale, + defaultLocale: media?.defaultLocale ?? archive.locale, + transform: media?.transform ?? "none", + ...(media?.budget ? { budget: media.budget } : {}), + sizes: "(min-width: 48rem) 48rem, 100vw", + priority: true, + ...(media?.preview ? { preview: true } : {}), + }) + : undefined; +const imageSrc = image && !attrs ? safeSrc(image.src) : undefined; +// An author's page (RFC-0019 §1): their role and profile links (safe schemes only). +const role = label?.role; +const links = (archive.term.links ?? []).flatMap((l) => { + const href = safeHref(l.href); + return href ? [{ ...l, href }] : []; +}); +const crumbs = [ + { name: t.home, href: "/" }, + ...archive.trail.map((s) => ({ name: s.name, href: s.href })), + { name: archive.shown.name }, +]; +--- +<section class="ch-archive" data-term-id={archive.term.id} data-taxonomy={archive.taxonomy.key}> + <Breadcrumbs label={t.breadcrumb} items={crumbs} /> + <header class="ch-archive-header"> + {kind && <p class="ch-archive-kind">{kind}</p>} + <h1>{archive.shown.name}</h1> + {role && <p class="ch-archive-role">{role}</p>} + {links.length > 0 && ( + <ul class="ch-archive-links"> + {links.map((l) => ( + <li><a href={l.href} rel={l.external ? "me noopener noreferrer" : "me"}>{l.label}</a></li> + ))} + </ul> + )} + <p class="ch-archive-meta"> + <span>{t.posts(archive.total)}</span> + {archive.feedPath && <a href={archive.feedPath}>{t.feed}</a>} + </p> + {attrs && ( + <img + class="ch-archive-image" + src={attrs.src} + srcset={attrs.srcset} + sizes={attrs.sizes} + alt={attrs.alt} + width={attrs.width || undefined} + height={attrs.height || undefined} + decoding="async" + style={attrs.style} + /> + )} + {image && imageSrc && ( + <img + class="ch-archive-image" + src={imageSrc} + alt={image.alt} + width={image.width} + height={image.height} + decoding="async" + /> + )} + {intro && <div class="ch-richtext" set:html={intro} />} + </header> + <EntryList entries={archive.entries} locale={archive.locale} media={media} /> + {archive.pages > 1 && ( + <nav aria-label={t.pagination} class="ch-pagination"> + {archive.page > 1 ? ( + <a href={viewPagePath(archive, archive.page - 1)} rel="prev">{t.newer}</a> + ) : <span />} + <span>{t.pageOf(archive.page, archive.pages)}</span> + {archive.page < archive.pages ? ( + <a href={viewPagePath(archive, archive.page + 1)} rel="next">{t.older}</a> + ) : <span />} + </nav> + )} +</section> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/Article.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Article.astro new file mode 100644 index 00000000..3c0f1e68 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Article.astro @@ -0,0 +1,110 @@ +--- +/** + * An article (RFC-0018 §3, the article view): kicker (the primary category), title, dek, byline + * (author, published, updated, reading time), cover, an "On this page" table of contents for + * longer pieces (headings carry ids; no JavaScript), then the body with the theme's own blocks and + * the corrections, newest first (RFC-0019 §2; the latest one is the "Updated" date). Collections + * with typed fields (RFC-0041 §9) show their values as a "Details" list under the cover. + */ +import { Body, EntryFields } from "@cloudhal/astro-renderer/components"; +import type { RenderedFields } from "@cloudhal/astro-renderer/fields"; +import { articleCorrections, articleOutline } from "@cloudhal/astro-renderer/kit"; +import { entryArticleParts } from "@cloudhal/astro-renderer/markdown"; +import type { MediaContext } from "@cloudhal/astro-renderer/media"; +import type { EntryTaxonomy } from "@cloudhal/astro-renderer/taxonomy"; +import type { ContentEntry } from "@cloudhal/core-contracts"; +import type { BlockType } from "@cloudhal/core-ts/blocks"; +import type { ThemeStrings } from "../theme.ts"; + +interface Props { + entry: ContentEntry; + terms: EntryTaxonomy; + locale: string; + t: ThemeStrings; + media?: MediaContext; + blocks?: Record<string, unknown>; + blockTypes?: readonly BlockType[]; + /** The entry's typed fields (`source.entryFields`); absent or empty draws nothing. */ + fields?: RenderedFields; +} +const { entry, terms, locale, t, media, blocks, blockTypes, fields } = Astro.props; +const parts = entryArticleParts(entry, locale, media); +const outline = articleOutline(entry); +const date = (iso: string) => + new Date(iso).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric" }); +const published = entry.publishedAt ?? undefined; +const corrections = articleCorrections(entry); +const newest = corrections.items[0]; +// "Updated": the latest correction; without one, only a real later edit, not the publish itself. +const updated = + corrections.latest ?? + (published && Date.parse(entry.updatedAt) - Date.parse(published) > 24 * 3600 * 1000 + ? entry.updatedAt + : undefined); +// RFC-0019 §1: the byline's authors link to their pages; without one, a person who wrote it. +const authors = terms.authors; +const author = + authors.length === 0 && entry.author.kind === "human" ? entry.author.name : undefined; +--- +<article class="ch-article" data-entry-id={entry.id}> + <header class="ch-article-head"> + {terms.section && ( + terms.section.href ? ( + <a class="ch-kicker" href={terms.section.href}>{terms.section.name}</a> + ) : ( + <span class="ch-kicker">{terms.section.name}</span> + ) + )} + <h1>{entry.title}</h1> + {entry.summary && <p class="ch-dek">{entry.summary}</p>} + <p class="ch-byline"> + {authors.length > 0 && ( + <span class="ch-byline__author"> + {t.byPrefix} + {authors.map((a, i) => ( + <Fragment> + {i > 0 && (i === authors.length - 1 ? t.and : ", ")} + {a.href ? <a href={a.href} rel="author">{a.name}</a> : a.name} + </Fragment> + ))} + </span> + )} + {author && <span class="ch-byline__author">{t.by(author)}</span>} + {published && <time datetime={published}>{date(published)}</time>} + {updated && <span>{t.updated(date(updated))}</span>} + {newest && <a href="#ch-corrections">{t.appended(t.correctionKind[newest.kind])}</a>} + <span>{t.minRead(outline.readingMinutes)}</span> + </p> + </header> + <Fragment set:html={parts.cover} /> + {fields && <EntryFields fields={fields} label={t.details} />} + {outline.toc.length >= 3 && ( + <details class="ch-toc" open> + <summary>{t.onThisPage}</summary> + <ol> + {outline.toc.map((item) => ( + <li data-depth={item.depth}><a href={`#${item.id}`}>{item.text}</a></li> + ))} + </ol> + </details> + )} + <div class="ch-richtext"> + <Body body={parts.body} media={parts.media} blocks={blocks} blockTypes={blockTypes} /> + </div> + {newest && ( + <section id="ch-corrections" class="ch-corrections" aria-labelledby="ch-corrections-title"> + <h2 id="ch-corrections-title">{t.corrections}</h2> + <ol> + {corrections.items.map((c) => ( + <li data-kind={c.kind}> + <p class="ch-correction__meta"> + <strong>{t.correctionKind[c.kind]}</strong> + <time datetime={c.at}>{date(c.at)}</time> + </p> + <p class="ch-correction__note" set:html={c.html} /> + </li> + ))} + </ol> + </section> + )} +</article> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/Breadcrumbs.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Breadcrumbs.astro new file mode 100644 index 00000000..1332f210 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Breadcrumbs.astro @@ -0,0 +1,25 @@ +--- +/** `Home › … › current`: the visible twin of the `BreadcrumbList` JSON-LD in `<head>`. */ +interface Props { + label: string; + items: Array<{ name: string; href?: string | undefined }>; +} +const { label, items } = Astro.props; +--- +{items.length > 1 && ( + <nav aria-label={label} class="ch-breadcrumbs"> + <ol> + {items.map((item, i) => ( + <li> + {i === items.length - 1 ? ( + <span aria-current="page">{item.name}</span> + ) : item.href ? ( + <a href={item.href}>{item.name}</a> + ) : ( + <span>{item.name}</span> + )} + </li> + ))} + </ol> + </nav> +)} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/EntryList.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/EntryList.astro new file mode 100644 index 00000000..702fa453 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/EntryList.astro @@ -0,0 +1,54 @@ +--- +/** + * A list of articles as cards: cover thumbnail (when the article has one), title, summary, + * publication date and reading time. + */ +import { readingMinutes } from "@cloudhal/astro-renderer/kit"; +import { renderBody } from "@cloudhal/astro-renderer/markdown"; +import { imageAttributes, type MediaContext } from "@cloudhal/astro-renderer/media"; +import { entryPath } from "@cloudhal/astro-renderer/routing"; +import type { ContentEntry } from "@cloudhal/core-contracts"; +import { strings } from "../theme.ts"; + +interface Props { + entries: ContentEntry[]; + locale: string; + media?: MediaContext | undefined; +} +const { entries, locale, media } = Astro.props; +const t = strings(locale); +const date = (iso: string) => + new Date(iso).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric" }); +const cover = (e: ContentEntry) => { + const asset = e.coverImage?.assetId ? media?.assets?.get(e.coverImage.assetId) : undefined; + if (!asset || !e.coverImage) return undefined; + return imageAttributes(asset, e.coverImage, { + locale, + defaultLocale: media?.defaultLocale ?? locale, + transform: media?.transform ?? "none", + ...(media?.budget ? { budget: media.budget } : {}), + sizes: "12rem", + ...(media?.preview ? { preview: true } : {}), + }); +}; +--- +<ul class="ch-entrylist"> + {entries.map((e) => { + const img = cover(e); + return ( + <li class="ch-card-entry" data-has-cover={img ? "yes" : undefined}> + {img && ( + <img class="ch-card-entry__cover" src={img.src} srcset={img.srcset} sizes={img.sizes} alt="" width={img.width || undefined} height={img.height || undefined} loading="lazy" decoding="async" /> + )} + <div class="ch-card-entry__body"> + <a href={entryPath(media?.permalinks, e)}>{e.title}</a> + {e.summary && <p>{e.summary}</p>} + <span class="ch-card-entry__meta"> + {e.publishedAt && <time datetime={e.publishedAt}>{date(e.publishedAt)}</time>} + <span>{t.minRead(readingMinutes(renderBody(e.body)))}</span> + </span> + </div> + </li> + ); + })} +</ul> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/LatestPosts.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/LatestPosts.astro new file mode 100644 index 00000000..0be2773d --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/LatestPosts.astro @@ -0,0 +1,30 @@ +--- +/** + * The theme's content list section (`components.contentList` in theme.ts): the section's entries + * sorted and trimmed as it asks, drawn as cards. + */ +import type { MediaContext } from "@cloudhal/astro-renderer/media"; +import type { ContentEntry, ContentListSection } from "@cloudhal/core-contracts"; +import EntryList from "./EntryList.astro"; + +interface Props { + section: ContentListSection; + entries: ContentEntry[]; + media?: MediaContext; +} +const { section, entries, media } = Astro.props; +const at = (e: ContentEntry) => e.publishedAt ?? e.createdAt; +const sorted = [...entries] + .sort((a, b) => + section.sort === "title" + ? a.title.localeCompare(b.title) + : section.sort === "oldest" + ? at(a).localeCompare(at(b)) + : at(b).localeCompare(at(a)), + ) + .slice(0, section.limit); +const locale = media?.locale ?? sorted[0]?.locale ?? "en"; +--- +<section class="ch-contentlist" data-section-id={section.id} data-kind="contentList" data-collection={section.collection}> + <EntryList entries={sorted} locale={locale} media={media} /> +</section> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/MenuPanel.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/MenuPanel.astro new file mode 100644 index 00000000..d4dc5aea --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/MenuPanel.astro @@ -0,0 +1,46 @@ +--- +/** + * The phone menu's content (RFC-0025 §2), in the header's disclosure, its drawer or full-screen + * dialog, and the `/menu` page: the header's menu with its groups as disclosures, the second menu, + * and the search form where the deployment answers searches. + */ +import type { ActiveNavItem } from "@cloudhal/astro-renderer/navigation"; +import NavLink from "./NavLink.astro"; +import NavMenu from "./NavMenu.astro"; + +interface Props { + nav: ActiveNavItem[]; + utility: ActiveNavItem[]; + search?: { label: string; placeholder: string; button: string; query?: string } | undefined; + words: { main: string; secondary: string }; + /** Keeps ids unique when the panel shows twice on a page. */ + idSuffix: string; + /** RFC-0026 26c: the site's icon weight. */ + iconWeight?: string | undefined; +} +const { nav, utility, search, words, idSuffix, iconWeight } = Astro.props; +const link = { on: "ch-menu__current", off: "" }; +--- +<div class="ch-menu__panel"> + {search && ( + <form class="ch-menu__search" method="get" action="/search" role="search"> + <label for={`ch-q-${idSuffix}`} class="sr-only">{search.label}</label> + <input id={`ch-q-${idSuffix}`} type="search" name="q" value={search.query ?? ""} maxlength="200" placeholder={search.placeholder} /> + <button type="submit" class="sr-only">{search.button}</button> + </form> + )} + {nav.length > 0 && ( + <nav aria-label={words.main} class="ch-menu__nav"> + <ul> + {nav.map((item) => <li>{item.children.length > 0 ? <NavMenu item={item} {...link} iconWeight={iconWeight} /> : <NavLink item={item} {...link} />}</li>)} + </ul> + </nav> + )} + {utility.length > 0 && ( + <nav aria-label={words.secondary} class="ch-menu__nav ch-menu__nav--small"> + <ul> + {utility.map((item) => <li><NavLink item={item} {...link} /></li>)} + </ul> + </nav> + )} +</div> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/NavLink.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/NavLink.astro new file mode 100644 index 00000000..ed3355bc --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/NavLink.astro @@ -0,0 +1,27 @@ +--- +/** + * One navigation link (RFC-0003 §7.3). `aria-current` is `page` on the exact match and `true` on a + * prefix or context match. External links never pass referrer or opener; only `newTab` opens one. + * A group shown without its children (past the theme's depth) is plain text. + */ +import type { ActiveNavItem } from "@cloudhal/astro-renderer/navigation"; + +interface Props { + item: ActiveNavItem; + /** Classes for the current (or in-trail) link and for the others. */ + on: string; + off: string; +} +const { item, on, off } = Astro.props; +--- +{item.href === undefined ? ( + <span>{item.label}</span> +) : ( + <a + href={item.href} + aria-current={item.current} + class={(item.current || item.inTrail ? on : off) || undefined} + rel={item.external ? "noopener noreferrer" : undefined} + target={item.newTab ? "_blank" : undefined} + >{item.label}</a> +)} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/NavMenu.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/NavMenu.astro new file mode 100644 index 00000000..19408e0a --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/NavMenu.astro @@ -0,0 +1,95 @@ +--- +/** + * An item with children as a disclosure (`<details>`, no JavaScript): the item's link (or a group's + * label) as the summary, its children below. Renders up to three levels; deeper items are left out + * (the menu's `maxDepth` is data, the theme decides what it shows). The active trail starts open. + * In the header's bar (`popover`, RFC-0025 §2) a group opens as a popover under its button instead, + * so it never pushes the bar: the browser opens and closes it (Escape, a click elsewhere) with no + * script, and a disclosure button never becomes `role=menu`. A top-level item with `display: mega` + * (RFC-0025 25d) opens one wide panel under the header instead: each child group is a column of its + * links (a child with no children of its own joins a first column of links). + */ +import type { ActiveNavItem } from "@cloudhal/astro-renderer/navigation"; +import { renderIcon } from "@cloudhal/core-ts/icons"; +import { iconSet } from "../icons.ts"; +import NavLink from "./NavLink.astro"; + +interface Props { + item: ActiveNavItem; + on: string; + off: string; + /** 1 for a top-level item. */ + level?: number; + /** A popover under a button (the header's bar), with this id. */ + popover?: string | undefined; + /** RFC-0026 26c: the site's icon weight. */ + iconWeight?: string | undefined; +} +const { item, on, off, level = 1, popover, iconWeight } = Astro.props; +const icon = (name: string, className?: string) => + renderIcon(name, { className, weight: iconWeight, set: iconSet }); +const MAX_LEVEL = 3; +const mega = popover !== undefined && level === 1 && item.display === "mega"; +const columns = mega ? item.children.filter((c) => c.children.length > 0) : []; +const loose = mega ? item.children.filter((c) => c.children.length === 0) : []; +--- +{popover ? ( + <div class="ch-nav-menu" data-popover=""> + {item.href && <NavLink item={item} on={on} off={off} />} + <button + type="button" + class:list={["ch-nav-menu__btn", item.current || item.inTrail ? on : off]} + popovertarget={popover} + aria-label={item.href ? item.label : undefined} + >{!item.href && item.label}<Fragment set:html={icon("chevron-down")} /></button> + <div popover="auto" id={popover} class:list={["ch-nav-menu__pop", mega && "ch-nav-menu__pop--mega"]}> + {mega ? ( + <div class="ch-mega"> + {loose.length > 0 && ( + <ul class="ch-mega__col"> + {loose.map((c) => <li><NavLink item={c} on={on} off={off} /></li>)} + </ul> + )} + {columns.map((col) => ( + <div class="ch-mega__col"> + <p class="ch-mega__heading">{col.href ? <NavLink item={col} on={on} off={off} /> : col.label}</p> + <ul> + {col.children.map((c) => <li><NavLink item={c} on={on} off={off} /></li>)} + </ul> + </div> + ))} + </div> + ) : ( + <ul> + {item.children.map((child) => ( + <li> + {child.children.length > 0 && level + 1 < MAX_LEVEL ? ( + <Astro.self item={child} on={on} off={off} level={level + 1} iconWeight={iconWeight} /> + ) : ( + <NavLink item={child} on={on} off={off} /> + )} + </li> + ))} + </ul> + )} + </div> + </div> +) : ( +<details class="ch-nav-menu" open={item.inTrail && level > 1 ? true : undefined}> + <summary class={(item.current || item.inTrail ? on : off) || undefined}> + {item.href ? <NavLink item={item} on={on} off={off} /> : <span>{item.label}</span>} + <Fragment set:html={icon("chevron-down", "ch-nav-menu__caret")} /> + </summary> + <ul> + {item.children.map((child) => ( + <li> + {child.children.length > 0 && level + 1 < MAX_LEVEL ? ( + <Astro.self item={child} on={on} off={off} level={level + 1} iconWeight={iconWeight} /> + ) : ( + <NavLink item={child} on={on} off={off} /> + )} + </li> + ))} + </ul> +</details> +)} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/NotFound.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/NotFound.astro new file mode 100644 index 00000000..058c0d48 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/NotFound.astro @@ -0,0 +1,27 @@ +--- +/** + * The built-in not-found page (404), in the reader's language: drawn when the site has no page at + * `/404` in that language (`@cloudhal/astro-renderer/not-found`). A link home, and the search form + * where the deployment answers searches; the middleware keeps both in the page's locale. + */ +import type { ThemeStrings } from "../theme.ts"; + +interface Props { + t: ThemeStrings; + /** Show the search form (the deployment answers `/search`). */ + search: boolean; +} +const { t, search } = Astro.props; +--- +<section class="ch-not-found ch-search" aria-labelledby="ch-not-found-title"> + <h1 id="ch-not-found-title">{t.notFoundTitle}</h1> + <p class="ch-not-found-text">{t.notFoundText}</p> + {search && ( + <form class="ch-search-form" method="get" action="/search" role="search"> + <label for="ch-not-found-q" class="ch-not-found-prompt">{t.notFoundSearch}</label> + <input id="ch-not-found-q" type="search" name="q" maxlength="200" placeholder={t.searchPlaceholder} autocomplete="off" /> + <button type="submit">{t.search}</button> + </form> + )} + <p class="ch-not-found-home"><a href="/">{t.notFoundHome}</a></p> +</section> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/PostTerms.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/PostTerms.astro new file mode 100644 index 00000000..1605b048 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/PostTerms.astro @@ -0,0 +1,33 @@ +--- +/** An article's public categories and tags as chips linking to their archives. */ +import type { EntryTaxonomy } from "@cloudhal/astro-renderer/taxonomy"; + +interface Props { + taxonomy: EntryTaxonomy; + locale: string; +} +const { taxonomy, locale } = Astro.props; +// Authors are the byline (RFC-0019 §1), not chips. +const groups = taxonomy.groups.filter((g) => g.taxonomy.key !== "author"); +--- +{groups.length > 0 && ( + <div class="ch-terms"> + {groups.map((group) => ( + <ul + class="ch-terms__group" + data-taxonomy={group.taxonomy.key} + aria-label={group.taxonomy.labels[locale]?.plural ?? group.taxonomy.key} + > + {group.terms.map((term) => ( + <li> + {term.href ? ( + <a class="ch-chip" href={term.href} rel="tag">{term.name}</a> + ) : ( + <span class="ch-chip">{term.name}</span> + )} + </li> + ))} + </ul> + ))} + </div> +)} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/RelatedPosts.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/RelatedPosts.astro new file mode 100644 index 00000000..73905ce6 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/RelatedPosts.astro @@ -0,0 +1,20 @@ +--- +/** "Related posts" under an article (`relatedEntries`: shared primary category or tags). */ +import type { MediaContext } from "@cloudhal/astro-renderer/media"; +import type { ContentEntry } from "@cloudhal/core-contracts"; +import EntryList from "./EntryList.astro"; + +interface Props { + title: string; + entries: ContentEntry[]; + locale: string; + media?: MediaContext | undefined; +} +const { title, entries, locale, media } = Astro.props; +--- +{entries.length > 0 && ( + <aside class="ch-related" aria-labelledby="ch-related-title"> + <h2 id="ch-related-title">{title}</h2> + <EntryList entries={entries} locale={locale} media={media} /> + </aside> +)} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/Search.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Search.astro new file mode 100644 index 00000000..59637bea --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/Search.astro @@ -0,0 +1,57 @@ +--- +/** + * The search page (RFC-0019 §3, RFC-0029 §2): an ordinary `<form method="get">` (no JavaScript), + * the results with their snippets (escaped text, matches in `<mark>`), and real links between + * result pages. + * Pages, topics and authors are found too; a topic or author shows its taxonomy ("Category"). + * Rendered only by deployments that answer searches; the page is never cached and never indexed. + */ +import { type SearchView, searchPageHref, snippetHtml } from "@cloudhal/astro-renderer/search"; +import type { ThemeStrings } from "../theme.ts"; + +interface Props { + search: SearchView; + locale: string; + t: ThemeStrings; +} +const { search, locale, t } = Astro.props; +const date = (iso: string) => + new Date(iso).toLocaleDateString(locale, { year: "numeric", month: "long", day: "numeric" }); +--- +<section class="ch-search" aria-labelledby="ch-search-title"> + <h1 id="ch-search-title">{t.search}</h1> + <form class="ch-search-form" method="get" action="/search" role="search"> + <label for="ch-search-q" class="sr-only">{t.searchLabel}</label> + <input id="ch-search-q" type="search" name="q" value={search.query} maxlength="200" placeholder={t.searchPlaceholder} autocomplete="off" /> + <button type="submit">{t.search}</button> + </form> + {search.stale && <p class="ch-search-note" role="status">{t.searchUpdating}</p>} + {search.query && ( + <p class="ch-search-summary" role="status"> + {search.total > 0 ? t.searchResults(search.total, search.query) : t.searchNone(search.query)} + </p> + )} + {search.hits.length > 0 && ( + <ol class="ch-search-results"> + {search.hits.map((hit) => ( + <li data-entry-id={hit.nodeId} data-kind={hit.kind ?? "article"}> + {hit.context && <p class="ch-search-context">{hit.context}</p>} + <h2><a href={hit.href}>{hit.title}</a></h2> + {hit.snippet && <p class="ch-search-snippet" set:html={snippetHtml(hit.snippet)} />} + {hit.publishedAt && <time datetime={hit.publishedAt}>{date(hit.publishedAt)}</time>} + </li> + ))} + </ol> + )} + {search.pages > 1 && ( + <nav aria-label={t.pagination} class="ch-pagination"> + {search.page > 1 ? ( + <a href={searchPageHref(search.query, search.page - 1)} rel="prev">{t.previous}</a> + ) : <span />} + <span>{t.pageOf(search.page, search.pages)}</span> + {search.page < search.pages ? ( + <a href={searchPageHref(search.query, search.page + 1)} rel="next">{t.next}</a> + ) : <span />} + </nav> + )} +</section> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/SearchDialog.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/SearchDialog.astro new file mode 100644 index 00000000..3c565a40 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/SearchDialog.astro @@ -0,0 +1,46 @@ +--- +/** + * The search modal (RFC-0029 §6): markup and words only; `/_cloudhal/search.1.js` wires it. Without + * the script the dialog never opens and the header form goes to `/search`. Inside, a form still + * goes to `/search` on Enter when no result is chosen. + */ +import { SEARCH_JSON_PATH, SEARCH_PATH } from "@cloudhal/astro-renderer/search"; +import type { ThemeStrings } from "../theme.ts"; + +interface Props { + label: string; + placeholder: string; + words: ThemeStrings["searchModal"]; +} +const { label, placeholder, words } = Astro.props; +const data = { + kinds: words.kinds, + all: words.all, + none: words.none, + recent: words.recent, + clear: words.clear, + limited: words.limited, + error: words.error, + updating: words.updating, + count: words.count, +}; +--- +<dialog id="ch-search" class="ch-search-dialog" aria-label={label} data-ch-search data-endpoint={SEARCH_JSON_PATH} data-page={SEARCH_PATH} data-words={JSON.stringify(data)}> + <div class="ch-search-dialog__panel"> + <form class="ch-search-dialog__bar" method="get" action={SEARCH_PATH} role="search"> + <label for="ch-search-dialog-q" class="sr-only">{label}</label> + <input id="ch-search-dialog-q" type="search" name="q" maxlength="200" autocomplete="off" spellcheck="false" placeholder={placeholder} data-ch-search-input /> + <button type="button" class="ch-search-dialog__close" data-ch-search-close>{words.close}</button> + </form> + <div class="ch-search-dialog__body"> + <div id="ch-search-list" class="ch-search-dialog__list" role="listbox" aria-label={label} data-ch-search-list></div> + <p class="ch-search-dialog__note" data-ch-search-note hidden></p> + </div> + <p class="ch-search-dialog__keys" aria-hidden="true"> + <span><kbd>↑</kbd><kbd>↓</kbd> {words.keys.move}</span> + <span><kbd>↵</kbd> {words.keys.open}</span> + <span><kbd>esc</kbd> {words.keys.close}</span> + </p> + <p class="sr-only" role="status" aria-live="polite" data-ch-search-status></p> + </div> +</dialog> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/components/blocks/Testimonial.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/components/blocks/Testimonial.astro new file mode 100644 index 00000000..80d8c601 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/components/blocks/Testimonial.astro @@ -0,0 +1,18 @@ +--- +/** + * The `testimonial` block (src/blocks.ts), in sections and inside bodies. `contentHtml` is the + * quote, already rendered by Cloudhal's safe markdown renderer; props were checked first. + */ +interface Props { + props: { author?: string; role?: string }; + contentHtml?: string; +} +const { props, contentHtml = "" } = Astro.props; +--- +<figure class="testimonial"> + <blockquote set:html={contentHtml} /> + <figcaption> + <span class="testimonial__author">{props.author}</span> + {props.role && <span class="testimonial__role">{props.role}</span>} + </figcaption> +</figure> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/env.d.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/env.d.ts new file mode 100644 index 00000000..61e0b265 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/env.d.ts @@ -0,0 +1,5 @@ +/// <reference types="astro/client" /> +declare module "virtual:cloudhal/config" { + const config: import("@cloudhal/astro-renderer").CloudhalRendererConfig; + export default config; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/fonts.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/fonts.ts new file mode 100644 index 00000000..9d982122 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/fonts.ts @@ -0,0 +1,31 @@ +/** + * The font files each pairing (RFC-0023 §2, `typePairing` in settings.ts) needs first: its latin + * subsets, preloaded by layouts/Base.astro so headings do not swap late. The latin-ext files + * (Turkish and other accented letters) load on demand through fonts.css's unicode ranges. + */ +export const PAIRING_PRELOADS: Readonly<Record<string, readonly string[]>> = { + editorial: [ + "/fonts/v1/fraunces-latin-wght-normal.woff2", + "/fonts/v1/inter-latin-wght-normal.woff2", + ], + modern: [ + "/fonts/v1/space-grotesk-latin-wght-normal.woff2", + "/fonts/v1/inter-latin-wght-normal.woff2", + ], + humanist: ["/fonts/v1/source-sans-3-latin-wght-normal.woff2"], + technical: [ + "/fonts/v1/jetbrains-mono-latin-wght-normal.woff2", + "/fonts/v1/inter-latin-wght-normal.woff2", + ], + classic: ["/fonts/v1/source-serif-4-latin-wght-normal.woff2"], +}; + +/** + * The same preloads as a `Link` response header. On a Cloudflare zone with Early Hints on, the + * edge sends it as a 103 before the page, so the fonts load while the Worker is still rendering. + */ +export function fontLinkHeader(hrefs: readonly string[]): string { + return hrefs + .map((h) => `<${h}>; rel=preload; as=font; type="font/woff2"; crossorigin`) + .join(", "); +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/icons.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/icons.ts new file mode 100644 index 00000000..dab0bad8 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/icons.ts @@ -0,0 +1,12 @@ +/** + * The theme's own icon drawings (RFC-0026 26d): names of the curated set this theme draws itself. + * Empty: every icon is Phosphor's, in the site's weight. `defineIconSet` checks each drawing + * (shapes only, `currentColor`), so an icon can never carry markup, links or scripts into pages. + * + * export const iconSet = defineIconSet({ + * star: { viewBox: "0 0 24 24", body: '<path d="M12 2l3 7h7l-6 4 2 7-6-4-6 4 2-7-6-4h7z"/>' }, + * }); + */ +import { defineIconSet } from "@cloudhal/core-ts/icons"; + +export const iconSet = defineIconSet({}); diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/layouts/Base.astro b/packages/cli/test/fixtures/theme-v0.5.0/src/layouts/Base.astro new file mode 100644 index 00000000..c456aca0 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/layouts/Base.astro @@ -0,0 +1,426 @@ +--- +import { CHROME_SCRIPT } from "@cloudhal/astro-renderer/chrome"; +import { SeoHead } from "@cloudhal/astro-renderer/components"; +import type { ActiveNavItem } from "@cloudhal/astro-renderer/navigation"; +import { PREFS_SCRIPT } from "@cloudhal/astro-renderer/prefs"; +import { SEARCH_SCRIPT } from "@cloudhal/astro-renderer/search-client"; +import type { HeadMeta } from "@cloudhal/astro-renderer/seo"; +import { themeVariables } from "@cloudhal/astro-renderer/theme-settings"; +import type { Site } from "@cloudhal/core-contracts"; +import { copyrightText, type ResolvedChrome, resolveChrome } from "@cloudhal/core-ts/chrome"; +import { renderIcon } from "@cloudhal/core-ts/icons"; +import MenuPanel from "../components/MenuPanel.astro"; +import NavLink from "../components/NavLink.astro"; +import NavMenu from "../components/NavMenu.astro"; +import SearchDialog from "../components/SearchDialog.astro"; +import { PAIRING_PRELOADS } from "../fonts.ts"; +import { iconSet } from "../icons.ts"; +import { themeSettings } from "../settings.ts"; +import type { ThemeStrings } from "../theme.ts"; +import "../styles/global.css"; + +interface Props { + site: Site; + meta: HeadMeta; + /** The `primary` menu, or the derived header when the site has none (RFC-0003 §7.3). */ + nav: ActiveNavItem[]; + /** The `footer` menu; undefined keeps the default footer links. */ + footer?: ActiveNavItem[] | undefined; + /** A preview of an embargoed or expired page or entry says so above it (RFC-0008 §7.3). */ + notice?: { state: string; at: string; text: string } | undefined; + /** RFC-0019 §3: the header's search form, where the deployment answers searches. */ + search?: + | { + label: string; + placeholder: string; + button: string; + query?: string; + /** RFC-0029 §6: the search modal's words; the header field opens it. */ + modal?: ThemeStrings["searchModal"]; + } + | undefined; + /** RFC-0024 §1: the page's own motion level, over the site's. */ + motion?: string | undefined; + /** RFC-0025: the page's header and footer (`resolveChrome`), and the menus they name by key. */ + chrome?: ResolvedChrome | undefined; + menus?: Record<string, ActiveNavItem[] | undefined> | undefined; + /** The chrome's own words, in the page's language. */ + words?: + | { + skip: string; + main: string; + secondary: string; + menu: string; + closeMenu: string; + footer: string; + legal: string; + social: string; + credit: string; + } + | undefined; + /** RFC-0025 25d: the bars' and toasts' words, in the page's language. */ + barWords?: Record<(typeof BAR_WORDS)[number], string> | undefined; + /** + * RFC-0033 §4: the reader's notice on a machine translation or a page shown in its original + * language, with a link to the original. + */ + translation?: + | { text: string; original?: { href: string; text: string; lang: string } } + | undefined; + /** RFC-0033 §4: the language switcher: each language's name in itself and its URL here. */ + languages?: + | { + label: string; + items: Array<{ locale: string; name: string; href: string; current: boolean }>; + } + | undefined; +} +const BAR_WORDS = [ + "announcement", + "dismiss", + "privacy", + "consentNotice", + "consentChoices", + "consentOk", + "consentAll", + "consentEssential", + "privacyPolicy", + "copied", + "linkCopied", +] as const; +const { + site, + meta, + nav, + footer, + notice, + search, + motion, + menus = {}, + words, + barWords, + translation, + languages, +} = Astro.props; +const chrome = Astro.props.chrome ?? resolveChrome(site); +const h = chrome.header; +const f = chrome.footer; +const w = words ?? { + skip: "Skip to content", + main: "Main", + secondary: "Secondary", + menu: "Menu", + closeMenu: "Close menu", + footer: "Footer", + legal: "Legal", + social: "Social links", + credit: "Built with Cloudhal", +}; +// The header's menu: the primary menu (or the derived header without one), else the one it names. +const headerNav = h.nav === "primary" ? nav : (menus[h.nav] ?? []); +const utilityNav = h.utility ? (menus[h.utility] ?? []) : []; +const legalNav = f.legal ? (f.legal === "footer" ? footer : menus[f.legal]) : undefined; +const brand = site.brand; +const SOCIAL: Record<string, string> = { + x: "X", + bluesky: "Bluesky", + mastodon: "Mastodon", + github: "GitHub", + linkedin: "LinkedIn", + instagram: "Instagram", + facebook: "Facebook", + youtube: "YouTube", + tiktok: "TikTok", + threads: "Threads", + rss: "RSS", + email: "Email", +}; +const social = f.social ? (brand?.social ?? []) : []; +const safe = (href: string) => (/^(https?:|mailto:|\/(?!\/)|#)/i.test(href) ? href : "#"); +// The actions this page can show (search only where the deployment answers searches). +const actions = h.actions.filter((a) => + a.kind === "button" + ? a.label && a.href + : a.kind === "locale" + ? languages + : a.kind === "search" && search, +); +// Without a header switcher, a multilingual site lists its languages in the footer (RFC-0033). +const footerLanguages = + languages && !actions.some((a) => a.kind === "locale") ? languages : undefined; +// The bar collapses into the phone menu below `collapseAt` (always for the minimal layout). +const collapse = h.layout === "minimal" ? "always" : h.collapseAt; +const dialog = + h.mobileStyle === "drawer-start" || + h.mobileStyle === "drawer-end" || + h.mobileStyle === "fullscreen"; +const searchAction = actions.some((a) => a.kind === "search") ? search : undefined; +// The chrome module, only where the header needs it (RFC-0025 §2): without it everything works. +// RFC-0025 25d: the bars, and the words they and the toasts speak. +const bw = barWords ?? { + announcement: "Announcement", + dismiss: "Dismiss", + privacy: "Privacy", + consentNotice: "This site keeps only what it needs to work: no tracking cookies.", + consentChoices: "Some parts of this site can load content from other services (videos, maps).", + consentOk: "OK", + consentAll: "Allow all", + consentEssential: "Only essential", + privacyPolicy: "Privacy policy", + copied: "Copied", + linkCopied: "Link copied", +}; +const announce = chrome.announcement; +const consent = chrome.consent.mode !== "none" ? chrome.consent : undefined; +// Read before the body paints, so a closed bar is never drawn (the edge cache ignores cookies). +const needsPrefs = Boolean(announce?.dismissible || consent); +const needsScript = + needsPrefs || + (!h.hidden && + (h.behavior !== "static" || + h.shrink || + h.transparent || + dialog || + headerNav.some((i) => i.children.length > 0))); +const copyright = f.copyright + ? copyrightText(f.copyright, site.name, new Date().getFullYear()) + : undefined; +// RFC-0026: icons in the site's weight (26c), the theme's own drawings over the set's (26d). +const iconWeight = String(site.theme.config?.iconWeight ?? "regular"); +const icon = (name: string, o: { label?: string; className?: string } = {}) => + renderIcon(name, { ...o, weight: iconWeight, set: iconSet }); +// The site's design settings (RFC-0017 §1): typed values only, defaults where unset. +const look = themeVariables(site, themeSettings); +// A page's own motion level wins over the site's (RFC-0024 §1). +if ( + motion && + ["none", "subtle", "standard", "expressive"].includes(motion) && + look.attributes["data-ch-motion"] +) + look.attributes["data-ch-motion"] = motion; +const fonts = PAIRING_PRELOADS[look.attributes["data-ch-type-pairing"] ?? ""] ?? []; +const header = { on: "text-zinc-900", off: "hover:text-zinc-900" }; +const below = { on: "text-zinc-700", off: "hover:text-zinc-700" }; +--- +<!doctype html> +<html lang={meta.lang} dir={meta.dir ?? "ltr"} {...look.attributes} data-ch-announcement={announce?.dismissible ? announce.id : undefined}> + <head> + {/* is:inline keeps it a classic script where it is: it must run before the body paints. */} + {needsPrefs && <script is:inline src={PREFS_SCRIPT.path}></script>} + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <SeoHead meta={meta} /> + {/* public/: static assets, answered before the Site Worker runs (no render, no analytics event) */} + <link rel="icon" href="/favicon.ico" sizes="32x32" /> + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> + <meta name="generator" content="Cloudhal" /> + {fonts.map((href) => <link rel="preload" href={href} as="font" type="font/woff2" crossorigin />)} + {look.style && <style set:html={look.style} />} + </head> + <body class="ch-body min-h-screen antialiased"> + <a class="ch-skip" href="#main">{w.skip}</a> + {announce && ( + <div class="ch-announcement" data-tone={announce.tone} role="region" aria-label={bw.announcement}> + <p class="ch-announcement__text"> + {announce.text} + {announce.link && <a href={safe(announce.link.href)}>{announce.link.label}</a>} + </p> + {announce.dismissible && ( + <button type="button" class="ch-announcement__close" data-ch-dismiss={announce.id} aria-label={bw.dismiss} hidden> + <Fragment set:html={icon("close")} /> + </button> + )} + </div> + )} + {!h.hidden && ( + <header + class="ch-header" + data-layout={h.layout} + data-behavior={h.behavior} + data-width={h.width} + data-surface={h.surface} + data-collapse={collapse} + data-shrink={h.shrink ? "" : undefined} + data-transparent={h.transparent ? "" : undefined} + data-over={h.transparent ? h.over : undefined} + > + <div class="ch-header__inner"> + <a href="/" class="ch-header__brand"> + {brand?.logo ? ( + <Fragment> + <img class="ch-logo" data-size={brand.logoHeight ?? "m"} data-variant={brand.logoDark ? "light" : undefined} src={brand.logo.src} alt={brand.logo.alt || site.name} /> + {brand.logoDark && <img class="ch-logo" data-size={brand.logoHeight ?? "m"} data-variant="dark" src={brand.logoDark.src} alt={brand.logoDark.alt || site.name} />} + </Fragment> + ) : ( + site.name + )} + </a> + {utilityNav.length > 0 && ( + <nav class="ch-header__utility" aria-label={w.secondary}> + {utilityNav.map((item) => <NavLink item={item} {...header} />)} + </nav> + )} + {headerNav.length > 0 && ( + <nav class="ch-header__nav" aria-label={w.main}> + {headerNav.map((item, i) => item.children.length > 0 ? <NavMenu item={item} {...header} popover={`ch-sub-${i}`} iconWeight={iconWeight} /> : <NavLink item={item} {...header} />)} + </nav> + )} + {actions.length > 0 && ( + <div class="ch-header__actions"> + {actions.map((a) => + a.kind === "button" && a.label && a.href ? ( + <a class={`ch-button ch-button--${a.variant ?? "primary"}`} href={safe(a.href)}>{a.label}</a> + ) : a.kind === "locale" && languages ? ( + <details class="ch-languages"> + <summary>{languages.label}</summary> + <ul> + {languages.items.map((l) => <li><a href={l.href} lang={l.locale} hreflang={l.locale} aria-current={l.current ? "true" : undefined}>{l.name}</a></li>)} + </ul> + </details> + ) : a.kind === "search" && search ? ( + <form class="ch-header-search" method="get" action="/search" role="search" data-ch-search-open={search.modal ? "" : undefined}> + <label for="ch-header-q" class="sr-only">{search.label}</label> + <input id="ch-header-q" type="search" name="q" value={search.query ?? ""} maxlength="200" placeholder={search.placeholder} /> + <button type="submit" class="sr-only">{search.button}</button> + </form> + ) : null, + )} + </div> + )} + {(headerNav.length > 0 || utilityNav.length > 0 || searchAction) && ( + <div class="ch-menu-slot"> + {dialog ? ( + <a class="ch-menu__open" href="/menu" data-ch-dialog="ch-menu"> + <Fragment set:html={icon("menu")} /> + {w.menu} + </a> + ) : ( + <details class="ch-menu"> + <summary class="ch-menu__open"> + <Fragment set:html={icon("menu")} /> + {w.menu} + </summary> + <MenuPanel iconWeight={iconWeight} nav={headerNav} utility={utilityNav} search={searchAction} words={w} idSuffix="drop" /> + </details> + )} + </div> + )} + </div> + </header> + )} + {!h.hidden && dialog && ( + <dialog id="ch-menu" class="ch-menu__dialog" data-style={h.mobileStyle} aria-label={w.menu}> + <div class="ch-menu__bar"> + <span class="ch-header__brand">{site.name}</span> + <form method="dialog"><button class="ch-menu__close" type="submit">{w.closeMenu}</button></form> + </div> + <MenuPanel iconWeight={iconWeight} nav={headerNav} utility={utilityNav} search={searchAction} words={w} idSuffix="dialog" /> + </dialog> + )} + <main id="main" tabindex="-1" class="mx-auto max-w-3xl px-4 pb-16"> + {notice && ( + <p + class="ch-gate-notice mb-6 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900" + role="status" + data-gate={notice.state} + data-gate-at={notice.at} + >{notice.text}</p> + )} + {translation && ( + <p class="ch-translation-notice mb-6 rounded-md border border-sky-300 bg-sky-50 px-4 py-3 text-sm text-sky-900" role="note"> + {translation.text} + {translation.original && <> <a href={translation.original.href} lang={translation.original.lang} hreflang={translation.original.lang}>{translation.original.text}</a></>} + </p> + )} + <slot /> + </main> + {!f.hidden && ( + <footer class="ch-footer" data-layout={f.layout} data-surface={f.surface}> + <div class="ch-footer__inner"> + {f.region && ( + <div class="ch-footer__region"> + <slot name="footer-region" /> + </div> + )} + {f.cta && ( + <div class="ch-footer__cta"> + <p class="ch-footer__cta-heading">{f.cta.heading}</p> + <a class="ch-button ch-button--primary" href={safe(f.cta.href)}>{f.cta.label}</a> + </div> + )} + {(f.columns.length > 0 || (f.layout !== "minimal" && f.layout !== "simple" && (f.tagline || social.length > 0))) && ( + <div class="ch-footer__top"> + <div class="ch-footer__about"> + <a href="/" class="ch-footer__brand">{brand?.logo ? <img class="ch-logo" data-size="s" src={brand.logo.src} alt={brand.logo.alt || site.name} /> : site.name}</a> + {f.tagline && <p class="ch-footer__tagline">{f.tagline}</p>} + {social.length > 0 && ( + <ul class="ch-footer__social" aria-label={w.social}> + {social.map((l) => <li><a href={safe(l.href)} rel="me noopener" aria-label={SOCIAL[l.network] ?? l.network} class="ch-social-link"><Fragment set:html={icon(l.network)} /></a></li>)} + </ul> + )} + </div> + {f.columns.map((c) => (menus[c.menu] ?? []).length > 0 && ( + <nav class="ch-footer__col" aria-label={c.heading}> + <p class="ch-footer__heading">{c.heading}</p> + <ul> + {(menus[c.menu] ?? []).flatMap((item) => [item, ...item.children]).map((item) => <li><NavLink item={item} {...below} /></li>)} + </ul> + </nav> + ))} + </div> + )} + {footerLanguages && ( + <nav class="ch-footer__languages" aria-label={footerLanguages.label}> + <ul> + {footerLanguages.items.map((l) => <li><a href={l.href} lang={l.locale} hreflang={l.locale} aria-current={l.current ? "true" : undefined}>{l.name}</a></li>)} + </ul> + </nav> + )} + <div class="ch-footer__legal"> + {copyright && <span>{copyright}</span>} + {f.layout === "simple" && f.tagline && <span>{f.tagline}</span>} + {f.layout === "simple" && social.length > 0 && ( + <ul class="ch-footer__social" aria-label={w.social}> + {social.map((l) => <li><a href={safe(l.href)} rel="me noopener" aria-label={SOCIAL[l.network] ?? l.network} class="ch-social-link"><Fragment set:html={icon(l.network)} /></a></li>)} + </ul> + )} + {f.credit && <span>{w.credit}</span>} + {legalNav ? ( + legalNav.length > 0 && ( + <nav class="flex flex-wrap gap-4" aria-label={f.legal === "footer" ? w.footer : w.legal}> + {legalNav.map((item) => item.children.length > 0 ? <NavMenu item={item} {...below} iconWeight={iconWeight} /> : <NavLink item={item} {...below} />)} + </nav> + ) + ) : ( + f.fallbackRss && <a href="/rss.xml" class="hover:text-zinc-700">RSS</a> + )} + </div> + </div> + </footer> + )} + {consent && ( + <div class="ch-consent" data-ch-bar data-ch-consent-bar data-mode={consent.mode} role="region" aria-label={bw.privacy}> + <p class="ch-consent__text"> + {consent.mode === "choices" ? bw.consentChoices : bw.consentNotice} + {consent.policy && <a href={safe(consent.policy)}>{bw.privacyPolicy}</a>} + </p> + <div class="ch-consent__actions"> + {consent.mode === "choices" ? ( + <Fragment> + <button type="button" class="ch-button ch-button--secondary" data-ch-consent-choice="essential" hidden>{bw.consentEssential}</button> + <button type="button" class="ch-button ch-button--primary" data-ch-consent-choice="all" hidden>{bw.consentAll}</button> + </Fragment> + ) : ( + <button type="button" class="ch-button ch-button--primary" data-ch-consent-choice="ok" hidden>{bw.consentOk}</button> + )} + </div> + </div> + )} + {/* RFC-0025 25d: toasts land here (a live region already in the page, so they are announced). */} + <div class="ch-toasts" data-ch-toasts role="status" aria-live="polite" data-copied={bw.copied} data-link-copied={bw.linkCopied}></div> + {needsScript && <script type="module" src={CHROME_SCRIPT.path}></script>} + {/* RFC-0029 §6: the search modal, where the deployment answers searches. */} + {search?.modal && <SearchDialog label={search.label} placeholder={search.placeholder} words={search.modal} />} + {search?.modal && <script type="module" src={SEARCH_SCRIPT.path}></script>} + </body> +</html> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/middleware.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/middleware.ts new file mode 100644 index 00000000..96951f7c --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/middleware.ts @@ -0,0 +1,2 @@ +// RFC-0033 §4: internal links of a localized page stay in its locale. +export { onRequest } from "@cloudhal/astro-renderer/locale-middleware"; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path].astro b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path].astro new file mode 100644 index 00000000..198ebff9 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path].astro @@ -0,0 +1,467 @@ +--- +import config from "virtual:cloudhal/config"; +import { archiveSummaries } from "@cloudhal/astro-renderer/archive"; +import { + addCacheTags, + listCacheTag, + nodeCacheTag, + termCacheTag, +} from "@cloudhal/astro-renderer/cache-tags"; +import { commentsJsonLd, loadComments } from "@cloudhal/astro-renderer/comments"; +import { Comments, Sections } from "@cloudhal/astro-renderer/components"; +import { EMBED_HEADER, usesEmbeds } from "@cloudhal/astro-renderer/embeds"; +import type { RenderedFields } from "@cloudhal/astro-renderer/fields"; +import { gateNotice } from "@cloudhal/astro-renderer/gates"; +import { + alternatesFor, + linkLocalizer, + localeRequest, + localeUrl, + localizedPath, + localizeHead, +} from "@cloudhal/astro-renderer/locales"; +import { + loadAssetIndex, + type MediaContext, + placeholderBudget, +} from "@cloudhal/astro-renderer/media"; +import { + type ActiveNavItem, + type ActiveRoute, + activeItem, + derivedNavigation, + markActive, + markCurrent, + navigationCacheTags, +} from "@cloudhal/astro-renderer/navigation"; +import { notFoundPage, notFoundPageIds } from "@cloudhal/astro-renderer/not-found"; +import { resolveRoute, staticPaths } from "@cloudhal/astro-renderer/routing"; +import { getSource, peerEditions } from "@cloudhal/astro-renderer/runtime"; +import { searchAvailable } from "@cloudhal/astro-renderer/search"; +import { + archiveHead, + entryHead, + pageHead, + searchHead, + siteOrigin, +} from "@cloudhal/astro-renderer/seo"; +import { loadSurfaceSources, surfaceSources } from "@cloudhal/astro-renderer/surfaces"; +import { + entryTaxonomy, + loadTaxonomyIndex, + relatedEntries, + termAncestors, +} from "@cloudhal/astro-renderer/taxonomy"; +import { themeVariables } from "@cloudhal/astro-renderer/theme-settings"; +import type { ContentEntry } from "@cloudhal/core-contracts"; +/** + * One route for the whole graph (RFC-0002 §7 order): a Page answers at its `path`; an article at + * its collection's permalink (`/<collection>/<slug>` by default, RFC-0038); a category or tag archive at `<basePath>/<slug>[/page/<n>]`. Anything + * else asks the site's redirects (301 with the query kept), then answers 410 for a retired term + * or 404. Term feeds live next door in `[...path]/rss.xml.ts`. The header renders the `primary` + * menu, or the derived header without one; a `footer` menu replaces the footer links (RFC-0003). + * Articles end with reader comments when the site has them on (RFC-0007; `?comments=<n>` pages). + * `/search?q=` is the site search (RFC-0019 §3) where the deployment answers it: never cached, + * never indexed, and its form sits in the header. A miss answers 404 with the site's page at + * `/404` in the request's locale, else the theme's own not-found page in that locale. + */ +import { resolveChrome } from "@cloudhal/core-ts/chrome"; +import { blockTypes } from "../blocks.ts"; +import Archive from "../components/Archive.astro"; +import Article from "../components/Article.astro"; +import Breadcrumbs from "../components/Breadcrumbs.astro"; +import MenuPanel from "../components/MenuPanel.astro"; +import NotFound from "../components/NotFound.astro"; +import PostTerms from "../components/PostTerms.astro"; +import RelatedPosts from "../components/RelatedPosts.astro"; +import Search from "../components/Search.astro"; +import { fontLinkHeader, PAIRING_PRELOADS } from "../fonts.ts"; +import { iconSet } from "../icons.ts"; +import Base from "../layouts/Base.astro"; +import { themeSettings } from "../settings.ts"; +import { + ARCHIVE_PAGE_SIZE, + blocks, + collectionIndex, + components, + NAV_CATEGORIES, + RELATED_POSTS, + strings, +} from "../theme.ts"; + +export async function getStaticPaths() { + const source = await getSource(config); + const site = await source.site(); + const { pages } = await staticPaths(source, { perPage: ARCHIVE_PAGE_SIZE }); + // RFC-0025 §2: the phone menu's page, unless the site has a page there. + if (!pages.includes("/menu")) pages.push("/menu"); + // RFC-0033: every other locale on this host under its prefix (archives follow in a later step). + const domains = site.localization?.domains ?? {}; + const urls = pages.map((p) => localizedPath(site, site.defaultLocale, p)); + for (const locale of site.locales) + if (locale !== site.defaultLocale && domains[locale] === undefined) + for (const p of [ + ...(await staticPaths(source, { perPage: ARCHIVE_PAGE_SIZE, locale })).pages, + "/menu", + ]) + urls.push(localizedPath(site, locale, p)); + return [...new Set(urls)].map((path) => ({ + params: { path: path === "/" ? undefined : path.slice(1) }, + })); +} + +const source = await getSource(config, Astro); +if (!source) return new Response("This site has not been published yet.", { status: 503 }); +const site = await source.site(); +const requested = Astro.params.path ? `/${Astro.params.path}` : "/"; +// RFC-0033 §4: the locale is in the path prefix or the host; a non-canonical locale URL moves. +const lr = localeRequest(site, Astro.url.host, requested); +if (lr.redirect !== undefined) + return new Response(null, { + status: 301, + headers: { location: `${lr.redirect}${Astro.url.search}` }, + }); +const locale = lr.locale; +const path = lr.path; +let route = await resolveRoute(source, path, { + perPage: ARCHIVE_PAGE_SIZE, + search: Astro.url.search, + locale, +}); +// RFC-0033 §4: a site may show a missing translation in its original language, with a notice, +// never indexed and never in `hreflang` (`localization.fallback`). +let shownIn = locale; +if ( + route.kind === "not-found" && + locale !== site.defaultLocale && + site.localization?.fallback === "default-with-notice" +) { + const original = await resolveRoute(source, path, { + perPage: ARCHIVE_PAGE_SIZE, + search: Astro.url.search, + locale: site.defaultLocale, + }); + if (original.kind === "page" || original.kind === "entry") { + route = original; + shownIn = site.defaultLocale; + } +} +if (route.kind === "redirect") + return new Response(null, { + status: route.status, + headers: { + location: route.location.startsWith("/") + ? localizedPath(site, locale, route.location) + : route.location, + }, + }); +if (route.kind === "gone") return new Response(null, { status: 410 }); +// RFC-0025 §2: `/menu` is the phone menu as a page (the menu link's target without JavaScript), +// unless the site has a page there. +const isMenu = route.kind === "not-found" && path === "/menu"; +// Reader comments (RFC-0007): tags the response `cloudhal-comments-<id>` and reads the thread; +// `?comments=<n>` past the last page is a miss. +const comments = + route.kind === "entry" + ? await loadComments(config, Astro, route.entry, { locale, site }) + : undefined; +// A miss (RFC-0033 §4) answers 404 in the request's locale: the site's page at `/404` (its +// translation, or the original under `default-with-notice`), else the theme's NotFound below. +// The authored page answers 404 at its own path too. +const notFound = + (!isMenu && (route.kind === "not-found" || route.kind === "feed")) || + comments?.notFound === true || + (route.kind === "page" && notFoundPageIds(await source.pages()).has(route.page.id)); +if (notFound) { + Astro.response.status = 404; + const authored = route.kind === "page" ? route.page : await notFoundPage(source, site, locale); + route = authored ? { kind: "page", page: authored } : { kind: "not-found" }; + shownIn = authored?.locale ?? locale; +} + +// Search results (RFC-0019 §3) are per query: never in the edge cache, never in search engines. +if (route.kind === "search") { + Astro.response.headers.set("cache-control", "no-store"); + Astro.response.headers.set("cloudflare-cdn-cache-control", "no-store"); + Astro.response.headers.set("x-robots-tag", "noindex"); +} +const origin = siteOrigin(site, Astro.url); +// Internal links of a localized page stay in its locale (`src/middleware.ts`). +const links = await linkLocalizer(source, locale, origin); +if (links) (Astro.locals as { cloudhalLinks?: (p: string) => string }).cloudhalLinks = links; +// Media (RFC-0004): assets resolve `assetId` and `asset:<id>`; previews never use transformations. +const media: MediaContext = { + assets: await loadAssetIndex(source), + locale, + defaultLocale: site.defaultLocale, + transform: config.media?.transform ?? "none", + preview: Astro.response.headers.get("x-cloudhal-mode") === "preview", + onMiss: (id) => + console.warn(`cloudhal: ${path} refers to ${id}, which this version does not have`), + // RFC-0026: icons in the site's weight, the theme's own drawings over the set's. + icons: { weight: String(site.theme.config?.iconWeight ?? "regular"), set: iconSet }, + // RFC-0028: one placeholder allowance for the whole page. + budget: placeholderBudget(), + // RFC-0038: entry links follow the site's collection permalinks. + permalinks: site, +}; +const t = strings(locale, site.theme.config?.strings); +const index = route.kind === "archive" ? route.index : await loadTaxonomyIndex(source, locale); +const primary = await source.navigation("primary", locale, { collectionIndex }); +const footerMenu = await source.navigation("footer", locale, { collectionIndex }); +const derivedCategories = !primary && NAV_CATEGORIES; +const needsEntries = derivedCategories || (route.kind === "entry" && RELATED_POSTS > 0); +const all = needsEntries ? await source.allEntries(locale) : []; +const categories = derivedCategories + ? archiveSummaries( + index, + all.filter((e) => e.status === "published"), + ).filter((a) => a.taxonomy.key === "category" && !a.term.parentId) + : []; +// The active item: exact path, longest prefix, then the entry's category or collection. +const activeRoute: ActiveRoute = { + path, + ...(route.kind === "entry" ? { entry: route.entry } : {}), + ...(route.kind === "archive" ? { archiveTermId: route.archive.term.id } : {}), +}; +const ancestors = (id: string) => { + const term = index.terms.get(id); + return term ? termAncestors(index, term).map((a) => a.id) : []; +}; +// The derived header lists top-level pages, never the not-found page. +const allPages = await source.pages(); +const notFoundIds = notFoundPageIds(allPages); +const navPages = allPages.filter((p) => !notFoundIds.has(p.id)); +const nav = primary + ? markActive(primary, activeItem(primary, activeRoute, ancestors)) + : markCurrent(derivedNavigation(site, navPages, categories, locale), path); +const footer = footerMenu + ? markActive(footerMenu, activeItem(footerMenu, activeRoute, ancestors)) + : undefined; +// RFC-0025: the page's header and footer, and the menus they name beyond primary and footer. +const chrome = resolveChrome(site, route.kind === "page" ? route.page : undefined); +const menus: Record<string, ActiveNavItem[] | undefined> = { primary: nav, footer }; +const extraMenus = await Promise.all( + chrome.menus + .filter((key) => !(key in menus)) + .map(async (key) => [key, await source.navigation(key, locale, { collectionIndex })] as const), +); +for (const [key, menu] of extraMenus) + menus[key] = menu ? markActive(menu, activeItem(menu, activeRoute, ancestors)) : undefined; +// Edge-cache tags next to the site tag (published responses on Cloudflare only), so a publish +// purges only the responses that show what changed (`cache-tags.ts`). The tags a purge must reach +// come first; the header's categories (names shown on every page) last. +const tags: string[] = []; + +let entries: Record<string, ContentEntry[]> = {}; +let surfaces: Awaited<ReturnType<typeof loadSurfaceSources>> = {}; +let terms = entryTaxonomy(undefined, { tags: [] }); +let related: ContentEntry[] = []; +let fields: RenderedFields | undefined; +if (route.kind === "page") { + tags.push(nodeCacheTag(route.page.id)); + // Lists of the site's entries change with any entry: the site's list tag covers them. + if ( + route.page.sections.some( + (s) => s.kind === "contentList" || (s.kind === "surface" && surfaceSources(s).length > 0), + ) + ) + tags.push(listCacheTag(site.id)); + // Generated UI (RFC-0020 §3): entry sources load from the same source as the page. + surfaces = await loadSurfaceSources(source, route.page.sections); + const collections = [ + ...new Set( + route.page.sections.filter((s) => s.kind === "contentList").map((s) => s.collection), + ), + ]; + entries = Object.fromEntries( + await Promise.all(collections.map(async (c) => [c, await source.entries(c, locale)])), + ); + // A section with a `filter` (RFC-0002 §5) lists its own entries, keyed by section id. + for (const s of route.page.sections) + if (s.kind === "contentList" && s.filter) + entries[s.id] = await source + .entriesByFilter(`collection:${s.collection}+${s.filter}`, { locale }) + .catch((err: unknown) => { + console.warn(`cloudhal: section ${s.id} has an invalid filter: ${String(err)}`); + return []; + }); +} else if (route.kind === "entry") { + terms = entryTaxonomy(index, route.entry); + related = relatedEntries(route.entry, all, index, RELATED_POSTS); + // RFC-0041 §9: typed fields in the entry's language; values read from the original tag it too. + fields = await source.entryFields?.(route.entry, { media, words: { yes: t.yes, no: t.no } }); + const group = route.entry.translation?.group; + if (group && Object.values(fields ?? {}).some((f) => f.fromOriginal)) + tags.push(nodeCacheTag(group)); + tags.push( + nodeCacheTag(route.entry.id), + // the related posts it links to: a retitled or removed one purges this page too + ...related.map((e) => nodeCacheTag(e.id)), + ...terms.groups.flatMap((g) => g.terms.map((s) => termCacheTag(s.id))), + ...terms.trail.map((s) => termCacheTag(s.id)), + ); +} else if (route.kind === "archive") { + tags.push( + // which entries the archive lists (and how many pages it has) changes with its membership + listCacheTag(route.archive.term.id), + termCacheTag(route.archive.term.id), + ...route.archive.trail.map((s) => termCacheTag(s.id)), + ...route.archive.entries.map((e) => nodeCacheTag(e.id)), + ); +} +tags.push( + ...categories.map((a) => termCacheTag(a.term.id)), + ...navigationCacheTags(primary), + ...navigationCacheTags(footerMenu), + ...extraMenus.flatMap(([, menu]) => navigationCacheTags(menu)), +); +// RFC-0025 §2: the footer's surface region loads its entry sources like a page's surfaces. +const regionSources = chrome.footer.region + ? await loadSurfaceSources(source, [chrome.footer.region]) + : {}; +if (chrome.footer.region && surfaceSources(chrome.footer.region).length > 0) + tags.push(listCacheTag(site.id)); +addCacheTags(Astro.response.headers, tags); + +// Video players (RFC-0015 §4): the Site Worker allows their frames on this page only. +if ( + (route.kind === "page" && usesEmbeds({ sections: route.page.sections })) || + (route.kind === "entry" && + route.entry.body.format === "markdown" && + usesEmbeds({ markdown: route.entry.body.value })) +) + Astro.response.headers.set(EMBED_HEADER, "1"); + +// Time gates (RFC-0008 §7.3): the published source never returns an embargoed or expired page or +// entry (it is a 404), so a gate that is closed here means a preview or `cloudhal dev`: say so. +const gated = route.kind === "page" ? route.page : route.kind === "entry" ? route.entry : undefined; +const gate = gated ? gateNotice(gated, site, locale, Date.now()) : undefined; +const notice = gate && { state: gate.state, at: gate.at, text: t[gate.state](gate.when) }; + +const head = + route.kind === "page" + ? pageHead(site, route.page, origin, media) + : route.kind === "entry" + ? entryHead(site, route.entry, origin, terms, media, commentsJsonLd(comments, origin)) + : route.kind === "archive" + ? archiveHead(site, route.archive, origin, media) + : route.kind === "search" + ? searchHead(site, route.search.query, origin, locale) + : { + ...searchHead(site, "", origin, locale), + title: `${isMenu ? t.menu : t.notFoundTitle} · ${site.name}`, + canonical: `${origin}${isMenu ? "/menu" : path}`, + }; +// RFC-0033 §4: self-canonical in the locale, `lang`/`dir`, and the `hreflang` set from the group. +const fallback = shownIn !== locale; +const alternates = + gated && !fallback && !notFound + ? // RFC-0034 §6: language editions published by other sites of the project, on their hosts. + await alternatesFor(source, gated, origin, await peerEditions(config, gated)) + : []; +const localized = localizeHead(head, site, { + locale: shownIn, + origin, + alternates, + ...(gated?.translation ? { translation: gated.translation } : {}), +}); +const meta = fallback || notFound ? { ...localized, robots: "noindex,follow" } : localized; +Astro.response.headers.set("content-language", shownIn); +// The pairing's fonts as a Link header too (Early Hints), not only as <link rel=preload> in <head>. +const pairing = themeVariables(site, themeSettings).attributes["data-ch-type-pairing"]; +const preloads = PAIRING_PRELOADS[pairing ?? ""] ?? []; +if (preloads.length) Astro.response.headers.set("link", fontLinkHeader(preloads)); +// The reader's notice (RFC-0033 §4): machine output until a person reviews it (or always, when +// the site asks), a page shown in its original language, each with a link to the original. +const original = alternates.find((a) => a.original && !a.current); +const tr = gated?.translation; +const machine = + tr?.method === "machine" || + (tr?.method === "machine-reviewed" && site.localization?.label === "always"); +const translationNotice = fallback + ? { text: t.notTranslated } + : machine + ? { + text: tr?.review ? t.machineReviewed : t.machineTranslated, + ...(original + ? { original: { href: original.href, text: t.readOriginal, lang: original.locale } } + : {}), + } + : undefined; +// The language switcher: this page's versions, else each language's home page. +const nativeName = (l: string) => { + try { + return new Intl.DisplayNames([l], { type: "language" }).of(l) ?? l; + } catch { + return l; + } +}; +// RFC-0034 §6: languages a language edition publishes (another site) follow the site's own. +const editionLocales = alternates + .map((a) => a.locale) + .filter((l) => l !== "x-default" && !site.locales.includes(l)); +const languages = + site.locales.length + editionLocales.length > 1 + ? { + label: t.languages, + items: [...site.locales, ...editionLocales].map((l) => ({ + locale: l, + name: nativeName(l), + href: alternates.find((a) => a.locale === l)?.href ?? localeUrl(site, l, "/", origin), + current: l === locale, + })), + } + : undefined; +const searchForm = searchAvailable(config, source) + ? { + label: t.searchLabel, + placeholder: t.searchPlaceholder, + button: t.search, + modal: t.searchModal, + ...(route.kind === "search" ? { query: route.search.query } : {}), + } + : undefined; +--- +<Base site={site} meta={meta} nav={nav} footer={footer} notice={notice} translation={translationNotice} languages={languages} search={searchForm} motion={route.kind === "page" ? route.page.motion : undefined} chrome={chrome} menus={menus} words={{ skip: t.skipToContent, main: t.mainMenu, secondary: t.secondaryMenu, menu: t.menu, closeMenu: t.closeMenu, footer: t.footerMenu, legal: t.legalMenu, social: t.socialLinks, credit: t.builtWith }} barWords={{ announcement: t.announcement, dismiss: t.dismiss, privacy: t.privacy, consentNotice: t.consentNotice, consentChoices: t.consentChoices, consentOk: t.consentOk, consentAll: t.consentAll, consentEssential: t.consentEssential, privacyPolicy: t.privacyPolicy, copied: t.copied, linkCopied: t.linkCopied }}> + {chrome.footer.region && ( + <Fragment slot="footer-region"> + <Sections sections={[chrome.footer.region]} entries={{}} components={components} blocks={blocks} blockTypes={blockTypes} media={media} surfaceSources={regionSources} /> + </Fragment> + )} + {isMenu && ( + <div class="ch-menu-page"> + <h1>{t.menu}</h1> + <MenuPanel + iconWeight={media.icons?.weight} + nav={chrome.header.nav === "primary" ? nav : (menus[chrome.header.nav] ?? [])} + utility={chrome.header.utility ? (menus[chrome.header.utility] ?? []) : []} + search={searchForm} + words={{ main: t.mainMenu, secondary: t.secondaryMenu }} + idSuffix="page" + /> + </div> + )} + {route.kind === "page" && ( + <Sections sections={route.page.sections} entries={entries} components={components} blocks={blocks} blockTypes={blockTypes} media={media} surfaceSources={surfaces} /> + )} + {route.kind === "entry" && ( + <Fragment> + <Breadcrumbs + label={t.breadcrumb} + items={[ + { name: t.home, href: "/" }, + ...terms.trail.map((s) => ({ name: s.name, href: s.href })), + { name: route.entry.title }, + ]} + /> + <Article entry={route.entry} terms={terms} locale={locale} t={t} media={media} blocks={blocks} blockTypes={blockTypes} fields={fields} /> + <PostTerms taxonomy={terms} locale={locale} /> + <RelatedPosts title={t.related} entries={related} locale={locale} media={media} /> + <Comments view={comments} /> + </Fragment> + )} + {route.kind === "archive" && <Archive archive={route.archive} t={t} media={media} />} + {route.kind === "search" && <Search search={route.search} locale={locale} t={t} />} + {route.kind === "not-found" && !isMenu && <NotFound t={t} search={searchForm !== undefined} />} +</Base> diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/llms.txt.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/llms.txt.ts new file mode 100644 index 00000000..97a37e4f --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/llms.txt.ts @@ -0,0 +1,40 @@ +import config from "virtual:cloudhal/config"; +import { addCacheTags, listCacheTag } from "@cloudhal/astro-renderer/cache-tags"; +import { llmsTxt } from "@cloudhal/astro-renderer/locale-feeds"; +import { localeRequest } from "@cloudhal/astro-renderer/locales"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; + +/** Static builds: `/<locale>/llms.txt` for every other locale served on this host. */ +export async function getStaticPaths() { + const source = await getSource(config); + const site = await source.site(); + const domains = site.localization?.domains ?? {}; + return site.locales + .filter( + (l) => + (l !== site.defaultLocale || site.localization?.routing === "prefix-all") && !domains[l], + ) + .map((l) => ({ params: { path: l } })); +} + +/** A locale's `llms.txt` (RFC-0033): `/<locale>/llms.txt`; any other path is not found. */ +export const GET: APIRoute = async ({ request, url, params }) => { + const headers = new Headers(); + const source = await getSource(config, { request, response: { headers } }); + if (!source) return new Response("This site has not been published yet.", { status: 503 }); + const site = await source.site(); + const lr = localeRequest(site, url.host, `/${params.path ?? ""}`); + if (lr.path !== "/") return new Response(null, { status: 404 }); + if (lr.redirect !== undefined) + return new Response(null, { + status: 301, + headers: { location: `${lr.redirect === "/" ? "" : lr.redirect}/llms.txt` }, + }); + headers.set("content-type", "text/plain; charset=utf-8"); + addCacheTags(headers, [listCacheTag(site.id)]); + return new Response(await llmsTxt(source, { origin: siteOrigin(site, url), locale: lr.locale }), { + headers, + }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/rss.xml.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/rss.xml.ts new file mode 100644 index 00000000..cd4a49b6 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/rss.xml.ts @@ -0,0 +1,93 @@ +import config from "virtual:cloudhal/config"; +import { + addCacheTags, + listCacheTag, + nodeCacheTag, + termCacheTag, +} from "@cloudhal/astro-renderer/cache-tags"; +import { archiveFeedXml, FEED_LIMIT } from "@cloudhal/astro-renderer/feeds"; +import { localeFeedXml } from "@cloudhal/astro-renderer/locale-feeds"; +import { localeRequest, localeUrl, localizedPath } from "@cloudhal/astro-renderer/locales"; +import { loadAssetIndex } from "@cloudhal/astro-renderer/media"; +import { resolveRoute, staticPaths } from "@cloudhal/astro-renderer/routing"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; +import { ARCHIVE_PAGE_SIZE } from "../../theme.ts"; + +const SUFFIX = "/rss.xml"; + +/** + * Static builds: one feed per served archive of a taxonomy with `feed: true`, in every locale on + * this host, and each other locale's site feed (`/<locale>/rss.xml`, RFC-0033). + */ +export async function getStaticPaths() { + const source = await getSource(config); + const site = await source.site(); + const domains = site.localization?.domains ?? {}; + const paths: string[] = []; + for (const locale of site.locales) { + if (domains[locale] !== undefined) continue; + const { feeds } = await staticPaths(source, { perPage: ARCHIVE_PAGE_SIZE, locale }); + paths.push(...feeds.map((p) => localizedPath(site, locale, p.slice(0, -SUFFIX.length)))); + const home = localizedPath(site, locale, "/"); + if (home !== "/") paths.push(home); + } + return [...new Set(paths)].map((p) => ({ params: { path: p.slice(1) } })); +} + +/** + * A category's or tag's RSS feed (`<basePath>/<slug>/rss.xml`, RFC-0002 §7), or a locale's site + * feed (`/<locale>/rss.xml`), in the locale of the path or host. Old slugs follow their redirects + * (prefix redirects carry `/rss.xml` along); retired terms answer 410. + */ +export const GET: APIRoute = async ({ request, url, params }) => { + const headers = new Headers(); + const source = await getSource(config, { request, response: { headers } }); + if (!source) return new Response("This site has not been published yet.", { status: 503 }); + const site = await source.site(); + const origin = siteOrigin(site, url); + const lr = localeRequest(site, url.host, `/${params.path ?? ""}`); + if (lr.redirect !== undefined) + return new Response(null, { + status: 301, + headers: { location: `${lr.redirect === "/" ? "" : lr.redirect}${SUFFIX}` }, + }); + if (lr.path === "/") { + headers.set("content-type", "application/rss+xml; charset=utf-8"); + addCacheTags(headers, [listCacheTag(site.id)]); + return new Response(await localeFeedXml(source, { origin, locale: lr.locale }), { headers }); + } + const route = await resolveRoute(source, `${lr.path}${SUFFIX}`, { + perPage: ARCHIVE_PAGE_SIZE, + search: url.search, + locale: lr.locale, + }); + if (route.kind === "redirect") + return new Response(null, { + status: route.status, + headers: { + location: route.location.startsWith("/") + ? localizedPath(site, lr.locale, route.location) + : route.location, + }, + }); + if (route.kind === "gone") return new Response(null, { status: 410 }); + if (route.kind !== "feed") return new Response(null, { status: 404 }); + headers.set("content-type", "application/rss+xml; charset=utf-8"); + addCacheTags(headers, [ + listCacheTag(route.archive.term.id), + termCacheTag(route.archive.term.id), + ...route.archive.entries.slice(0, FEED_LIMIT).map((e) => nodeCacheTag(e.id)), + ]); + const body = archiveFeedXml({ + site, + origin, + locale: lr.locale, + linkOrigin: localeUrl(site, lr.locale, "", origin), + archive: route.archive, + taxonomy: route.index, + assets: await loadAssetIndex(source), + }); + return new Response(body, { headers }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/search.json.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/search.json.ts new file mode 100644 index 00000000..326807c5 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/[...path]/search.json.ts @@ -0,0 +1,23 @@ +import config from "virtual:cloudhal/config"; +import { linkLocalizer, localeRequest } from "@cloudhal/astro-renderer/locales"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { searchJsonResponse } from "@cloudhal/astro-renderer/search"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; + +/** Static builds have no search: nothing to write. */ +export function getStaticPaths() { + return []; +} + +/** A locale's search modal endpoint, `/<locale>/search.json` (RFC-0029 §5, RFC-0033). */ +export const GET: APIRoute = async ({ request, url, params }) => { + const source = await getSource(config, { request, response: { headers: new Headers() } }); + if (!source) return searchJsonResponse(config, source, url); + const site = await source.site(); + const lr = localeRequest(site, url.host, `/${params.path ?? ""}`); + if (lr.path !== "/" || lr.redirect !== undefined) + return new Response(null, { status: 404, headers: { "cache-control": "no-store" } }); + const localize = await linkLocalizer(source, lr.locale, siteOrigin(site, url)); + return searchJsonResponse(config, source, url, { locale: lr.locale, localize }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/llms.txt.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/llms.txt.ts new file mode 100644 index 00000000..dca67a6d --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/llms.txt.ts @@ -0,0 +1,21 @@ +import config from "virtual:cloudhal/config"; +import { addCacheTags, listCacheTag } from "@cloudhal/astro-renderer/cache-tags"; +import { llmsTxt } from "@cloudhal/astro-renderer/locale-feeds"; +import { hostLocale } from "@cloudhal/astro-renderer/locales"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; + +/** `llms.txt` (llmstxt.org) in the host's locale; other locales at `/<locale>/llms.txt` (RFC-0033). */ +export const GET: APIRoute = async ({ request, url }) => { + const headers = new Headers(); + const source = await getSource(config, { request, response: { headers } }); + if (!source) return new Response("This site has not been published yet.", { status: 503 }); + const site = await source.site(); + headers.set("content-type", "text/plain; charset=utf-8"); + addCacheTags(headers, [listCacheTag(site.id)]); + const locale = hostLocale(site, url.host) ?? site.defaultLocale; + return new Response(await llmsTxt(source, { origin: siteOrigin(site, url), locale }), { + headers, + }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/robots.txt.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/robots.txt.ts new file mode 100644 index 00000000..7bec4753 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/robots.txt.ts @@ -0,0 +1,13 @@ +import config from "virtual:cloudhal/config"; +import { robotsTxt } from "@cloudhal/astro-renderer/feeds"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; + +export const GET: APIRoute = async ({ request, url }) => { + const headers = new Headers(); + const source = await getSource(config, { request, response: { headers } }); + headers.set("content-type", "text/plain; charset=utf-8"); + if (!source) return new Response("User-agent: *\nDisallow: /\n", { status: 503, headers }); + return new Response(robotsTxt(siteOrigin(await source.site(), url)), { headers }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/rss.xml.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/rss.xml.ts new file mode 100644 index 00000000..b92a06ae --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/rss.xml.ts @@ -0,0 +1,25 @@ +import config from "virtual:cloudhal/config"; +import { addCacheTags, listCacheTag } from "@cloudhal/astro-renderer/cache-tags"; +import { localeFeedXml } from "@cloudhal/astro-renderer/locale-feeds"; +import { hostLocale } from "@cloudhal/astro-renderer/locales"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; + +/** + * The site's RSS 2.0 feed: the newest published articles of every collection, in the host's + * locale (the default one, or a locale's own domain). Other locales: `/<locale>/rss.xml`. + */ +export const GET: APIRoute = async ({ request, url }) => { + const headers = new Headers(); + const source = await getSource(config, { request, response: { headers } }); + if (!source) return new Response("This site has not been published yet.", { status: 503 }); + const site = await source.site(); + headers.set("content-type", "application/rss+xml; charset=utf-8"); + // Lists every collection's newest entries: any entry change purges it (`cloudhal-list-<site>`). + addCacheTags(headers, [listCacheTag(site.id)]); + const locale = hostLocale(site, url.host) ?? site.defaultLocale; + return new Response(await localeFeedXml(source, { origin: siteOrigin(site, url), locale }), { + headers, + }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/search.json.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/search.json.ts new file mode 100644 index 00000000..4dbbfdef --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/search.json.ts @@ -0,0 +1,21 @@ +import config from "virtual:cloudhal/config"; +import { hostLocale, linkLocalizer } from "@cloudhal/astro-renderer/locales"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { searchJsonResponse } from "@cloudhal/astro-renderer/search"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; + +/** + * The search modal's endpoint (RFC-0029 §5): the same index and rules as `/search`, as JSON, in + * the host's locale (other locales: `/<locale>/search.json`, RFC-0033). Never cached, never + * indexed; a static build has no search and writes no file. + */ +export const GET: APIRoute = async ({ request, url }) => { + // The source's own headers (preview, cache tags) do not apply: the answer is never cached. + const source = await getSource(config, { request, response: { headers: new Headers() } }); + if (!source) return searchJsonResponse(config, source, url); + const site = await source.site(); + const locale = hostLocale(site, url.host) ?? site.defaultLocale; + const localize = await linkLocalizer(source, locale, siteOrigin(site, url)); + return searchJsonResponse(config, source, url, { locale, localize }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/pages/sitemap.xml.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/sitemap.xml.ts new file mode 100644 index 00000000..7475ac2b --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/pages/sitemap.xml.ts @@ -0,0 +1,23 @@ +import config from "virtual:cloudhal/config"; +import { addCacheTags, listCacheTag } from "@cloudhal/astro-renderer/cache-tags"; +import { sitemapXml } from "@cloudhal/astro-renderer/feeds"; +import { localizedSitemap } from "@cloudhal/astro-renderer/locale-feeds"; +import { getSource } from "@cloudhal/astro-renderer/runtime"; +import { siteOrigin } from "@cloudhal/astro-renderer/seo"; +import type { APIRoute } from "astro"; + +/** + * Canonical, indexable URLs of the published site in every locale this host serves (RFC-0033): + * pages, articles and indexable archives, each with its language versions as `xhtml:link`. + */ +export const GET: APIRoute = async ({ request, url }) => { + const headers = new Headers(); + const source = await getSource(config, { request, response: { headers } }); + if (!source) return new Response("This site has not been published yet.", { status: 503 }); + const site = await source.site(); + headers.set("content-type", "application/xml; charset=utf-8"); + // Lists every page, entry and indexable archive: any entry or term change purges it. + addCacheTags(headers, [listCacheTag(site.id)]); + const urls = await localizedSitemap(source, siteOrigin(site, url), url.host); + return new Response(sitemapXml(urls), { headers }); +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/settings.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/settings.ts new file mode 100644 index 00000000..0d0e8cb3 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/settings.ts @@ -0,0 +1,312 @@ +/** + * This theme's design settings (RFC-0017 §1): what Studio's Appearance form, agents + * (`get_theme_settings` / `set_theme_settings`) and the generator may change about its look. + * The site's values live in `Site.theme.config`, are checked against this schema, and reach the + * page as CSS variables and `data-ch-*` attributes (`themeVariables` in `layouts/Base.astro`, + * used by `styles/features/*.css`). Defaults keep the starter's look. + * + * Keep this file data only, with type imports only (the CLI loads it on its own). + */ +import type { PropsSchema } from "@cloudhal/core-ts/blocks"; + +export const themeSettings: PropsSchema = { + type: "object", + additionalProperties: false, + properties: { + // RFC-0023 §1: one seed color and three choices; the color roles (and dark mode) follow. + // Without a seed (and in light mode) the four colors below apply, as before. + seed: { + type: "string", + format: "color", + title: { en: "Seed color", tr: "Ana renk" }, + description: { + en: "One color the whole palette is built from, light and dark, always readable.", + tr: "Tüm paletin, açık ve koyu, okunaklı kalarak üretildiği tek renk.", + }, + }, + scheme: { + type: "string", + enum: ["tonal", "vibrant", "neutral", "mono"], + "x-labels": { + tonal: { en: "Tonal", tr: "Tonlu" }, + vibrant: { en: "Vibrant", tr: "Canlı" }, + neutral: { en: "Neutral", tr: "Nötr" }, + mono: { en: "Monochrome", tr: "Tek renk" }, + }, + default: "tonal", + "x-widget": "radio", + title: { en: "Palette", tr: "Palet" }, + }, + contrast: { + type: "string", + enum: ["standard", "high"], + "x-labels": { + standard: { en: "Standard (AA)", tr: "Standart (AA)" }, + high: { en: "High (AAA)", tr: "Yüksek (AAA)" }, + }, + default: "standard", + title: { en: "Contrast", tr: "Kontrast" }, + }, + mode: { + type: "string", + enum: ["light", "dark", "auto"], + "x-labels": { + light: { en: "Light", tr: "Açık" }, + dark: { en: "Dark", tr: "Koyu" }, + auto: { en: "Follow the reader", tr: "Okura göre" }, + }, + default: "light", + "x-widget": "radio", + title: { en: "Mode", tr: "Mod" }, + }, + brand: { + type: "string", + format: "color", + default: "#18181b", + title: { en: "Brand color", tr: "Marka rengi" }, + description: { en: "Buttons and calls to action.", tr: "Butonlar ve çağrılar." }, + }, + link: { + type: "string", + format: "color", + default: "#0369a1", + title: { en: "Link color", tr: "Bağlantı rengi" }, + }, + surface: { + type: "string", + format: "color", + default: "#ffffff", + title: { en: "Background", tr: "Arka plan" }, + }, + text: { + type: "string", + format: "color", + default: "#18181b", + title: { en: "Text", tr: "Metin" }, + }, + // RFC-0023 §2: a curated font pairing (self-hosted in public/fonts) and a fluid type scale. + // Without a pairing the typeface below applies; without a scale the sizes stay fixed. + typePairing: { + type: "string", + enum: ["system", "editorial", "modern", "humanist", "technical", "classic"], + "x-labels": { + system: { en: "System", tr: "Sistem" }, + editorial: { en: "Editorial (Fraunces and Inter)", tr: "Editoryal (Fraunces ve Inter)" }, + modern: { en: "Modern (Space Grotesk and Inter)", tr: "Modern (Space Grotesk ve Inter)" }, + humanist: { en: "Humanist (Source Sans)", tr: "Hümanist (Source Sans)" }, + technical: { + en: "Technical (JetBrains Mono and Inter)", + tr: "Teknik (JetBrains Mono ve Inter)", + }, + classic: { en: "Classic (Source Serif)", tr: "Klasik (Source Serif)" }, + }, + default: "system", + title: { en: "Font pairing", tr: "Yazı tipi eşleşmesi" }, + description: { + en: "Headings and text set in fonts that go together. System uses the typeface below.", + tr: "Başlıklar ve metin birbirine uyan yazı tipleriyle. Sistem, aşağıdaki yazı tipini kullanır.", + }, + }, + typeScale: { + type: "string", + enum: ["compact", "balanced", "dramatic"], + "x-labels": { + compact: { en: "Compact", tr: "Sıkı" }, + balanced: { en: "Balanced", tr: "Dengeli" }, + dramatic: { en: "Dramatic", tr: "Çarpıcı" }, + }, + "x-widget": "radio", + title: { en: "Type scale", tr: "Yazı ölçeği" }, + description: { + en: "How much bigger headings are than text; sizes adapt to the screen.", + tr: "Başlıkların metinden ne kadar büyük olduğu; boyutlar ekrana uyar.", + }, + }, + font: { + type: "string", + enum: ["system", "serif", "humanist", "mono"], + "x-labels": { + system: { en: "System", tr: "Sistem" }, + serif: { en: "Serif", tr: "Tırnaklı" }, + humanist: { en: "Humanist", tr: "Hümanist" }, + mono: { en: "Monospace", tr: "Eş aralıklı" }, + }, + default: "system", + "x-widget": "radio", + title: { en: "Typeface", tr: "Yazı tipi" }, + }, + // RFC-0023 §3: corners for buttons, fields, cards and bands, and how much room things get. + radius: { + type: "string", + enum: ["none", "small", "medium", "large", "full"], + "x-labels": { + none: { en: "Square", tr: "Köşeli" }, + small: { en: "Slightly rounded", tr: "Hafif yuvarlak" }, + medium: { en: "Medium", tr: "Orta" }, + large: { en: "Rounded", tr: "Yuvarlak" }, + full: { en: "Pill buttons", tr: "Hap düğmeler" }, + }, + default: "small", + title: { en: "Corners", tr: "Köşeler" }, + }, + density: { + type: "string", + enum: ["compact", "cozy", "airy"], + "x-labels": { + compact: { en: "Compact", tr: "Sıkı" }, + cozy: { en: "Cozy", tr: "Rahat" }, + airy: { en: "Airy", tr: "Ferah" }, + }, + default: "cozy", + "x-widget": "radio", + title: { en: "Spacing", tr: "Boşluk" }, + description: { + en: "How much room sections, cards and buttons get.", + tr: "Bölümlerin, kartların ve düğmelerin ne kadar yer kapladığı.", + }, + }, + // RFC-0024 §1: how much the site moves; a page can set its own level, and readers who ask for + // reduced motion always get less. + motion: { + type: "string", + enum: ["none", "subtle", "standard", "expressive"], + "x-labels": { + none: { en: "Still", tr: "Durağan" }, + subtle: { en: "Subtle", tr: "İnce" }, + standard: { en: "Standard", tr: "Standart" }, + expressive: { en: "Expressive", tr: "Etkileyici" }, + }, + default: "none", + "x-widget": "radio", + title: { en: "Motion", tr: "Hareket" }, + description: { + en: "How much sections move as people scroll and point. Readers who ask for reduced motion always see less.", + tr: "Bölümlerin kaydırırken ve üzerine gelirken ne kadar hareket ettiği. Daha az hareket isteyen okurlar her zaman daha azını görür.", + }, + }, + // RFC-0024 §5: how one page gives way to the next. + pageTransition: { + type: "string", + enum: ["none", "crossfade", "slide", "morph"], + "x-labels": { + none: { en: "None", tr: "Yok" }, + crossfade: { en: "Crossfade", tr: "Geçişli solma" }, + slide: { en: "Slide", tr: "Kaydırma" }, + morph: { en: "Morph shared parts", tr: "Ortak öğeleri dönüştür" }, + }, + default: "none", + "x-widget": "radio", + title: { en: "Page transition", tr: "Sayfa geçişi" }, + description: { + en: "How one page gives way to the next, in browsers that support it.", + tr: "Bir sayfanın diğerine nasıl geçtiği (destekleyen tarayıcılarda).", + }, + }, + // RFC-0026 26c: how heavy the site's icons are drawn. + iconWeight: { + type: "string", + enum: ["light", "regular", "bold", "fill"], + "x-labels": { + light: { en: "Light", tr: "İnce" }, + regular: { en: "Regular", tr: "Normal" }, + bold: { en: "Bold", tr: "Kalın" }, + fill: { en: "Filled", tr: "Dolu" }, + }, + default: "regular", + "x-widget": "radio", + title: { en: "Icons", tr: "İkonlar" }, + description: { + en: "Light suits airy, editorial sites; bold and filled read better small and on busy pages.", + tr: "İnce, ferah ve editoryal sitelere uyar; kalın ve dolu, küçük boyutta ve kalabalık sayfalarda daha iyi okunur.", + }, + }, + // RFC-0033 §4a: the theme's own words in a language it does not ship, or worded otherwise. + strings: { + type: "array", + maxItems: 50, + title: { en: "Theme words", tr: "Tema metinleri" }, + description: { + en: "The theme's own words (notices, buttons, labels) in a language, over the theme's: one item per language. Agents can draft them; people review.", + tr: "Temanın kendi metinleri (bildirimler, düğmeler, etiketler), bir dilde, temanınkilerin yerine: her dil için bir öğe. Agent'lar taslak hazırlayabilir; kişiler gözden geçirir.", + }, + items: { + type: "object", + additionalProperties: false, + required: ["locale"], + properties: { + locale: { type: "string", maxLength: 35, title: { en: "Language", tr: "Dil" } }, + machineTranslated: { + type: "string", + maxLength: 300, + title: { en: "Machine translation notice", tr: "Makine çevirisi notu" }, + }, + machineReviewed: { + type: "string", + maxLength: 300, + title: { en: "Reviewed translation notice", tr: "İncelenmiş çeviri notu" }, + }, + readOriginal: { + type: "string", + maxLength: 300, + title: { en: "Read the original", tr: "Orijinalini oku" }, + }, + notTranslated: { + type: "string", + maxLength: 300, + title: { en: "Not translated notice", tr: "Çevrilmemiş sayfa notu" }, + }, + languages: { type: "string", maxLength: 300, title: { en: "Languages", tr: "Diller" } }, + home: { type: "string", maxLength: 300, title: { en: "Home", tr: "Ana sayfa" } }, + menu: { type: "string", maxLength: 300, title: { en: "Menu", tr: "Menü" } }, + search: { type: "string", maxLength: 300, title: { en: "Search", tr: "Ara" } }, + searchLabel: { + type: "string", + maxLength: 300, + title: { en: "Search label", tr: "Arama etiketi" }, + }, + searchPlaceholder: { + type: "string", + maxLength: 300, + title: { en: "Search placeholder", tr: "Arama ipucu" }, + }, + related: { + type: "string", + maxLength: 300, + title: { en: "Related posts", tr: "İlgili yazılar" }, + }, + skipToContent: { + type: "string", + maxLength: 300, + title: { en: "Skip to content", tr: "İçeriğe geç" }, + }, + notFoundTitle: { + type: "string", + maxLength: 300, + title: { en: "Not found: title", tr: "Bulunamadı: başlık" }, + }, + notFoundText: { + type: "string", + maxLength: 300, + title: { en: "Not found: text", tr: "Bulunamadı: metin" }, + }, + notFoundHome: { + type: "string", + maxLength: 300, + title: { en: "Not found: home link", tr: "Bulunamadı: ana sayfa bağlantısı" }, + }, + notFoundSearch: { + type: "string", + maxLength: 300, + title: { en: "Not found: search prompt", tr: "Bulunamadı: arama çağrısı" }, + }, + }, + }, + }, + }, + // WCAG 2.2 AA: body text 4.5:1, links 4.5:1, buttons and other UI parts 3:1 against the page. + "x-contrast": [ + ["text", "surface", 4.5], + ["link", "surface", 4.5], + ["brand", "surface", 3], + ], +}; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/strings.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/strings.ts new file mode 100644 index 00000000..0a9d60bb --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/strings.ts @@ -0,0 +1,279 @@ +/** + * The theme's own words per language (RFC-0033 §4a). It ships English, Turkish and Japanese; any + * other language gets English, with the site's `strings` setting over it. Plain data, no components, + * so tests import it directly. + */ +/** The few words the theme itself prints (the content brings its own). */ +const STRINGS = { + en: { + home: "Home", + breadcrumb: "Breadcrumb", + related: "Related posts", + pagination: "Pagination", + newer: "← Newer", + older: "Older →", + pageOf: (page: number, pages: number) => `Page ${page} of ${pages}`, + feed: "RSS feed", + posts: (n: number) => (n === 1 ? "1 post" : `${n} posts`), + by: (name: string) => `By ${name}`, + byPrefix: "By ", + and: " and ", + updated: (when: string) => `Updated ${when}`, + minRead: (n: number) => `${n} min read`, + onThisPage: "On this page", + /** RFC-0041 §9: the typed fields list on an article, and the words of yes/no fields. */ + details: "Details", + yes: "Yes", + no: "No", + /** RFC-0019 §2: the corrections list at the end of an article, and the note under the byline. */ + corrections: "Corrections and updates", + correctionKind: { correction: "Correction", clarification: "Clarification", update: "Update" }, + appended: (kind: string) => `${kind} appended`, + /** RFC-0019 §3: the search form in the header and the search page. */ + /** RFC-0025: the header and footer's own words. */ + skipToContent: "Skip to content", + mainMenu: "Main", + footerMenu: "Footer", + legalMenu: "Legal", + socialLinks: "Social links", + secondaryMenu: "Secondary", + menu: "Menu", + closeMenu: "Close menu", + announcement: "Announcement", + dismiss: "Dismiss", + privacy: "Privacy", + consentNotice: "This site keeps only what it needs to work: no tracking cookies.", + consentChoices: "Some parts of this site can load content from other services (videos, maps).", + consentOk: "OK", + consentAll: "Allow all", + consentEssential: "Only essential", + privacyPolicy: "Privacy policy", + copied: "Copied", + linkCopied: "Link copied", + builtWith: "Built with Cloudhal", + search: "Search", + searchLabel: "Search this site", + searchPlaceholder: "Search the site…", + searchResults: (n: number, q: string) => + n === 1 ? `1 result for “${q}”` : `${n} results for “${q}”`, + searchNone: (q: string) => `Nothing matches “${q}”. Try fewer or other words.`, + searchUpdating: "Search is catching up with the latest changes.", + /** RFC-0029 §6: the search modal's words (`data-words`; `{q}` is the query, `#` the count). */ + searchModal: { + kinds: { article: "Articles", page: "Pages", term: "Topics", author: "Authors" }, + all: "See all results for “{q}”", + none: "Nothing matches “{q}”.", + recent: "Recent searches", + clear: "Clear recent searches", + limited: "Too many searches right now.", + error: "Search is unavailable right now.", + updating: "Search is catching up with the latest changes.", + count: { one: "# result", other: "# results" }, + close: "Close", + keys: { move: "move", open: "open", close: "close" }, + }, + previous: "← Previous", + next: "Next →", + /** Previews of an embargoed or expired page or entry (RFC-0008 §7.3); readers never see these. */ + embargoed: (when: string) => `Embargoed until ${when}. Only previews show it until then.`, + expired: (when: string) => `Expired on ${when}. Only previews show it now.`, + // RFC-0033: the reader's notice on machine translations, the fallback notice, the switcher. + machineTranslated: "This page was translated automatically and has not been reviewed yet.", + machineReviewed: "This page was translated automatically and reviewed by a person.", + readOriginal: "Read the original", + notTranslated: + "This page isn't available in your language yet, so it is shown in its original language.", + languages: "Languages", + // The not-found page (404) when the site has no page at `/404` in the reader's language. + notFoundTitle: "Page not found", + notFoundText: "The page you are looking for doesn't exist or has moved.", + notFoundHome: "Go to the home page", + notFoundSearch: "Or search this site:", + }, + tr: { + home: "Ana sayfa", + breadcrumb: "Sayfa yolu", + related: "İlgili yazılar", + pagination: "Sayfalar", + newer: "← Daha yeni", + older: "Daha eski →", + pageOf: (page: number, pages: number) => `Sayfa ${page} / ${pages}`, + feed: "RSS beslemesi", + posts: (n: number) => `${n} yazı`, + by: (name: string) => name, + byPrefix: "", + and: " ve ", + updated: (when: string) => `Güncellendi: ${when}`, + minRead: (n: number) => `${n} dk okuma`, + onThisPage: "Bu sayfada", + details: "Ayrıntılar", + yes: "Evet", + no: "Hayır", + corrections: "Düzeltmeler ve güncellemeler", + correctionKind: { correction: "Düzeltme", clarification: "Açıklama", update: "Güncelleme" }, + appended: (kind: string) => `${kind} eklendi`, + skipToContent: "İçeriğe geç", + mainMenu: "Ana menü", + footerMenu: "Alt menü", + legalMenu: "Yasal", + socialLinks: "Sosyal bağlantılar", + secondaryMenu: "İkincil menü", + menu: "Menü", + closeMenu: "Menüyü kapat", + announcement: "Duyuru", + dismiss: "Kapat", + privacy: "Gizlilik", + consentNotice: "Bu site yalnızca çalışması için gerekeni saklar: izleme çerezi yok.", + consentChoices: + "Bu sitenin bazı bölümleri başka servislerden içerik yükleyebilir (videolar, haritalar).", + consentOk: "Tamam", + consentAll: "Tümüne izin ver", + consentEssential: "Yalnız gerekli", + privacyPolicy: "Gizlilik politikası", + copied: "Kopyalandı", + linkCopied: "Bağlantı kopyalandı", + builtWith: "Cloudhal ile yapıldı", + search: "Ara", + searchLabel: "Sitede ara", + searchPlaceholder: "Sitede ara…", + searchResults: (n: number, q: string) => `“${q}” için ${n} sonuç`, + searchNone: (q: string) => + `“${q}” ile eşleşen bir şey yok. Daha az ya da başka sözcükler deneyin.`, + searchUpdating: "Arama son değişiklikleri eklemeye devam ediyor.", + searchModal: { + kinds: { article: "Yazılar", page: "Sayfalar", term: "Konular", author: "Yazarlar" }, + all: "“{q}” için tüm sonuçlar", + none: "“{q}” ile eşleşen bir şey yok.", + recent: "Son aramalar", + clear: "Son aramaları temizle", + limited: "Şu anda çok fazla arama yapıldı.", + error: "Arama şu anda kullanılamıyor.", + updating: "Arama son değişiklikleri eklemeye devam ediyor.", + count: { one: "# sonuç", other: "# sonuç" }, + close: "Kapat", + keys: { move: "gez", open: "aç", close: "kapat" }, + }, + previous: "← Önceki", + next: "Sonraki →", + embargoed: (when: string) => + `${when} tarihine kadar ambargolu. O zamana kadar yalnızca önizlemede görünür.`, + expired: (when: string) => + `${when} tarihinde yayından kalktı. Artık yalnızca önizlemede görünür.`, + machineTranslated: "Bu sayfa otomatik olarak çevrildi ve henüz gözden geçirilmedi.", + machineReviewed: "Bu sayfa otomatik olarak çevrildi ve bir kişi tarafından gözden geçirildi.", + readOriginal: "Orijinalini oku", + notTranslated: "Bu sayfa henüz dilinizde yok; bu yüzden orijinal dilinde gösteriliyor.", + languages: "Diller", + notFoundTitle: "Sayfa bulunamadı", + notFoundText: "Aradığınız sayfa yok ya da taşınmış.", + notFoundHome: "Ana sayfaya dön", + notFoundSearch: "Ya da sitede arayın:", + }, + ja: { + home: "ホーム", + breadcrumb: "パンくずリスト", + related: "関連記事", + pagination: "ページ送り", + newer: "← 新しい記事", + older: "古い記事 →", + pageOf: (page: number, pages: number) => `${pages} ページ中 ${page} ページ`, + feed: "RSS フィード", + posts: (n: number) => `${n} 件の記事`, + by: (name: string) => `${name} 著`, + byPrefix: "著者:", + and: "、", + updated: (when: string) => `${when} 更新`, + minRead: (n: number) => `${n} 分で読めます`, + onThisPage: "このページの内容", + details: "詳細", + yes: "はい", + no: "いいえ", + corrections: "訂正と更新", + correctionKind: { correction: "訂正", clarification: "補足", update: "更新" }, + appended: (kind: string) => `${kind}を追記`, + skipToContent: "本文へスキップ", + mainMenu: "メイン", + footerMenu: "フッター", + legalMenu: "法的情報", + socialLinks: "ソーシャルリンク", + secondaryMenu: "サブメニュー", + menu: "メニュー", + closeMenu: "メニューを閉じる", + announcement: "お知らせ", + dismiss: "閉じる", + privacy: "プライバシー", + consentNotice: "このサイトは動作に必要なものだけを保存します。追跡用 Cookie は使いません。", + consentChoices: + "このサイトの一部は、ほかのサービスのコンテンツ(動画、地図)を読み込むことがあります。", + consentOk: "OK", + consentAll: "すべて許可", + consentEssential: "必須のみ", + privacyPolicy: "プライバシーポリシー", + copied: "コピーしました", + linkCopied: "リンクをコピーしました", + builtWith: "Cloudhal で構築", + search: "検索", + searchLabel: "サイト内検索", + searchPlaceholder: "サイト内を検索…", + searchResults: (n: number, q: string) => `「${q}」の検索結果 ${n} 件`, + searchNone: (q: string) => + `「${q}」に一致するものはありません。言葉を減らすか、別の言葉でお試しください。`, + searchUpdating: "検索は最新の変更を反映している途中です。", + searchModal: { + kinds: { article: "記事", page: "ページ", term: "トピック", author: "著者" }, + all: "「{q}」の検索結果をすべて見る", + none: "「{q}」に一致するものはありません。", + recent: "最近の検索", + clear: "最近の検索を消去", + limited: "現在、検索が集中しています。", + error: "現在、検索を利用できません。", + updating: "検索は最新の変更を反映している途中です。", + count: { one: "# 件", other: "# 件" }, + close: "閉じる", + keys: { move: "移動", open: "開く", close: "閉じる" }, + }, + previous: "← 前へ", + next: "次へ →", + embargoed: (when: string) => + `${when} まで公開を控えています。それまではプレビューでのみ表示されます。`, + expired: (when: string) => + `${when} に掲載期限が切れました。現在はプレビューでのみ表示されます。`, + machineTranslated: "このページは自動翻訳されたもので、まだ確認されていません。", + machineReviewed: "このページは自動翻訳され、人が確認しました。", + readOriginal: "原文を読む", + notTranslated: "このページはまだお使いの言語で読めないため、元の言語で表示しています。", + languages: "言語", + notFoundTitle: "ページが見つかりません", + notFoundText: "お探しのページは存在しないか、移動しました。", + notFoundHome: "ホームページへ", + notFoundSearch: "またはサイト内を検索:", + }, +} as const; + +export type ThemeStrings = (typeof STRINGS)[keyof typeof STRINGS]; + +/** + * The theme's words in `locale` (its language subtag; English for a language the theme does not + * ship, and for any word a shipped language lacks), with the site's own wording over them + * (`strings` in the theme settings, RFC-0033 §4a: one item per language, matched on the full tag, + * then the language): plain-text words only, the keys the settings declare. + */ +export function strings(locale: string, overrides?: unknown): ThemeStrings { + const lang = (locale.split("-")[0] ?? locale).toLowerCase(); + const base: ThemeStrings = Object.hasOwn(STRINGS, lang) + ? { ...STRINGS.en, ...STRINGS[lang as keyof typeof STRINGS] } + : STRINGS.en; + if (!Array.isArray(overrides)) return base; + const words = base as Record<string, unknown>; + const items = overrides as Array<Record<string, unknown> | null>; + const item = + items.find((o) => o?.locale === locale) ?? + items.find((o) => typeof o?.locale === "string" && o.locale.toLowerCase() === lang); + if (!item) return base; + const own = Object.fromEntries( + Object.entries(item).filter( + ([k, v]) => k !== "locale" && typeof v === "string" && v && typeof words[k] === "string", + ), + ); + return Object.keys(own).length ? ({ ...base, ...own } as ThemeStrings) : base; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/ambient.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/ambient.css new file mode 100644 index 00000000..9c76813d --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/ambient.css @@ -0,0 +1,185 @@ +/* + * Ambient backgrounds (RFC-0024 §4): a layer under a band's or card's content, from its role + * colors at low strength, so the surface's text roles keep their contrast. CSS kinds move slowly + * (12 s or more a cycle) where motion plays; particles are painted by the ambient module on a + * canvas. Everywhere else, and while the reader has paused motion, the layer is a still poster. + */ +[data-ch-ambient] { + --ch-amb-1: var(--ch-color-brand); + --ch-amb-2: var(--ch-color-accent); + position: relative; + isolation: isolate; + overflow: clip; +} +[data-ch-colors="brand"] { + --ch-amb-2: var(--ch-color-gradient); +} +[data-ch-colors="accent"] { + --ch-amb-1: var(--ch-color-accent); + --ch-amb-2: var(--ch-color-link); +} +[data-ch-colors="neutral"] { + --ch-amb-1: var(--ch-color-border-strong); + --ch-amb-2: var(--ch-color-text-subtle); +} +[data-ch-ambient]::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} +[data-ch-ambient="aurora"]::before { + inset: -20%; + background: + radial-gradient( + 40% 50% at 20% 30%, + color-mix(in oklch, var(--ch-amb-1) 28%, transparent), + transparent 70% + ), + radial-gradient( + 35% 45% at 80% 20%, + color-mix(in oklch, var(--ch-amb-2) 24%, transparent), + transparent 70% + ), + radial-gradient( + 45% 40% at 60% 90%, + color-mix(in oklch, var(--ch-amb-1) 18%, transparent), + transparent 70% + ); + filter: blur(24px); +} +[data-ch-ambient="gradient-shift"]::before { + background-image: linear-gradient( + 115deg, + color-mix(in oklch, var(--ch-amb-1) 22%, transparent), + color-mix(in oklch, var(--ch-amb-2) 22%, transparent), + color-mix(in oklch, var(--ch-amb-1) 22%, transparent) + ); + background-size: 300% 300%; +} +[data-ch-ambient="grain"]::before { + inset: -10%; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .22 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + opacity: 0.6; +} +[data-ch-ambient="beams"]::before { + background-image: + linear-gradient( + 105deg, + transparent 35%, + color-mix(in oklch, var(--ch-amb-1) 20%, transparent) 45%, + transparent 55% + ), + linear-gradient( + 105deg, + transparent 55%, + color-mix(in oklch, var(--ch-amb-2) 16%, transparent) 63%, + transparent 72% + ); + background-size: 250% 100%; +} +/* Particles: a soft still poster until (and unless) the ambient module paints the canvas. */ +[data-ch-ambient="particles"]::before { + background: + radial-gradient( + 45% 60% at 25% 35%, + color-mix(in oklch, var(--ch-amb-1) 14%, transparent), + transparent 70% + ), + radial-gradient( + 40% 55% at 75% 65%, + color-mix(in oklch, var(--ch-amb-2) 12%, transparent), + transparent 70% + ); +} +.ch-ambient { + position: absolute; + inset: 0; + z-index: -1; + width: 100%; + height: 100%; + pointer-events: none; +} +@keyframes ch-amb-aurora { + to { + translate: 6% -4%; + rotate: 8deg; + } +} +@keyframes ch-amb-pan { + to { + background-position: 100% 50%; + } +} +@keyframes ch-amb-drift { + to { + translate: -6% -4%; + } +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-ambient="aurora"]::before { + animation: ch-amb-aurora 18s ease-in-out infinite alternate; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-ambient="gradient-shift"]::before { + animation: ch-amb-pan 16s ease-in-out infinite alternate; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-ambient="grain"]::before { + animation: ch-amb-drift 14s ease-in-out infinite alternate; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-ambient="beams"]::before { + animation: ch-amb-pan 12s linear infinite alternate; + } +} +/* + * Page transitions (RFC-0024 §5): themeVariables writes @view-transition when the site chose one; + * these shape it. Crossfade is the browser's own, at the level's pace; slide moves the old page + * out and the new one in (crossfade at "subtle"); morph moves elements with the same + * view-transition name between pages. Nothing animates at "none" or under reduced motion. + */ +::view-transition-group(*), +::view-transition-old(*), +::view-transition-new(*) { + animation-duration: var(--ch-dur-normal, 250ms); + animation-timing-function: var(--ch-ease-standard, ease); +} +@keyframes ch-vt-out { + to { + opacity: 0; + translate: calc(var(--ch-move-l, 0px) * -0.5) 0; + } +} +@keyframes ch-vt-in { + from { + opacity: 0; + translate: calc(var(--ch-move-l, 0px) * 0.5) 0; + } +} +:root[data-ch-page-transition="slide"]:is( + [data-ch-motion="standard"], + [data-ch-motion="expressive"] +)::view-transition-old(root) { + animation-name: ch-vt-out; +} +:root[data-ch-page-transition="slide"]:is( + [data-ch-motion="standard"], + [data-ch-motion="expressive"] +)::view-transition-new(root) { + animation-name: ch-vt-in; +} +:root[data-ch-motion="none"]::view-transition-group(*), +:root[data-ch-motion="none"]::view-transition-old(*), +:root[data-ch-motion="none"]::view-transition-new(*) { + animation: none; +} +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + } +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/article.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/article.css new file mode 100644 index 00000000..17573585 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/article.css @@ -0,0 +1,215 @@ +/* The article (RFC-0018 §3): kicker, title, dek, byline, table of contents. */ +.ch-article-head { + @apply space-y-3 border-b border-zinc-200 pt-6 pb-6; +} +.ch-kicker { + @apply inline-block text-sm font-semibold uppercase; + color: var(--ch-color-link); + letter-spacing: 0.04em; +} +a.ch-kicker:hover { + @apply underline underline-offset-4; +} +.ch-article-head h1 { + @apply text-4xl font-bold tracking-tight sm:text-5xl; +} +.ch-dek { + @apply text-xl leading-relaxed text-zinc-600; +} +.ch-byline { + @apply flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-zinc-500; +} +.ch-byline > * + *::before { + content: "·"; + @apply mr-3 text-zinc-400; +} +.ch-byline__author { + @apply font-medium text-zinc-800; +} +.ch-cover { + @apply my-6; +} +.ch-cover img { + @apply w-full rounded-lg; +} +.ch-cover figcaption { + @apply mt-2 text-sm text-zinc-500; +} +.ch-toc { + @apply my-6 rounded-lg border border-zinc-200 px-4 py-3 text-sm; +} +.ch-toc summary { + @apply cursor-pointer font-medium; +} +.ch-toc ol { + @apply mt-2 space-y-1; +} +.ch-toc li[data-depth="3"] { + @apply pl-4; +} +.ch-toc a { + @apply text-zinc-600 underline-offset-4 hover:text-zinc-900 hover:underline; +} +.ch-richtext :is(h2, h3)[id] { + scroll-margin-top: 1.5rem; +} +.ch-byline__author a { + @apply underline-offset-4 hover:underline; +} +.ch-byline a[href="#ch-corrections"] { + @apply font-medium text-amber-800 underline underline-offset-4; +} +/* Corrections (RFC-0019 §2): newest first, the kind as a label. */ +.ch-corrections { + @apply mt-10 rounded-lg border border-amber-200 bg-amber-50 px-5 py-4; + scroll-margin-top: 1.5rem; +} +.ch-corrections h2 { + @apply text-lg font-semibold; +} +.ch-corrections ol { + @apply mt-3 space-y-3; +} +.ch-correction__meta { + @apply flex flex-wrap gap-x-2 text-sm text-zinc-600; +} +.ch-correction__meta strong { + @apply font-semibold text-amber-900; +} +.ch-correction__note { + @apply mt-1 text-zinc-800; +} +.ch-correction__note a { + @apply underline underline-offset-4; +} +/* Article lists as cards: cover thumbnail, title, summary, date and reading time. */ +.ch-card-entry { + @apply flex gap-4; +} +.ch-card-entry__cover { + @apply h-24 w-36 shrink-0 rounded-md object-cover sm:h-28 sm:w-44; +} +.ch-card-entry__body { + @apply min-w-0 space-y-1; +} +.ch-card-entry__body a { + @apply text-lg font-semibold leading-snug underline-offset-4 hover:underline; +} +.ch-card-entry__body p { + @apply text-sm text-zinc-600; +} +.ch-card-entry__meta { + @apply flex flex-wrap gap-x-3 text-xs text-zinc-500; +} +.ch-card-entry__meta > * + *::before { + content: "·"; + @apply mr-3; +} + +/* Breadcrumbs, term chips, archives, pagination, related posts (RFC-0002). */ +.ch-breadcrumbs ol { + @apply flex flex-wrap gap-1 pt-6 text-sm text-zinc-500; +} +.ch-breadcrumbs li + li::before { + content: "›"; + @apply mr-1 text-zinc-400; +} +.ch-breadcrumbs a { + @apply hover:text-zinc-900; +} +.ch-terms { + @apply flex flex-wrap gap-2 border-t border-zinc-200 py-4; +} +.ch-terms__group { + @apply flex flex-wrap gap-2; +} +.ch-chip { + @apply inline-block rounded-full border border-zinc-300 px-3 py-1 text-sm text-zinc-700; +} +a.ch-chip { + @apply hover:border-zinc-900 hover:text-zinc-900; +} +.ch-terms__group[data-taxonomy="category"] .ch-chip { + @apply border-(--ch-color-surface-inverse) bg-(--ch-color-surface-inverse) text-(--ch-color-text-inverse) hover:text-(--ch-color-text-inverse); +} +.ch-archive-header { + @apply space-y-2 border-b border-zinc-200 pt-4 pb-6; +} +.ch-archive-header h1 { + @apply text-4xl font-bold tracking-tight; +} +.ch-archive-kind { + @apply text-sm font-medium tracking-wide text-zinc-500 uppercase; +} +.ch-archive-meta { + @apply flex gap-4 text-sm text-zinc-500; +} +.ch-archive-meta a { + @apply underline-offset-4 hover:underline; +} +.ch-archive-image { + @apply mt-4 w-full rounded; +} +/* Author pages (RFC-0019 §1): round portrait, role and profile links. */ +.ch-archive[data-taxonomy="author"] .ch-archive-image { + @apply h-24 w-24 rounded-full object-cover; +} +.ch-archive-role { + @apply text-lg text-zinc-600; +} +.ch-archive-links { + @apply flex flex-wrap gap-3 text-sm; +} +.ch-archive-links a { + @apply font-medium underline underline-offset-4; +} +.ch-entrylist { + @apply space-y-6 py-6; +} +.ch-entrylist a { + @apply text-lg font-medium underline-offset-4 hover:underline; +} +.ch-entrylist p { + @apply text-sm text-zinc-600; +} +.ch-entrylist time { + @apply text-xs text-zinc-500; +} +.ch-pagination { + @apply flex items-center justify-between border-t border-zinc-200 py-6 text-sm text-zinc-600; +} +.ch-pagination a { + @apply font-medium text-zinc-900 underline-offset-4 hover:underline; +} +.ch-related { + @apply border-t border-zinc-200 pt-6; +} +.ch-related h2 { + @apply text-xl font-semibold; +} + +/* Navigation menus (RFC-0003): dropdowns and nested items as disclosures, no JavaScript. */ +.ch-nav-menu { + @apply relative; +} +.ch-nav-menu summary { + @apply cursor-pointer list-none; +} +.ch-nav-menu summary::-webkit-details-marker { + display: none; +} +.ch-nav-menu__caret { + @apply ml-1 text-xs; +} +.ch-nav-menu[open] > summary .ch-nav-menu__caret { + rotate: 180deg; +} +.ch-nav-menu > ul { + @apply absolute z-10 mt-2 min-w-40 space-y-1 rounded border border-zinc-200 bg-white p-2 shadow-sm; +} +footer .ch-nav-menu > ul { + @apply bottom-full mb-2; +} +.ch-nav-menu .ch-nav-menu > ul { + @apply static mt-1 border-0 p-0 pl-3 shadow-none; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/blocks.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/blocks.css new file mode 100644 index 00000000..aa826300 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/blocks.css @@ -0,0 +1,93 @@ +/* Shown in previews only, where an invalid ```surface would draw nothing. */ +.ch-surface-problem { + @apply rounded-lg px-4 py-3 text-sm; + border: 1px solid var(--ch-color-accent); +} +.ch-surface-problem ul { + @apply mt-2 list-disc ps-5; +} +.ch-richtext table:not([class]) tbody tr:hover { + background: var(--ch-color-surface-muted); +} +.ch-image { + @apply py-6; +} +/* Block types (RFC-0015): callout, button, video link and questions. */ +.ch-block { + @apply py-4; +} +.ch-callout { + @apply space-y-2 rounded border-l-4 border-sky-500 bg-sky-50 px-4 py-3; +} +.ch-callout--success { + @apply border-emerald-500 bg-emerald-50; +} +.ch-callout--warning { + @apply border-amber-500 bg-amber-50; +} +.ch-callout--danger { + @apply border-red-500 bg-red-50; +} +.ch-button { + /* w-fit: a button in a Stack (a flex column) keeps its own width (RFC-0022 22e). */ + @apply inline-block w-fit rounded px-4 py-2 font-medium; +} +.ch-button--primary { + @apply text-(--ch-color-on-brand); + background: var(--ch-color-brand); +} +.ch-button--secondary { + @apply border border-zinc-300 text-zinc-900; +} +.ch-embed__link { + @apply flex items-center gap-3 rounded border border-zinc-200 px-4 py-3 no-underline; +} +.ch-embed__play { + @apply flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-(--ch-color-surface-inverse) text-(--ch-color-text-inverse); +} +.ch-embed__title { + @apply font-medium text-zinc-900; +} +.ch-embed__host { + @apply ml-auto text-sm text-zinc-500; +} +.ch-embed__frame { + @apply aspect-video w-full rounded border-0; +} +.ch-faq__item { + @apply border-b border-zinc-200 py-3; +} +.ch-faq__item summary { + @apply cursor-pointer font-medium; +} +.ch-faq__answer { + @apply mt-2 space-y-2 text-zinc-700; +} +/* This theme's own block type (src/blocks.ts). */ +.testimonial blockquote { + @apply border-l-4 border-zinc-300 pl-4 text-lg italic text-zinc-800; +} +.testimonial figcaption { + @apply mt-2 flex gap-2 text-sm; +} +.testimonial__author { + @apply font-medium text-zinc-900; +} +.testimonial__role { + @apply text-zinc-500; +} +.ch-image figcaption { + @apply mt-2 text-sm text-zinc-500; +} +.ch-contentlist { + @apply py-6; +} +.ch-contentlist ul { + @apply space-y-4; +} +.ch-contentlist a { + @apply text-lg font-medium underline-offset-4 hover:underline; +} +.ch-contentlist p { + @apply text-sm text-zinc-600; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/carousel.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/carousel.css new file mode 100644 index 00000000..8d611569 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/carousel.css @@ -0,0 +1,266 @@ +/* + * RFC-0027: carousels. A scroll-snap track (native swipe, momentum and keyboard scrolling, RTL by + * logical properties), slides in one frame size, markers as links to the slides. + */ +.ch-carousel { + --ch-c-gap: calc(var(--spacing) * 4); + --ch-c-peek: 0%; + --ch-c-n: 1; + position: relative; +} +.ch-carousel[data-gap="none"] { + --ch-c-gap: 0px; +} +.ch-carousel[data-gap="s"] { + --ch-c-gap: calc(var(--spacing) * 2); +} +.ch-carousel[data-gap="l"] { + --ch-c-gap: calc(var(--spacing) * 6); +} +.ch-carousel[data-gap="xl"] { + --ch-c-gap: calc(var(--spacing) * 10); +} +.ch-carousel[data-peek="s"] { + --ch-c-peek: 12%; +} +.ch-carousel[data-peek="m"] { + --ch-c-peek: 24%; +} +.ch-carousel[data-per-view="2"] { + --ch-c-n: 2; + --ch-c-n-set: 2; +} +.ch-carousel[data-per-view="3"] { + --ch-c-n: 3; + --ch-c-n-set: 3; +} +.ch-carousel[data-per-view="4"] { + --ch-c-n: 4; + --ch-c-n-set: 4; +} +/* perView follows the room the carousel has: at most 2 when medium, 1 when narrow (a peek stays). */ +.ch-carousel { + container-type: inline-size; +} +@container (max-width: 44rem) { + .ch-carousel__track { + --ch-c-n: min(var(--ch-c-n-set, 1), 2); + } +} +@container (max-width: 28rem) { + .ch-carousel__track { + --ch-c-n: 1; + } +} +.ch-carousel__track { + display: grid; + grid-auto-flow: column; + /* perView slides, the next one peeking in by --ch-c-peek */ + grid-auto-columns: calc( + (100% - (var(--ch-c-n) - 1) * var(--ch-c-gap) - var(--ch-c-peek)) / + var(--ch-c-n) + ); + gap: var(--ch-c-gap); + overflow-x: auto; + overscroll-behavior-inline: contain; + scroll-snap-type: inline mandatory; + scrollbar-width: thin; + padding-block: 2px; +} +/* As many as fit: a rail without perView, by item width. */ +.ch-carousel[data-variant="rail"]:not([data-per-view]) .ch-carousel__track { + grid-auto-columns: min(16rem, 85%); +} +.ch-carousel[data-item="s"]:not([data-per-view]) .ch-carousel__track { + grid-auto-columns: min(12rem, 70%); +} +.ch-carousel[data-item="l"]:not([data-per-view]) .ch-carousel__track { + grid-auto-columns: min(22rem, 90%); +} +.ch-carousel__track:focus-visible { + outline: 2px solid var(--ch-color-focus, currentColor); + outline-offset: 2px; +} +:root:not([data-ch-motion="none"]) .ch-carousel__track { + scroll-behavior: smooth; +} +@media (prefers-reduced-motion: reduce) { + .ch-carousel__track { + scroll-behavior: auto !important; + } +} +.ch-carousel__slide { + scroll-snap-align: start; + min-width: 0; +} +.ch-carousel[data-align="center"] .ch-carousel__slide { + scroll-snap-align: center; +} +/* A hero: full width like a band, one slide at a time, every slide in one frame. */ +.ch-carousel[data-variant="hero"] { + margin-inline: calc(50% - 50vw); +} +.ch-carousel[data-variant="hero"] .ch-carousel__track { + grid-auto-columns: 100%; + gap: 0; + scrollbar-width: none; +} +.ch-carousel[data-variant="hero"] .ch-carousel__slide { + scroll-snap-stop: always; +} +.ch-carousel[data-aspect] .ch-carousel__slide { + overflow: hidden; +} +.ch-carousel[data-aspect="16:9"] .ch-carousel__slide { + aspect-ratio: 16 / 9; +} +.ch-carousel[data-aspect="21:9"] .ch-carousel__slide { + aspect-ratio: 21 / 9; +} +.ch-carousel[data-aspect="4:3"] .ch-carousel__slide { + aspect-ratio: 4 / 3; +} +.ch-carousel[data-aspect="1:1"] .ch-carousel__slide { + aspect-ratio: 1; +} +.ch-carousel[data-aspect="3:4"] .ch-carousel__slide { + aspect-ratio: 3 / 4; +} +/* A wide hero frame is too short on a phone: narrow carousels use a taller one (still one size). */ +@container (max-width: 40rem) { + .ch-carousel[data-variant="hero"]:is([data-aspect="16:9"], [data-aspect="21:9"]) + .ch-carousel__slide { + aspect-ratio: 4 / 5; + } +} +/* A slide's content fills its frame: images cover it, a band stays inside it. */ +.ch-carousel[data-aspect] .ch-carousel__slide > * { + height: 100%; +} +.ch-carousel[data-aspect] .ch-carousel__slide :is(.ch-figure, .ch-figure img) { + height: 100%; + width: 100%; + object-fit: cover; + margin: 0; +} +.ch-carousel .ch-band { + margin-inline: 0; + padding-inline: clamp(1rem, 6%, 4rem); +} +.ch-carousel[data-variant="hero"] .ch-band { + display: flex; + align-items: center; +} +/* A band holding only a picture fills what its padding leaves (under a transparent header too). */ +.ch-carousel[data-variant="hero"] .ch-band > .ch-band__inner:has(> .ch-figure:only-child) { + flex: 1; + align-self: stretch; + min-height: 0; +} +/* Controls: arrows the module reveals (their place kept), markers as links. */ +.ch-carousel__controls { + @apply mt-3 flex items-center justify-center gap-3; +} +.ch-carousel__arrow { + @apply inline-flex size-9 cursor-pointer items-center justify-center rounded-full border border-zinc-300 text-lg; + background: var(--ch-color-surface); +} +.ch-carousel__arrow[data-ch-pending] { + visibility: hidden; +} +.ch-carousel__arrow[aria-disabled="true"] { + @apply cursor-default opacity-40; +} +.ch-carousel__markers { + @apply flex flex-wrap items-center justify-center gap-1.5; + scroll-target-group: auto; +} +.ch-carousel__markers a { + @apply inline-flex min-w-7 items-center justify-center rounded-full px-2 py-0.5 text-sm tabular-nums text-zinc-600 no-underline; +} +.ch-carousel__markers[data-style="dots"] a { + @apply size-6 min-w-6 p-0; +} +.ch-carousel__markers[data-style="dots"] a::before { + content: ""; + @apply block size-2 rounded-full bg-current; +} +.ch-carousel__markers a:is(:target-current, [aria-current="true"]) { + background: var(--ch-color-brand); + color: var(--ch-color-on-brand); +} +.ch-carousel__markers[data-style="dots"] a:is(:target-current, [aria-current="true"]) { + background: transparent; + color: var(--ch-color-brand); +} + +/* + * RFC-0027 27c: galleries (one image at a time, whole, with thumbnails) and testimonials (centred, + * the neighbours peeking in). A gallery shows each image whole on a calm field until RFC-0028's + * fit modes (blur-fill) arrive. + */ +.ch-carousel[data-variant="gallery"] .ch-carousel__track { + grid-auto-columns: 100%; + gap: 0; + scrollbar-width: none; +} +.ch-carousel[data-variant="gallery"] .ch-carousel__slide { + scroll-snap-stop: always; + background: var(--ch-color-surface-muted); + border-radius: var(--ch-radius-m, 0.5rem); +} +.ch-carousel[data-variant="gallery"][data-aspect] + .ch-carousel__slide + :is(.ch-figure, .ch-figure img) { + object-fit: contain; +} +.ch-carousel__markers[data-style="thumbs"] { + @apply flex-nowrap justify-start gap-2 overflow-x-auto; + scrollbar-width: thin; +} +.ch-carousel__markers[data-style="thumbs"] a { + @apply size-16 min-w-16 shrink-0 overflow-hidden rounded p-0 opacity-70; + outline: 2px solid transparent; + outline-offset: 2px; +} +.ch-carousel__markers[data-style="thumbs"] a img { + @apply size-full object-cover; +} +.ch-carousel__markers[data-style="thumbs"] a:is(:target-current, [aria-current="true"]) { + background: transparent; + opacity: 1; + outline-color: var(--ch-color-brand); +} +.ch-carousel[data-variant="testimonials"] { + --ch-c-peek: 24%; +} +.ch-carousel[data-variant="testimonials"][data-peek="none"] { + --ch-c-peek: 0%; +} +.ch-carousel[data-variant="testimonials"] .ch-carousel__slide { + scroll-snap-align: center; +} +.ch-carousel[data-variant="testimonials"] .ch-carousel__track { + /* one quote in the middle, its neighbours' edges showing; the first and last centre too */ + grid-auto-columns: 100%; + padding-inline: calc(var(--ch-c-peek) / 2); + scroll-padding-inline: calc(var(--ch-c-peek) / 2); +} + +/* RFC-0027 27d: a playing hero's rotation control: first in Tab order (first in the markup), shown + at the start of the controls row (clear of a transparent header over the hero). */ +.ch-carousel__rotate { + @apply absolute bottom-0 start-4 z-10 inline-flex size-9 cursor-pointer items-center justify-center rounded-full border border-zinc-300 text-base; + background: var(--ch-color-surface); + color: var(--ch-color-text); +} +.ch-carousel__rotate[data-ch-pending] { + display: none; +} +.ch-carousel__rotate[data-state="playing"] [data-when="stopped"], +.ch-carousel__rotate[data-state="stopped"] [data-when="playing"] { + display: none; +} +.ch-carousel__rotate > span { + display: inline-flex; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/chrome-menus.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/chrome-menus.css new file mode 100644 index 00000000..a33935dd --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/chrome-menus.css @@ -0,0 +1,264 @@ +/* + * RFC-0025 25b: the phone menu, submenus, hiding, shrinking and the transparent header. + * `--ch-header-h` is the chrome module's measure of the header (a close guess until it runs). + */ +:root { + --ch-header-h: 4.75rem; +} +.ch-menu-slot { + display: none; +} +@media (max-width: 639.98px) { + .ch-header[data-collapse="sm"] :is(.ch-header__nav, .ch-header__utility, .ch-header-search) { + display: none; + } + .ch-header[data-collapse="sm"] .ch-menu-slot { + display: block; + } + .ch-header[data-collapse="sm"] .ch-header__brand { + margin-inline-end: auto; + } +} +@media (max-width: 767.98px) { + .ch-header[data-collapse="md"] :is(.ch-header__nav, .ch-header__utility, .ch-header-search) { + display: none; + } + .ch-header[data-collapse="md"] .ch-menu-slot { + display: block; + } + .ch-header[data-collapse="md"] .ch-header__brand { + margin-inline-end: auto; + } +} +@media (max-width: 1023.98px) { + .ch-header[data-collapse="lg"] :is(.ch-header__nav, .ch-header__utility, .ch-header-search) { + display: none; + } + .ch-header[data-collapse="lg"] .ch-menu-slot { + display: block; + } + .ch-header[data-collapse="lg"] .ch-header__brand { + margin-inline-end: auto; + } +} +.ch-header[data-collapse="always"] :is(.ch-header__nav, .ch-header__utility, .ch-header-search) { + display: none; +} +.ch-header[data-collapse="always"] .ch-menu-slot { + display: block; +} +.ch-header[data-collapse="always"] .ch-header__brand { + margin-inline-end: auto; +} +.ch-menu__open { + @apply inline-flex cursor-pointer list-none items-center gap-2 rounded border border-current/25 px-3 py-1.5 text-sm font-medium; +} +.ch-menu__open::-webkit-details-marker { + display: none; +} +/* What opens over the page reads like the page, whatever the header stands on. */ +:is(.ch-menu__panel, .ch-nav-menu__pop, .ch-menu__dialog) { + background: var(--ch-color-surface); + color: var(--ch-color-text); +} +.ch-menu[open] > .ch-menu__panel { + @apply absolute inset-x-4 top-full z-40 overflow-auto rounded-lg border border-zinc-200 p-4 shadow-lg; + max-height: calc(100dvh - var(--ch-header-h) - 1rem); +} +.ch-menu__panel { + @apply grid gap-4; +} +.ch-menu__nav ul { + @apply grid gap-1; +} +.ch-menu__nav a, +.ch-menu__nav summary, +.ch-menu__nav li > span { + @apply block rounded px-2 py-2 text-base; +} +.ch-menu__nav a:hover { + background: var(--ch-color-surface-muted); +} +/* A group's line: its link (or name) and the caret, side by side. */ +.ch-menu__nav summary { + @apply flex items-center justify-between gap-2; +} +.ch-menu__nav summary > a { + @apply p-0; +} +.ch-menu__nav--small a { + @apply py-1.5 text-sm; +} +.ch-menu__current { + @apply font-semibold; +} +.ch-menu__panel .ch-nav-menu > ul { + @apply static mt-0 border-0 p-0 pl-3 shadow-none; +} +.ch-menu__search input { + @apply w-full rounded-md border border-zinc-300 px-3 py-2; +} +/* Drawers and the full-screen menu: a modal dialog (the browser traps focus, Escape closes it). */ +.ch-menu__dialog { + @apply m-0 content-start gap-4 p-4; + max-width: none; + max-height: none; + height: 100dvh; + width: min(22rem, calc(100vw - 3rem)); + border: 0; +} +/* Only an open dialog is laid out: a display of its own would show a closed one. */ +.ch-menu__dialog[open] { + display: grid; +} +.ch-menu__dialog[data-style="drawer-end"] { + margin-inline-start: auto; +} +.ch-menu__dialog[data-style="fullscreen"] { + width: 100vw; +} +.ch-menu__dialog::backdrop { + background: rgb(0 0 0 / 0.45); +} +.ch-menu__bar { + @apply flex items-center justify-between gap-3; +} +.ch-menu__close { + @apply rounded border border-zinc-300 px-3 py-1.5 text-sm font-medium; +} +.ch-menu__dialog[open] { + transition: + translate var(--ch-dur-2, 0.25s) var(--ch-ease-out, ease-out), + opacity var(--ch-dur-2, 0.25s) ease-out; +} +@starting-style { + .ch-menu__dialog[data-style="drawer-start"][open] { + translate: -100% 0; + } + .ch-menu__dialog[data-style="drawer-end"][open] { + translate: 100% 0; + } + .ch-menu__dialog[data-style="fullscreen"][open] { + opacity: 0; + } +} +/* Submenus in the bar: popovers under their button (anchor positioning; the module otherwise). */ +.ch-nav-menu[data-popover] { + @apply inline-flex items-center gap-1; +} +.ch-nav-menu__btn { + @apply inline-flex cursor-pointer items-center gap-1 rounded px-1 py-0.5; +} +/* From the text's own color, so it reads on the page and on a transparent header alike. */ +.ch-nav-menu__btn:hover, +.ch-nav-menu__btn:has(+ :popover-open) { + background: color-mix(in oklab, currentColor 12%, transparent); +} +.ch-nav-menu__pop { + @apply min-w-48 rounded-lg border border-zinc-200 p-2 text-sm shadow-lg; + margin: 0; + inset: auto; + position-area: bottom span-right; + margin-block-start: 6px; +} +.ch-nav-menu__pop ul { + @apply grid gap-0.5; +} +.ch-nav-menu__pop a, +.ch-nav-menu__pop li > span { + @apply block rounded px-2 py-1.5; +} +.ch-nav-menu__pop a:hover { + background: var(--ch-color-surface-muted); +} +/* Hiding and shrinking move the header, never its box, so nothing below it shifts. */ +.ch-header[data-behavior="hide-on-scroll"], +.ch-header[data-shrink], +.ch-header[data-shrink] .ch-header__inner { + transition: translate var(--ch-dur-2, 0.25s) var(--ch-ease-out, ease-out); +} +.ch-header[data-shrink][data-ch-scrolled] { + translate: 0 -0.75rem; +} +.ch-header[data-shrink][data-ch-scrolled] .ch-header__inner { + translate: 0 0.375rem; +} +.ch-header[data-behavior="hide-on-scroll"][data-ch-hidden] { + translate: 0 -100%; +} +/* Transparent over a loud first band: that surface's text role, its own fill once scrolled. */ +.ch-header[data-transparent] { + margin-block-end: calc(-1 * var(--ch-header-h)); +} +.ch-header[data-transparent]:not([data-ch-scrolled]) { + background: transparent; + border-bottom-color: transparent; + box-shadow: none; +} +.ch-header[data-transparent][data-over="inverse"]:not([data-ch-scrolled]) { + color: var(--ch-color-text-inverse); +} +.ch-header[data-transparent]:is([data-over="brand"], [data-over="gradient"]):not( + [data-ch-scrolled] +) { + color: var(--ch-color-on-brand); +} +.ch-header[data-transparent][data-over="accent"]:not([data-ch-scrolled]) { + color: var(--ch-color-on-accent); +} +.ch-header[data-transparent]:not([data-ch-scrolled]) + :is(.ch-header__nav, .ch-header__utility, .ch-header__bar-link) { + color: inherit; +} +.ch-header[data-transparent]:not([data-ch-scrolled]) :is(.ch-header__nav, .ch-header__utility) > a, +.ch-header[data-transparent]:not([data-ch-scrolled]) .ch-nav-menu[data-popover] > :is(a, button) { + color: inherit; +} +/* On a brand hero the primary button would vanish into it: its colors swap until the fill. */ +.ch-header[data-transparent]:is([data-over="brand"], [data-over="gradient"]):not([data-ch-scrolled]) +.ch-button--primary { + background: var(--ch-color-on-brand); + color: var(--ch-color-brand); +} +.ch-header[data-transparent][data-ch-scrolled] { + background: var(--ch-color-surface); + transition: background-color var(--ch-dur-2, 0.25s) ease-out; +} +/* Under a transparent header the page starts at the very top (the band makes its own room). */ +.ch-header[data-transparent] ~ main > .ch-surface:first-child { + padding-block-start: 0; +} +/* The first band (the root or its first child, three levels at most, like `heroSurface`) starts + under the header: it keeps its own room above the content. */ +.ch-header[data-transparent] ~ main > .ch-surface:first-child > .ch-band:first-child, +.ch-header[data-transparent] ~ main > .ch-surface:first-child > :first-child > .ch-band:first-child, +.ch-header[data-transparent] + ~ main + > .ch-surface:first-child + > :first-child + > :first-child + > .ch-band:first-child, +.ch-header[data-transparent] + ~ main + > .ch-surface:first-child + .ch-carousel[data-variant="hero"] + > .ch-carousel__track + > .ch-carousel__slide + > .ch-band:first-child { + padding-block-start: calc(var(--ch-header-h) + 3rem); +} +:root[data-ch-motion="none"] :is(.ch-header, .ch-header__inner, .ch-menu__dialog) { + transition: none; +} +@media (prefers-reduced-motion: reduce) { + :is(.ch-header, .ch-header__inner, .ch-menu__dialog) { + transition: none; + } +} +/* The `/menu` page: the phone menu, for readers without the dialog. */ +.ch-menu-page { + @apply py-8; +} +.ch-menu-page h1 { + @apply mb-6 text-3xl font-bold; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/chrome.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/chrome.css new file mode 100644 index 00000000..dd9e04b4 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/chrome.css @@ -0,0 +1,193 @@ +/* + * Site chrome (RFC-0025 §2): the header and footer from `resolveChrome`. The defaults (logo-left, + * static, content width, plain; a simple footer) reproduce the former header and footer. + */ +.ch-skip { + @apply sr-only; +} +.ch-skip:focus { + @apply not-sr-only fixed top-2 left-2 z-50 rounded bg-white px-3 py-2 text-sm font-medium text-zinc-900 shadow; +} +#main:focus { + outline: none; +} +.ch-header { + @apply relative z-30; +} +.ch-header__inner { + @apply mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-3 px-4 py-6; +} +.ch-header[data-width="wide"] .ch-header__inner { + @apply max-w-6xl; +} +.ch-header[data-width="full"] .ch-header__inner { + @apply max-w-none sm:px-8; +} +.ch-header__brand { + @apply shrink-0 text-lg font-semibold whitespace-nowrap; +} +.ch-header__nav, +.ch-header__utility { + @apply flex flex-wrap gap-4 text-sm text-zinc-600; +} +.ch-header__utility { + @apply text-xs; +} +.ch-header__actions { + @apply flex items-center gap-3; +} +.ch-header__actions:empty { + display: none; +} +.ch-header__actions .ch-button { + @apply px-3 py-1.5 text-sm whitespace-nowrap; +} +/* Layouts: the nav in the middle; everything centered on its own rows; brand and actions apart. */ +@media (min-width: 768px) { + .ch-header[data-layout="logo-left-nav-center"] .ch-header__inner { + display: grid; + grid-template-columns: 1fr auto 1fr; + } + .ch-header[data-layout="logo-left-nav-center"] .ch-header__actions { + justify-self: end; + } + .ch-header[data-layout="split"] .ch-header__nav { + margin-inline-end: auto; + } + .ch-header[data-layout="split"] .ch-header__brand { + order: 2; + margin-inline-end: auto; + } + .ch-header[data-layout="split"] .ch-header__nav { + order: 1; + } + .ch-header[data-layout="split"] .ch-header__actions { + order: 3; + } +} +.ch-header[data-layout="centered"] .ch-header__inner { + @apply flex-col justify-center text-center; +} +.ch-header[data-layout="centered"] .ch-header__nav { + @apply justify-center; +} +/* Behaviors: sticky; hide-on-scroll and fixed are sticky too (the chrome module hides the first). */ +.ch-header:is([data-behavior="sticky"], [data-behavior="hide-on-scroll"], [data-behavior="fixed"]) { + position: sticky; + top: 0; + background: var(--ch-color-surface, #fff); + border-bottom: 1px solid rgb(0 0 0 / 0.08); +} +:root:has( + .ch-header:is([data-behavior="sticky"], [data-behavior="hide-on-scroll"], [data-behavior="fixed"]) +) { + scroll-padding-top: var(--ch-header-h); +} +/* Surfaces: the same color roles as bands. */ +:is(.ch-header, .ch-footer)[data-surface="muted"] { + background: var(--ch-color-surface-muted); +} +.ch-header[data-surface="raised"] { + background: var(--ch-color-surface-raised); + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.05), + 0 6px 20px -6px rgb(0 0 0 / 0.12); +} +:is(.ch-header, .ch-footer)[data-surface="inverse"] { + background: var(--ch-color-surface-inverse); + color: var(--ch-color-text-inverse); +} +.ch-header[data-surface="brand"] { + background: var(--ch-color-brand); + color: var(--ch-color-on-brand); +} +:is(.ch-header, .ch-footer):is([data-surface="inverse"], [data-surface="brand"]) + :is(.ch-header__nav, .ch-header__utility, .ch-footer__inner) { + color: inherit; +} +:is(.ch-header, .ch-footer):is([data-surface="inverse"], [data-surface="brand"]) a { + color: inherit; +} +.ch-logo { + @apply block w-auto; + height: 1.75rem; +} +.ch-logo[data-size="s"] { + height: 1.25rem; +} +.ch-logo[data-size="l"] { + height: 2.5rem; +} +.ch-logo[data-variant="dark"] { + display: none; +} +:root[data-ch-mode="dark"] .ch-logo[data-variant="light"] { + display: none; +} +:root[data-ch-mode="dark"] .ch-logo[data-variant="dark"] { + display: block; +} +@media (prefers-color-scheme: dark) { + :root[data-ch-mode="auto"] .ch-logo[data-variant="light"] { + display: none; + } + :root[data-ch-mode="auto"] .ch-logo[data-variant="dark"] { + display: block; + } +} +.ch-footer__inner { + @apply mx-auto max-w-3xl px-4 py-8 text-xs text-zinc-500; +} +/* On the muted surface, the role checked against it (text-muted on surface-muted, 4.5:1). */ +.ch-footer[data-surface="muted"] :is(.ch-footer__inner, .ch-footer__tagline) { + color: var(--ch-color-text-muted); +} +.ch-footer:not([data-layout="simple"], [data-layout="minimal"]) .ch-footer__inner { + @apply max-w-6xl py-12; +} +.ch-footer__legal { + @apply flex flex-wrap items-center justify-between gap-4; +} +.ch-footer[data-layout="minimal"] .ch-footer__legal { + @apply justify-center; +} +.ch-footer__top { + @apply mb-8 grid gap-8 text-sm sm:grid-cols-2 lg:grid-cols-4; +} +.ch-footer__brand { + @apply text-base font-semibold text-zinc-900; +} +.ch-footer__tagline { + @apply mt-2 max-w-xs text-zinc-600; +} +.ch-footer__social { + @apply mt-3 flex flex-wrap gap-3; +} +.ch-footer__legal .ch-footer__social { + @apply mt-0; +} +.ch-footer__social a, +.ch-footer__col a { + @apply hover:text-zinc-700; +} +.ch-footer__heading { + @apply mb-3 font-medium text-zinc-900; +} +.ch-footer__col ul { + @apply space-y-2; +} +.ch-footer__cta { + @apply mb-10 flex flex-wrap items-center justify-between gap-4 rounded-lg px-6 py-8; + background: var(--ch-color-surface-muted); +} +.ch-footer:is([data-surface="muted"], [data-surface="inverse"]) .ch-footer__cta { + background: var(--ch-color-surface-raised); + color: var(--ch-color-text); +} +.ch-footer__cta-heading { + @apply text-xl font-semibold text-zinc-900; +} +:is(.ch-footer)[data-surface="inverse"] + :is(.ch-footer__brand, .ch-footer__heading, .ch-footer__tagline, .ch-footer__cta-heading) { + color: inherit; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/comments.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/comments.css new file mode 100644 index 00000000..313b3021 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/comments.css @@ -0,0 +1,75 @@ +/* Reader comments (RFC-0007): `<Comments />` renders plain HTML; these rules only style it. */ +.cloudhal-comments { + @apply space-y-4 border-t border-zinc-200 py-8; +} +.cloudhal-comments h2 { + @apply text-2xl font-semibold; +} +.cloudhal-comment-list, +.cloudhal-comment-replies { + @apply space-y-4; +} +.cloudhal-comment-replies { + @apply mt-4 border-l border-zinc-200 pl-4; +} +.cloudhal-comment-meta { + @apply flex flex-wrap items-baseline gap-2 text-sm text-zinc-500; +} +.cloudhal-comment-author { + @apply font-semibold text-zinc-900; +} +.cloudhal-comment-badge { + @apply rounded bg-zinc-100 px-1.5 text-xs text-zinc-700; +} +.cloudhal-comment-body { + @apply mt-1 space-y-2 leading-relaxed; +} +.cloudhal-comment-body a, +.cloudhal-comment-context { + @apply underline underline-offset-4; +} +.cloudhal-comment-actions { + @apply mt-1 flex flex-wrap items-start gap-3 text-sm text-zinc-500; +} +.cloudhal-comment-actions summary, +.cloudhal-comment-reply { + @apply cursor-pointer underline-offset-4 hover:underline; +} +.cloudhal-comment-removed > p { + @apply text-sm text-zinc-500 italic; +} +.cloudhal-comment-note { + @apply rounded border border-zinc-300 bg-zinc-50 p-3 text-sm; +} +.cloudhal-comment-form, +.cloudhal-report-form { + @apply max-w-xl space-y-3; +} +.cloudhal-comment-form h3 { + @apply text-lg font-semibold; +} +.cloudhal-comment-form label, +.cloudhal-report-form label { + @apply block text-sm font-medium; +} +.cloudhal-comment-form small, +.cloudhal-comment-privacy { + @apply block text-xs text-zinc-500; +} +.cloudhal-comment-form :is(input, textarea), +.cloudhal-report-form :is(input, textarea, select) { + @apply w-full rounded border border-zinc-300 px-2 py-1; +} +.cloudhal-comment-form [aria-invalid="true"] { + @apply border-red-600; +} +.cloudhal-comment-form button, +.cloudhal-report-form button { + @apply rounded px-4 py-2 bg-(--ch-color-surface-inverse) text-(--ch-color-text-inverse); +} +.cloudhal-comment-form button[data-cancel-reply] { + @apply bg-zinc-200 text-zinc-900; +} +.cloudhal-comments-pages a { + @apply text-sm underline underline-offset-4; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/icons.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/icons.css new file mode 100644 index 00000000..e2261d10 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/icons.css @@ -0,0 +1,70 @@ +/* + * RFC-0026: icons follow the text (1em, currentColor), sit on its baseline, flip right-to-left + * when they point, and stay visible in forced colors. + */ +.ch-icon { + display: inline-block; + flex-shrink: 0; + vertical-align: -0.125em; +} +[dir="rtl"] .ch-icon[data-ch-mirror] { + transform: scaleX(-1); +} +@media (forced-colors: active) { + .ch-icon { + fill: CanvasText; + } + a .ch-icon, + button .ch-icon { + fill: currentColor; + } +} +.ch-footer__social { + @apply items-center; +} +.ch-social-link { + @apply inline-flex size-8 items-center justify-center rounded-full text-lg; +} +.ch-social-link:hover { + background: color-mix(in oklab, currentColor 12%, transparent); +} + +/* RFC-0026: icons in surfaces — on their own, in buttons, badges and statistics. */ +.ch-icon-box { + /* w-fit: in a Stack or Card (a flex column) it keeps its own width, at the start. */ + @apply inline-flex w-fit items-center justify-center text-2xl; +} +.ch-icon-box[data-size="s"] { + @apply text-base; +} +.ch-icon-box[data-size="l"] { + @apply text-4xl; +} +.ch-icon-box[data-size="xl"] { + @apply text-6xl; +} +.ch-icon-box[data-tone="brand"] { + color: var(--ch-color-brand); +} +.ch-icon-box[data-tone="accent"] { + color: var(--ch-color-accent); +} +.ch-icon-box[data-tone="success"] { + color: #15803d; +} +.ch-icon-box[data-tone="warning"] { + color: #b45309; +} +.ch-icon-box[data-tone="danger"] { + color: #b91c1c; +} +.ch-button:has(> .ch-button__icon) { + @apply inline-flex items-center gap-2; +} +.ch-badge > .ch-icon { + @apply mr-1; +} +.ch-stat__icon { + @apply mb-2 block text-3xl; + color: var(--ch-color-brand); +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/languages.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/languages.css new file mode 100644 index 00000000..437af5b7 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/languages.css @@ -0,0 +1,34 @@ +/* RFC-0033 §4: the language switcher (header action and footer list). */ +.ch-languages { + position: relative; +} +.ch-languages > summary { + cursor: pointer; + list-style: none; +} +.ch-languages > ul { + position: absolute; + inset-inline-end: 0; + z-index: 20; + margin-block-start: 0.5rem; + min-inline-size: 10rem; + padding: 0.5rem; + border: 1px solid rgb(0 0 0 / 0.12); + border-radius: 0.5rem; + background: var(--ch-color-surface); +} +.ch-languages a, +.ch-footer__languages a { + display: block; + padding-block: 0.25rem; +} +.ch-languages a[aria-current="true"], +.ch-footer__languages a[aria-current="true"] { + font-weight: 600; +} +.ch-footer__languages ul { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-block: 1rem; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/live-surfaces.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/live-surfaces.css new file mode 100644 index 00000000..2c3a995d --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/live-surfaces.css @@ -0,0 +1,238 @@ +/* Live surfaces (RFC-0020 §4): conditional components, inputs and action buttons. */ +.ch-node { + display: contents; +} +.ch-node[hidden] { + display: none; +} +/* An action button starts from the browser's button: reset it, then give each style its own look. */ +button.ch-button { + @apply cursor-pointer border-0 bg-transparent font-medium; + font-family: inherit; +} +button.ch-button--primary { + @apply text-(--ch-color-on-brand); + background: var(--ch-color-brand); +} +button.ch-button--secondary { + @apply border border-zinc-300 text-zinc-900; +} +.ch-toggle { + @apply inline-flex w-fit flex-wrap gap-1 rounded-lg bg-zinc-100 p-1; +} +.ch-toggle__option input { + @apply sr-only; +} +.ch-toggle__option span { + @apply block cursor-pointer rounded-md px-3 py-1.5 text-sm font-medium text-zinc-600; +} +.ch-toggle__option input:checked + span { + @apply bg-white text-zinc-900 shadow-sm; +} +.ch-toggle__option input:focus-visible + span { + @apply outline-2 outline-offset-2; +} +.ch-field { + @apply grid content-start gap-1.5; +} +.ch-field__label { + @apply text-sm font-medium; +} +.ch-select { + @apply w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2; +} +.ch-slider { + @apply flex items-center gap-3; +} +.ch-slider input { + @apply w-full max-w-sm; + accent-color: var(--ch-color-brand); +} +.ch-slider__value { + @apply min-w-12 text-right font-medium tabular-nums; +} +/* RFC-0022 §2: local inputs. */ +.ch-input-wrap { + @apply flex w-full max-w-xs items-center gap-2; +} +.ch-input { + @apply w-full max-w-xs rounded-md border border-zinc-300 bg-white px-3 py-2 tabular-nums; +} +.ch-input[aria-invalid="true"] { + @apply border-red-600; +} +.ch-input__unit { + @apply shrink-0 text-sm text-zinc-600; +} +.ch-field__help { + @apply text-sm text-zinc-600; +} +[aria-invalid="true"] ~ .ch-field__help, +.ch-input-wrap:has([aria-invalid="true"]) ~ .ch-field__help { + @apply text-red-700; +} +.ch-check { + @apply flex w-fit cursor-pointer items-center gap-2; +} +.ch-check input { + @apply size-4; + accent-color: var(--ch-color-brand); +} +.ch-radio { + @apply m-0 grid min-w-0 gap-1.5 border-0 p-0; +} +.ch-radio__options { + @apply flex flex-col gap-1.5; +} +.ch-radio__option { + @apply flex w-fit cursor-pointer items-center gap-2; +} +.ch-radio__option input { + accent-color: var(--ch-color-brand); +} +.ch-radio[data-variant="buttons"] .ch-radio__options { + @apply inline-flex w-fit flex-row flex-wrap gap-1 rounded-lg bg-zinc-100 p-1; +} +.ch-radio[data-variant="buttons"] .ch-radio__option input { + @apply sr-only; +} +.ch-radio[data-variant="buttons"] .ch-radio__option span { + @apply block min-h-9 rounded-md px-3 py-1.5 text-sm font-medium text-zinc-600; +} +.ch-radio[data-variant="buttons"] .ch-radio__option input:checked + span { + @apply bg-white text-zinc-900 shadow-sm; +} +.ch-radio[data-variant="buttons"] .ch-radio__option input:focus-visible + span { + @apply outline-2 outline-offset-2; +} +.ch-noscript { + @apply text-sm text-zinc-600; +} + +/* RFC-0022 §3: charts. Six series colors from the brand color, each distinct at 3:1 on white. */ +:root { + --ch-chart-1: var(--ch-color-brand); + --ch-chart-2: #2563eb; + --ch-chart-3: #d97706; + --ch-chart-4: #059669; + --ch-chart-5: #db2777; + --ch-chart-6: #7c3aed; +} +.ch-chart { + @apply m-0 grid gap-3; +} +.ch-chart__title { + @apply text-base font-semibold; +} +.ch-chart__svg { + @apply block h-auto w-full; +} +.ch-chart__grid { + stroke: var(--ch-color-border); + stroke-width: 1; +} +.ch-chart__tick { + fill: var(--ch-color-text-muted); + font-size: 12px; +} +.ch-chart__total { + fill: currentColor; + font-size: 22px; + font-weight: 600; +} +.ch-chart__legend { + @apply m-0 flex list-none flex-wrap gap-x-4 gap-y-1 p-0 text-sm; +} +.ch-chart__legend li { + @apply flex items-center gap-1.5; +} +.ch-chart__swatch { + @apply inline-block size-3 rounded-sm; +} +.ch-chart__caption, +.ch-chart__empty { + @apply text-sm text-zinc-600; +} +.ch-chart__data summary { + @apply cursor-pointer text-sm text-zinc-600; +} +.ch-chart__data table { + @apply mt-2 w-full border-collapse text-sm tabular-nums; +} +.ch-chart__data th, +.ch-chart__data td { + @apply border-b border-zinc-200 px-2 py-1 text-left; +} + +/* RFC-0022 §4: tables. Below 480 px each row is a card whose cells carry their column's label. */ +.ch-table-wrap { + @apply w-full; +} +.ch-table { + @apply w-full border-collapse text-sm tabular-nums; +} +.ch-table caption { + @apply pb-2 text-left text-base font-semibold; +} +.ch-table th, +.ch-table td { + @apply border-b border-zinc-200 px-3 py-2 text-left align-top; +} +.ch-table th { + @apply font-semibold text-zinc-600; +} +.ch-table [data-align="end"] { + @apply text-right; +} +.ch-table[data-striped="yes"] tbody tr:nth-child(even) { + @apply bg-zinc-50; +} +.ch-table tr[data-highlight="yes"] { + background: color-mix(in srgb, var(--ch-color-brand) 8%, transparent); +} +.ch-table tr[data-highlight="yes"] td:first-child { + box-shadow: inset 3px 0 0 var(--ch-color-brand); +} +@media (max-width: 480px) { + .ch-table thead { + @apply sr-only; + } + .ch-table caption { + @apply block w-full; + } + .ch-table, + .ch-table tbody, + .ch-table tr, + .ch-table td { + @apply block w-full; + } + .ch-table tr { + @apply mb-3 rounded-lg border border-zinc-200 px-3 py-1; + } + .ch-table td { + @apply flex justify-between gap-4 border-b border-zinc-100 px-0 text-right last:border-b-0; + } + .ch-table td::before { + @apply text-left font-medium text-zinc-600; + content: attr(data-label); + } + .ch-table td[data-phone="hide"] { + @apply hidden; + } + .ch-table tr[data-highlight="yes"] td:first-child { + box-shadow: none; + } +} + +/* + * Corners of controls (src/settings.ts `radius`, RFC-0023 §3): buttons and single-line fields + * take `--ch-radius-control`, a pill at `full`; cards and bands keep the smaller radii. After + * the base rules, so they win. + */ +.ch-button, +.ch-hero__cta, +.ch-body + :is(input[type="text"], input[type="search"], input[type="number"], input[type="email"], select), +.ch-body form button[type="submit"]:not(.sr-only) { + border-radius: var(--ch-radius-control, 0.25rem); +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion-effects.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion-effects.css new file mode 100644 index 00000000..b244cea6 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion-effects.css @@ -0,0 +1,217 @@ +/* Heading effects: a highlighted phrase, the brand gradient, words that take turns. */ +.ch-hl { + background-image: linear-gradient( + color-mix(in oklch, var(--ch-color-accent) 38%, transparent), + color-mix(in oklch, var(--ch-color-accent) 38%, transparent) + ); + background-repeat: no-repeat; + background-position: 0 88%; + background-size: 100% 0.36em; +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-hl { + transition: background-size var(--ch-dur-slow) var(--ch-ease-standard) var(--ch-dur-normal); + } + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-pre + .ch-hl { + background-size: 0% 0.36em; + } + @starting-style { + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-hl { + background-size: 0% 0.36em; + } + } +} +/* From the brand to the link color: both read on the page (the token expansion checks them). */ +.ch-gradient-text { + color: var(--ch-color-brand); +} +@supports (background-clip: text) { + .ch-gradient-text { + background-image: linear-gradient(90deg, var(--ch-color-brand), var(--ch-color-link)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } +} +.ch-rotate { + display: inline-grid; + vertical-align: bottom; +} +.ch-rotate__word { + grid-area: 1 / 1; + opacity: 0; +} +.ch-rotate__word[data-active] { + opacity: 1; +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) .ch-rotate__word { + translate: 0 0.35em; + transition: + opacity var(--ch-dur-normal) var(--ch-ease-standard), + translate var(--ch-dur-normal) var(--ch-ease-standard); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-rotate__word[data-active] { + translate: 0 0; + } +} +/* Marquee: a still, wrapping row until the motion module sets it moving (standard and above). */ +.ch-marquee { + --ch-mq-gap: calc(var(--spacing) * 10); + --ch-mq-dur: 34s; + overflow: hidden; +} +.ch-marquee[data-gap="s"] { + --ch-mq-gap: calc(var(--spacing) * 4); +} +.ch-marquee[data-gap="m"] { + --ch-mq-gap: calc(var(--spacing) * 6); +} +.ch-marquee[data-gap="xl"] { + --ch-mq-gap: calc(var(--spacing) * 16); +} +.ch-marquee[data-speed="slow"] { + --ch-mq-dur: 52s; +} +.ch-marquee[data-speed="fast"] { + --ch-mq-dur: 20s; +} +.ch-marquee__track { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ch-mq-gap); +} +.ch-marquee__track > * { + flex: none; +} +@keyframes ch-marquee { + to { + translate: calc(-50% - var(--ch-mq-gap) / 2) 0; + } +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) .ch-marquee[data-ch-ready] { + mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-marquee[data-ch-ready] + .ch-marquee__track { + width: max-content; + flex-wrap: nowrap; + animation: ch-marquee var(--ch-mq-dur) linear infinite; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-marquee[data-ch-ready][data-direction="end"] + .ch-marquee__track { + animation-direction: reverse; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-marquee[data-ch-ready][data-hover-pause]:is(:hover, :focus-within) + .ch-marquee__track { + animation-play-state: paused; + } +} +/* Counter: the final number holds its width while it counts. */ +.ch-counter { + display: flex; + flex-direction: column; + gap: calc(var(--spacing) * 1); +} +.ch-counter__value { + font-size: var(--text-4xl); + font-weight: 700; + line-height: 1.1; +} +.ch-counter__num { + display: inline-grid; + font-variant-numeric: tabular-nums; +} +.ch-counter__num > * { + grid-area: 1 / 1; +} +.ch-counter__hold { + visibility: hidden; +} +.ch-counter__label { + font-size: var(--text-sm); + color: var(--ch-color-text-muted); +} +/* Loops: decorative, slow, only where motion plays, and paused by the reader's control. */ +[data-ch-loop="gradient-shift"] { + background-image: linear-gradient( + 120deg, + color-mix(in oklch, var(--ch-color-brand) 14%, var(--ch-color-surface-raised)), + color-mix(in oklch, var(--ch-color-accent) 14%, var(--ch-color-surface-raised)), + color-mix(in oklch, var(--ch-color-brand) 14%, var(--ch-color-surface-raised)) + ); + background-size: 220% 220%; +} +@keyframes ch-loop-float { + to { + translate: 0 -6px; + } +} +@keyframes ch-loop-pulse { + 50% { + scale: 1.04; + } +} +@keyframes ch-loop-spin { + to { + rotate: 360deg; + } +} +@keyframes ch-loop-gradient { + to { + background-position: 100% 50%; + } +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) [data-ch-loop="float"] { + animation: ch-loop-float 6s ease-in-out infinite alternate; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) [data-ch-loop="pulse"] { + animation: ch-loop-pulse 2.8s ease-in-out infinite; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) [data-ch-loop="spin-slow"] { + animation: ch-loop-spin 28s linear infinite; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-loop="gradient-shift"] { + animation: ch-loop-gradient 14s ease-in-out infinite alternate; + } +} +/* The reader's pause control: only where something actually moves on its own, once a page. */ +.ch-motion-toggle { + display: none; +} +.ch-motion-toggle[hidden] { + display: none !important; +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) .ch-motion-toggle { + display: flex; + width: fit-content; + margin: calc(var(--spacing) * 4) auto 0; + align-items: center; + gap: calc(var(--spacing) * 2); + border: 1px solid var(--ch-color-border-strong); + border-radius: 9999px; + padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 4); + font-size: var(--text-sm); + color: var(--ch-color-text-muted); + background: var(--ch-color-surface); + cursor: pointer; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-motion-toggle[aria-pressed="true"] { + color: var(--ch-color-text); + border-color: var(--ch-color-text); + } +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion-scroll.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion-scroll.css new file mode 100644 index 00000000..9dc3b3cf --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion-scroll.css @@ -0,0 +1,309 @@ +/* + * Scroll effects (RFC-0024 24b): continuous, tied to the element's place in the viewport. With + * scroll-driven animations (Chrome, Safari) they need no script; elsewhere the motion module + * writes the progress as --ch-p (0–1). Only "fade-through" plays at "subtle" (it moves nothing). + */ +[data-ch-scroll] { + --ch-dep-px: calc(var(--ch-dep, 1) * var(--ch-move-m, 0px)); +} +[data-ch-dep="-3"] { + --ch-dep: -3; +} +[data-ch-dep="-2"] { + --ch-dep: -2; +} +[data-ch-dep="-1"] { + --ch-dep: -1; +} +[data-ch-dep="1"] { + --ch-dep: 1; +} +[data-ch-dep="2"] { + --ch-dep: 2; +} +[data-ch-dep="3"] { + --ch-dep: 3; +} +@keyframes ch-sc-parallax { + from { + translate: 0 calc(var(--ch-dep-px) * -1); + } + to { + translate: 0 var(--ch-dep-px); + } +} +@keyframes ch-sc-pan { + from { + translate: calc(var(--ch-dep-px) * -1) 0; + } + to { + translate: var(--ch-dep-px) 0; + } +} +@keyframes ch-sc-fade { + 0%, + 100% { + opacity: 0.25; + } + 30%, + 70% { + opacity: 1; + } +} +@keyframes ch-sc-scale { + 0% { + scale: 0.88; + } + 45%, + 100% { + scale: 1; + } +} +@keyframes ch-sc-rotate { + from { + rotate: -5deg; + } + to { + rotate: 5deg; + } +} +@media (prefers-reduced-motion: no-preference) { + @supports (animation-timeline: view()) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) [data-ch-scroll], + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="fade-through"] { + animation-duration: auto; + animation-timing-function: linear; + animation-fill-mode: both; + animation-timeline: view(); + animation-range: cover 0% cover 100%; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="parallax"] { + animation-name: ch-sc-parallax; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) [data-ch-scroll="pan"] { + animation-name: ch-sc-pan; + } + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="fade-through"] { + animation-name: ch-sc-fade; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="scale-through"] { + animation-name: ch-sc-scale; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="rotate-through"] { + animation-name: ch-sc-rotate; + } + } + @supports not (animation-timeline: view()) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="parallax"] { + translate: 0 calc((var(--ch-p, 0.5) * 2 - 1) * var(--ch-dep-px)); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) [data-ch-scroll="pan"] { + translate: calc((var(--ch-p, 0.5) * 2 - 1) * var(--ch-dep-px)) 0; + } + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="fade-through"] { + opacity: clamp(0.25, calc(1 - (max(var(--ch-p, 0.5), 1 - var(--ch-p, 0.5)) - 0.7) * 2.5), 1); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="scale-through"] { + scale: clamp(0.88, calc(0.88 + var(--ch-p, 0.5) * 0.27), 1); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-scroll="rotate-through"] { + rotate: calc((var(--ch-p, 0.5) * 2 - 1) * 5deg); + } + } +} +/* + * Scroll story (RFC-0024 §3): the first child stays in view beside (or, on phones, above) the + * steps; the step being read gets a brand rule at its edge. Without JavaScript the steps simply + * follow each other, all equally readable. + */ +.ch-scrolly { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: calc(var(--spacing) * 10); + align-items: start; +} +.ch-scrolly[data-ratio="2-1"] { + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); +} +.ch-scrolly[data-ratio="1-2"] { + grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); +} +.ch-scrolly > :first-child { + grid-row: 1 / span 12; + grid-column: 1; + position: sticky; + top: calc(var(--ch-header-h, 0px) + 12vh); +} +.ch-scrolly > .ch-step { + grid-column: 2; +} +.ch-scrolly[data-side="end"] > :first-child { + grid-column: 2; +} +.ch-scrolly[data-side="end"] > .ch-step { + grid-column: 1; +} +.ch-step { + display: flex; + min-height: 70vh; + flex-direction: column; + justify-content: center; + gap: calc(var(--spacing) * 3); + border-inline-start: 3px solid transparent; + padding-inline-start: calc(var(--spacing) * 5); +} +.ch-scrolly[data-ch-live] > .ch-step[data-ch-active] { + border-inline-start-color: var(--ch-color-brand); +} +:root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-scrolly[data-ch-live] + > .ch-step { + transition: border-color var(--ch-dur-normal, 0ms) var(--ch-ease-standard); +} +@media (max-width: 767px) { + .ch-scrolly, + .ch-scrolly[data-ratio] { + grid-template-columns: minmax(0, 1fr); + gap: calc(var(--spacing) * 4); + } + .ch-scrolly > :first-child, + .ch-scrolly[data-side="end"] > :first-child { + grid-row: auto; + grid-column: 1; + top: var(--ch-header-h, 0px); + z-index: 1; + max-height: 45vh; + overflow: hidden; + background: var(--ch-color-surface); + padding-block: calc(var(--spacing) * 2); + } + .ch-scrolly > .ch-step, + .ch-scrolly[data-side="end"] > .ch-step { + grid-column: 1; + min-height: 55vh; + } +} +/* Scroll progress (RFC-0024 §3): a thin bar; hidden where nothing may move. */ +.ch-progress { + display: block; + height: 3px; + overflow: hidden; + background: color-mix(in oklch, var(--ch-color-border) 70%, transparent); +} +.ch-progress[data-placement="page"] { + position: fixed; + inset: 0 0 auto 0; + z-index: 50; +} +.ch-progress[data-placement="section"] { + position: sticky; + top: var(--ch-header-h, 0px); + z-index: 5; +} +.ch-progress__bar { + display: block; + height: 100%; + background: var(--ch-color-brand); + transform-origin: 0 50%; + scale: var(--ch-p, 0) 1; +} +.ch-progress[data-tone="accent"] .ch-progress__bar { + background: var(--ch-color-accent); +} +.ch-progress[data-tone="neutral"] .ch-progress__bar { + background: var(--ch-color-text-muted); +} +:root[data-ch-motion="none"] .ch-progress { + display: none; +} +@keyframes ch-progress { + from { + scale: 0 1; + } + to { + scale: 1 1; + } +} +@supports (animation-timeline: scroll()) { + .ch-surface { + view-timeline: --ch-section block; + } + .ch-progress[data-placement="page"] .ch-progress__bar { + animation: ch-progress linear both; + animation-duration: auto; + animation-timeline: scroll(root); + } + .ch-progress[data-placement="section"] .ch-progress__bar { + animation: ch-progress linear both; + animation-duration: auto; + animation-timeline: --ch-section; + animation-range: contain 0% contain 100%; + } +} +/* + * Kinetic type (RFC-0024 24c): a split heading or text reveals piece by piece; the element itself + * stays put, its pieces (words, or characters within words) rise and fade in turn. + */ +[data-ch-reveal^="split-"] { + --ch-from-tr: none; + --ch-from-sc: none; + --ch-from-f: none; + --ch-from-c: none; +} +[data-ch-reveal^="split-"].ch-pre { + opacity: 1; +} +.ch-split .ch-w { + display: inline-block; +} +.ch-split[data-mode="chars"] .ch-w { + white-space: nowrap; +} +.ch-split[data-mode="chars"] .ch-c { + display: inline-block; +} +.ch-split { + --ch-piece-tr: 0 0.45em; + --ch-piece-step: 45ms; +} +.ch-split[data-mode="chars"] { + --ch-piece-step: 22ms; +} +.ch-split[data-mode="lines"] { + --ch-piece-step: 120ms; +} +:root[data-ch-motion="subtle"] .ch-split { + --ch-piece-tr: none; +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + :is(.ch-split:not([data-mode="chars"]) .ch-w, .ch-split[data-mode="chars"] .ch-c) { + transition: + opacity var(--ch-dur-normal) var(--ch-ease-standard) + calc(var(--ch-piece-step) * var(--ch-i, 0)), + translate var(--ch-dur-normal) var(--ch-ease-standard) + calc(var(--ch-piece-step) * var(--ch-i, 0)); + } + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + .ch-pre + :is(.ch-split:not([data-mode="chars"]) .ch-w, .ch-split[data-mode="chars"] .ch-c) { + opacity: 0; + translate: var(--ch-piece-tr); + } + @starting-style { + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + :is(.ch-split:not([data-mode="chars"]) .ch-w, .ch-split[data-mode="chars"] .ch-c) { + opacity: 0; + translate: var(--ch-piece-tr); + } + } +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion.css new file mode 100644 index 00000000..ca48d13f --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/motion.css @@ -0,0 +1,254 @@ +/* + * Motion (RFC-0024): presets the renderer writes as data-ch-* attributes; the tokens come from + * themeVariables, one set per level, picked by data-ch-motion on <html>. Content is complete + * without this layer; nothing animates layout; nothing moves at "none" or under reduced motion. + * Reveals use opacity, translate, scale, filter and clip-path; hover effects use transform and + * box-shadow, so one element can have both without their transitions overriding each other. + */ +[data-ch-reveal] { + --ch-from-tr: none; + --ch-from-sc: none; + --ch-from-f: none; + --ch-from-c: none; + --ch-rv-dur: var(--ch-dur-normal, 0ms); +} +[data-ch-reveal="rise"] { + --ch-from-tr: 0 var(--ch-move-m, 0); +} +[data-ch-reveal="sink"] { + --ch-from-tr: 0 calc(var(--ch-move-m, 0) * -1); +} +[data-ch-reveal="slide-start"] { + --ch-from-tr: calc(var(--ch-move-l, 0) * -1) 0; +} +[data-ch-reveal="slide-end"] { + --ch-from-tr: var(--ch-move-l, 0) 0; +} +[dir="rtl"] [data-ch-reveal="slide-start"] { + --ch-from-tr: var(--ch-move-l, 0) 0; +} +[dir="rtl"] [data-ch-reveal="slide-end"] { + --ch-from-tr: calc(var(--ch-move-l, 0) * -1) 0; +} +[data-ch-reveal="scale-in"] { + --ch-from-sc: 0.94; +} +[data-ch-reveal="blur-in"] { + --ch-from-f: blur(10px); +} +[data-ch-reveal="wipe-up"] { + --ch-from-c: inset(100% 0 0 0); +} +[data-ch-reveal="wipe-start"] { + --ch-from-c: inset(0 100% 0 0); +} +[data-ch-reveal="mask-rise"] { + --ch-from-c: inset(0 0 100% 0); + --ch-from-tr: 0 var(--ch-move-s, 0); +} +[data-ch-speed="fast"] { + --ch-rv-dur: var(--ch-dur-fast, 0ms); +} +[data-ch-speed="slow"] { + --ch-rv-dur: var(--ch-dur-slow, 0ms); +} +[data-ch-d="1"] { + --ch-d: 1; +} +[data-ch-d="2"] { + --ch-d: 2; +} +[data-ch-d="3"] { + --ch-d: 3; +} +[data-ch-d="4"] { + --ch-d: 4; +} +[data-ch-d="5"] { + --ch-d: 5; +} +[data-ch-d="6"] { + --ch-d: 6; +} +[data-ch-d="7"] { + --ch-d: 7; +} +[data-ch-d="8"] { + --ch-d: 8; +} +[data-ch-d="9"] { + --ch-d: 9; +} +[data-ch-d="10"] { + --ch-d: 10; +} +[data-ch-d="11"] { + --ch-d: 11; +} +[data-ch-d="12"] { + --ch-d: 12; +} +[data-ch-d="13"] { + --ch-d: 13; +} +[data-ch-d="14"] { + --ch-d: 14; +} +[data-ch-d="15"] { + --ch-d: 15; +} +[data-ch-d="16"] { + --ch-d: 16; +} +/* At "subtle" every reveal is a short fade. */ +:root[data-ch-motion="subtle"] [data-ch-reveal] { + --ch-from-tr: none; + --ch-from-sc: none; + --ch-from-f: none; + --ch-from-c: none; +} +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-reveal] { + --ch-tr-r: + opacity var(--ch-rv-dur) var(--ch-ease-standard) calc(var(--ch-stagger) * var(--ch-d, 0)), + translate var(--ch-rv-dur) var(--ch-ease-standard) calc(var(--ch-stagger) * var(--ch-d, 0)), + scale var(--ch-rv-dur) var(--ch-ease-standard) calc(var(--ch-stagger) * var(--ch-d, 0)), + filter var(--ch-rv-dur) var(--ch-ease-standard) calc(var(--ch-stagger) * var(--ch-d, 0)), + clip-path var(--ch-rv-dur) var(--ch-ease-standard) calc(var(--ch-stagger) * var(--ch-d, 0)); + } + /* Below the first screen: the motion module adds .ch-pre while off screen, removes it on entry. */ + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-reveal].ch-pre { + opacity: 0; + translate: var(--ch-from-tr); + scale: var(--ch-from-sc); + filter: var(--ch-from-f); + clip-path: var(--ch-from-c); + } + /* On the first screen: the entrance plays from the first frame, with no script. */ + @starting-style { + :root:is([data-ch-motion="subtle"], [data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-reveal] { + opacity: 0; + translate: var(--ch-from-tr); + scale: var(--ch-from-sc); + filter: var(--ch-from-f); + clip-path: var(--ch-from-c); + } + } +} +:root:not([data-ch-motion="none"]) [data-ch-hover] { + --ch-tr-h: + transform var(--ch-dur-fast, 0ms) var(--ch-ease-standard), + box-shadow var(--ch-dur-fast, 0ms) var(--ch-ease-standard), + background-size var(--ch-dur-normal, 0ms) var(--ch-ease-standard); +} +@media (prefers-reduced-motion: reduce) { + [data-ch-hover] { + --ch-tr-h: outline-color 0s; + } +} +:is([data-ch-reveal], [data-ch-hover]) { + transition: var(--ch-tr-r, outline-offset 0s), var(--ch-tr-h, outline-color 0s); +} +/* A hover effect starts at once, whatever its element's reveal delay. */ +[data-ch-hover]:is(:hover, :focus-visible, :focus-within) { + transition-delay: 0s; +} +/* Effects that do not move anything: every level but "none". */ +:root:not([data-ch-motion="none"]) +[data-ch-hover="glow"]:is(:hover, :focus-visible, :focus-within) { + box-shadow: + 0 0 0 1px color-mix(in oklch, var(--ch-color-brand) 45%, transparent), + 0 10px 30px -10px color-mix(in oklch, var(--ch-color-brand) 55%, transparent); +} +:root:not([data-ch-motion="none"]) + :is( + [data-ch-hover="underline"]:not(.ch-card), + [data-ch-hover="underline"].ch-card :is(h2, h3, h4) + ) { + text-decoration: none; + background-image: linear-gradient(currentColor, currentColor); + background-repeat: no-repeat; + background-position: 0 100%; + background-size: 0% 1px; +} +:root:not([data-ch-motion="none"]) + :is( + [data-ch-hover="underline"]:not(.ch-card):is(:hover, :focus-visible), + [data-ch-hover="underline"].ch-card:is(:hover, :focus-within) :is(h2, h3, h4) + ) { + background-size: 100% 1px; +} +:root:not([data-ch-motion="none"]) [data-ch-hover="spotlight"] { + position: relative; + isolation: isolate; +} +:root:not([data-ch-motion="none"]) [data-ch-hover="spotlight"]::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + border-radius: inherit; + pointer-events: none; + opacity: 0; + background: radial-gradient( + 280px circle at calc(var(--ch-mx, 0.5) * 100%) calc(var(--ch-my, 0.5) * 100%), + color-mix(in oklch, var(--ch-color-brand) 18%, transparent), + transparent 70% + ); + transition: opacity var(--ch-dur-fast, 0ms) var(--ch-ease-standard); +} +:root:not([data-ch-motion="none"]) [data-ch-hover="spotlight"]:hover::after { + opacity: 1; +} +/* Effects that move: "standard" and "expressive", for readers who have not asked for less. */ +@media (prefers-reduced-motion: no-preference) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-hover="lift"]:is(:hover, :focus-visible, :focus-within) { + transform: translateY(calc(var(--ch-move-s) * -0.5)); + box-shadow: 0 16px 36px -16px rgb(0 0 0 / 0.3); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-hover="scale"]:is(:hover, :focus-visible, :focus-within) { + transform: scale(1.03); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-hover="zoom-media"] { + overflow: clip; + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-hover="zoom-media"] + img { + transition: transform var(--ch-dur-slow) var(--ch-ease-standard); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-hover="zoom-media"]:is(:hover, :focus-within) + img { + transform: scale(1.06); + } +} +@media (prefers-reduced-motion: no-preference) and (hover: hover) and (pointer: fine) { + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-hover="tilt"]:hover { + transform: perspective(900px) rotateX(calc((0.5 - var(--ch-my, 0.5)) * 7deg)) + rotateY(calc((var(--ch-mx, 0.5) - 0.5) * 7deg)); + } + :root:is([data-ch-motion="standard"], [data-ch-motion="expressive"]) + [data-ch-hover="magnetic"]:hover { + transform: translate( + calc((var(--ch-mx, 0.5) - 0.5) * 12px), + calc((var(--ch-my, 0.5) - 0.5) * 12px) + ); + } +} +/* The reader's pause control (RFC-0024 §1) stops every loop on the page, ambient layers included. */ +:root[data-ch-motion-paused] *, +:root[data-ch-motion-paused] *::before, +:root[data-ch-motion-paused] *::after { + animation-play-state: paused !important; +} +.ch-motion { + display: block; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/overlays.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/overlays.css new file mode 100644 index 00000000..41d4380d --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/overlays.css @@ -0,0 +1,324 @@ +/* + * RFC-0025 §3: overlays (Dialog, Drawer, Popover, Tooltip, Lightbox). Native top-layer elements, + * painted from the color roles; the page behind a modal does not scroll. + */ +html:has(dialog:modal) { + overflow: hidden; + scrollbar-gutter: stable; +} +.ch-dialog { + margin: auto; + padding: 0; + border: 0; + width: 100%; + max-width: min(36rem, calc(100vw - 2rem)); + max-height: min(85dvh, 48rem); + overflow: auto; + overscroll-behavior: contain; + border-radius: var(--ch-radius-l, 0.75rem); + background: var(--ch-color-surface); + color: var(--ch-color-text); + box-shadow: 0 24px 60px -16px rgb(0 0 0 / 0.4); +} +.ch-dialog[data-size="s"] { + max-width: min(24rem, calc(100vw - 2rem)); +} +.ch-dialog[data-size="l"] { + max-width: min(52rem, calc(100vw - 2rem)); +} +.ch-dialog::backdrop { + background: rgb(0 0 0 / 0.5); +} +.ch-dialog__head { + @apply sticky top-0 z-10 flex items-center justify-between gap-4 px-5 py-4; + background: inherit; + border-bottom: 1px solid var(--ch-color-border); +} +.ch-dialog__title { + @apply text-lg font-semibold; +} +.ch-dialog__close { + @apply inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full; +} +.ch-dialog__close:hover, +.ch-lightbox__nav button:hover { + background: color-mix(in oklab, currentColor 12%, transparent); +} +.ch-dialog__body { + @apply grid gap-4 px-5 py-5; +} +/* Drawers: the full height of an edge, or the bottom (a sheet on phones). */ +.ch-drawer { + margin: 0; + height: 100dvh; + max-height: none; + width: min(26rem, calc(100vw - 3rem)); + border-radius: 0; +} +.ch-drawer[data-size="s"] { + width: min(20rem, calc(100vw - 3rem)); +} +.ch-drawer[data-size="l"] { + width: min(36rem, calc(100vw - 3rem)); +} +.ch-drawer[data-placement="end"] { + margin-inline-start: auto; +} +.ch-drawer[data-placement="bottom"] { + margin-block-start: auto; + width: 100%; + max-width: none; + height: auto; + max-height: 85dvh; + border-radius: var(--ch-radius-l, 0.75rem) var(--ch-radius-l, 0.75rem) 0 0; +} +/* Popovers and tooltips sit next to their trigger (anchor positioning; the module elsewhere). */ +.ch-popover { + margin: 0; + inset: auto; + position-area: bottom span-right; + position-try-fallbacks: flip-block, flip-inline; + margin-block-start: 8px; + width: 18rem; + max-width: calc(100vw - 2rem); + padding: 1rem; + border: 1px solid var(--ch-color-border); + border-radius: var(--ch-radius-m, 0.5rem); + background: var(--ch-color-surface); + color: var(--ch-color-text); + box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.3); +} +.ch-popover[data-width="s"] { + width: 12rem; +} +.ch-popover[data-width="l"] { + width: 26rem; +} +.ch-popover[data-placement="top"] { + position-area: top span-right; + margin-block: 0 8px; +} +.ch-popover[data-placement="start"] { + position-area: left span-bottom; + margin: 0 8px 0 0; +} +.ch-popover[data-placement="end"] { + position-area: right span-bottom; + margin: 0 0 0 8px; +} +.ch-popover > * + * { + margin-block-start: 0.75rem; +} +.ch-tooltip { + margin: 0; + inset: auto; + position-area: top; + position-try-fallbacks: flip-block; + margin-block-end: 6px; + max-width: 16rem; + padding: 0.375rem 0.625rem; + border: 0; + border-radius: var(--ch-radius-s, 0.25rem); + background: var(--ch-color-surface-inverse); + color: var(--ch-color-text-inverse); + font-size: 0.8125rem; + line-height: 1.35; +} +/* Lightbox: thumbnails, then the images large on a dark stage. */ +.ch-lightbox__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.5rem; +} +.ch-lightbox__thumb { + display: block; + overflow: hidden; + border-radius: var(--ch-radius-s, 0.25rem); +} +.ch-lightbox__thumb img { + aspect-ratio: 1; + width: 100%; + height: auto; + object-fit: cover; +} +.ch-lightbox__dialog { + max-width: min(72rem, calc(100vw - 2rem)); + background: #0b0b0c; + color: #f4f4f5; +} +.ch-lightbox__dialog .ch-dialog__head { + border-bottom-color: rgb(255 255 255 / 0.12); +} +.ch-lightbox__count { + @apply text-sm tabular-nums; +} +.ch-lightbox__stage { + @apply px-5 py-4; +} +/* RFC-0028: one stage size for every picture; each shows whole, its sides filled by itself blurred + (the image's own placeholder layer), so moving between portrait and landscape never jumps. */ +.ch-lightbox__item img { + display: block; + width: 100%; + height: min(70dvh, 56rem); + object-fit: contain; + border-radius: var(--ch-radius-m, 0.5rem); +} +.ch-lightbox__item figcaption { + @apply mt-3 text-center text-sm; + color: #d4d4d8; +} +.ch-lightbox__nav { + @apply flex justify-center gap-4 pb-5; +} +.ch-lightbox__nav button { + @apply inline-flex size-11 cursor-pointer items-center justify-center rounded-full text-2xl; +} +/* Entrances and exits at the site's motion level (RFC-0024); none under reduced motion. */ +:root:not([data-ch-motion="none"]) :is(.ch-dialog, .ch-popover, .ch-tooltip) { + transition: + opacity var(--ch-dur-2, 0.2s) ease-out, + translate var(--ch-dur-2, 0.2s) var(--ch-ease-out, ease-out), + display var(--ch-dur-2, 0.2s) allow-discrete, + overlay var(--ch-dur-2, 0.2s) allow-discrete; +} +:root:not([data-ch-motion="none"]) + :is(.ch-dialog:not([open]), .ch-popover:not(:popover-open), .ch-tooltip:not(:popover-open)) { + opacity: 0; +} +@starting-style { + .ch-dialog[open] { + opacity: 0; + translate: 0 0.5rem; + } + .ch-drawer[data-placement="end"][open] { + translate: 100% 0; + } + .ch-drawer[data-placement="start"][open] { + translate: -100% 0; + } + .ch-drawer[data-placement="bottom"][open] { + translate: 0 100%; + } + .ch-popover:popover-open { + opacity: 0; + translate: 0 -0.25rem; + } + .ch-tooltip:popover-open { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + :is(.ch-dialog, .ch-popover, .ch-tooltip) { + transition: none !important; + } +} + +/* + * RFC-0025 25d: the announcement and consent bars, toasts, mega panels and the footer's region. + * A closed bar is hidden by the root marks `prefs.1.js` sets before the page paints. + */ +.ch-announcement { + @apply relative flex items-center justify-center gap-3 px-10 py-2 text-center text-sm; + background: var(--ch-color-surface-inverse); + color: var(--ch-color-text-inverse); +} +.ch-announcement[data-tone="brand"] { + background: var(--ch-color-brand); + color: var(--ch-color-on-brand); +} +.ch-announcement[data-tone="warning"] { + background: #fef3c7; + color: #78350f; +} +.ch-announcement__text a { + @apply ml-2 font-medium underline underline-offset-2; + color: inherit; +} +.ch-announcement__close { + @apply absolute end-2 top-1/2 inline-flex size-8 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full; +} +.ch-announcement__close:hover { + background: color-mix(in oklab, currentColor 15%, transparent); +} +:root[data-ch-announcement-dismissed] .ch-announcement { + display: none; +} +/* The consent bar: at the bottom, never over what has focus (the page keeps room for it). */ +.ch-consent { + @apply fixed inset-x-3 bottom-3 z-50 mx-auto flex max-w-3xl flex-wrap items-center justify-between gap-3 rounded-lg px-4 py-3 text-sm; + background: var(--ch-color-surface-raised, var(--ch-color-surface)); + color: var(--ch-color-text); + border: 1px solid var(--ch-color-border); + box-shadow: 0 12px 32px -12px rgb(0 0 0 / 0.3); +} +.ch-consent__text a { + @apply ml-1 underline underline-offset-2; +} +.ch-consent__actions { + @apply flex gap-2; +} +.ch-consent__actions .ch-button { + @apply px-3 py-1.5 text-sm; +} +:root[data-ch-consent] .ch-consent { + display: none; +} +:root:not([data-ch-consent]):has(.ch-consent) { + scroll-padding-bottom: calc(var(--ch-consent-h, 5rem) + 1rem); +} +:root:not([data-ch-consent]):has(.ch-consent) body { + padding-bottom: calc(var(--ch-consent-h, 5rem) + 1rem); +} +/* Toasts: short news in a live region; the newest at the bottom. */ +.ch-toasts { + @apply pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2; +} +.ch-toast { + @apply rounded-full px-4 py-2 text-sm shadow-lg; + background: var(--ch-color-surface-inverse); + color: var(--ch-color-text-inverse); +} +:root:not([data-ch-motion="none"]) .ch-toast { + transition: + opacity var(--ch-dur-2, 0.2s) ease-out, + translate var(--ch-dur-2, 0.2s) ease-out; +} +@starting-style { + .ch-toast { + opacity: 0; + translate: 0 0.5rem; + } +} +/* A mega panel: under the header, as wide as it, its groups in columns. */ +.ch-nav-menu__pop--mega { + position-area: none; + inset: var(--ch-header-h, 4.75rem) 1rem auto 1rem; + margin: 0 auto; + width: auto; + max-width: 72rem; + padding: 1.5rem; +} +.ch-mega { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 1.5rem; +} +.ch-mega__heading { + @apply mb-2 text-xs font-semibold text-zinc-500 uppercase; +} +.ch-mega__col ul, +ul.ch-mega__col { + @apply grid gap-0.5; +} +/* The footer's surface region sits above the columns, in the footer's width. */ +.ch-footer__region { + @apply mb-10 text-base; + color: var(--ch-color-text); +} +.ch-footer[data-surface="inverse"] .ch-footer__region { + color: inherit; +} +.ch-footer__region .ch-surface { + padding: 0; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/prose.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/prose.css new file mode 100644 index 00000000..52a21da4 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/prose.css @@ -0,0 +1,140 @@ +/* Section defaults; a theme overrides these or swaps components. */ +.ch-hero { + @apply py-12; +} +.ch-hero h1 { + @apply text-4xl font-bold tracking-tight; +} +.ch-hero__sub { + @apply mt-3 text-lg text-zinc-600; +} +.ch-hero__cta { + @apply mt-6 inline-block rounded px-4 py-2 text-(--ch-color-on-brand); + background: var(--ch-color-brand); +} +.ch-article-header { + @apply space-y-2 border-b border-zinc-200 pt-8 pb-6; +} +.ch-article-header h1 { + @apply text-4xl font-bold tracking-tight; +} +.ch-summary { + @apply text-lg text-zinc-600; +} +.ch-article-header time { + @apply text-sm text-zinc-500; +} +.ch-richtext { + @apply space-y-4 py-6 leading-relaxed; +} +.ch-richtext h1 { + @apply text-3xl font-bold; +} +.ch-richtext h2 { + @apply text-2xl font-semibold; +} +.ch-richtext h3 { + @apply pt-2 text-xl font-semibold; +} +.ch-richtext h2 { + @apply pt-4; +} +/* The body's own markdown (no class: surface components inside a body keep their styles). The + reset clears lists, tables, quotes and code; these bring them back in the theme's roles. */ +.ch-richtext :is(ul, ol):not([class]) { + padding-inline-start: 1.5em; +} +.ch-richtext ul:not([class]) { + list-style: disc; +} +.ch-richtext ol:not([class]) { + list-style: decimal; +} +.ch-richtext :is(ul, ol):not([class]) :is(ul, ol):not([class]) { + margin-block-start: 0.35em; +} +.ch-richtext ul:not([class]) ul:not([class]) { + list-style: circle; +} +.ch-richtext li:not([class]) + li:not([class]) { + margin-block-start: 0.35em; +} +.ch-richtext li::marker { + color: var(--ch-color-text-muted); +} +/* Task lists: the checkbox is the marker. */ +.ch-richtext ul:not([class]):has(> li > input[type="checkbox"]) { + list-style: none; + padding-inline-start: 0.25em; +} +.ch-richtext li > input[type="checkbox"] { + inline-size: 1em; + block-size: 1em; + margin-inline-end: 0.5em; + vertical-align: -0.125em; + accent-color: var(--ch-color-brand); +} +.ch-richtext blockquote:not([class]) { + border-inline-start: 3px solid var(--ch-color-border-strong); + padding-inline-start: 1em; + color: var(--ch-color-text-muted); + font-style: italic; +} +.ch-richtext :not(pre) > code { + @apply rounded px-1.5 py-0.5; + font-size: 0.9em; + background: var(--ch-color-surface-muted); +} +.ch-richtext pre:not([class]) { + @apply overflow-x-auto rounded-lg px-4 py-3 text-sm leading-relaxed; + background: var(--ch-color-surface-muted); + border: 1px solid var(--ch-color-border); +} +.ch-richtext hr:not([class]) { + border: 0; + border-block-start: 1px solid var(--ch-color-border); + margin-block: 2rem; +} +/* Tables: ruled rows, a header, the column alignment markdown asked for; wide ones scroll on + their own (the page never scrolls sideways). */ +.ch-richtext table:not([class]) { + display: block; + inline-size: max-content; + max-inline-size: 100%; + overflow-x: auto; + border-collapse: collapse; + font-size: 0.95em; + font-variant-numeric: tabular-nums; +} +.ch-richtext table:not([class]) :is(th, td) { + padding: 0.45em 0.8em; + border-block-end: 1px solid var(--ch-color-border); + text-align: start; + vertical-align: top; +} +.ch-richtext table:not([class]) th { + font-weight: 600; + border-block-end-color: var(--ch-color-border-strong); +} +.ch-richtext table:not([class]) :is(th, td)[align="center"] { + text-align: center; +} +.ch-richtext table:not([class]) :is(th, td)[align="right"] { + text-align: end; +} +/* Footnotes: small raised numbers, the notes listed at the end under a rule. */ +.ch-fnref a { + @apply px-0.5 text-xs no-underline; + color: var(--ch-color-link); +} +.ch-footnotes { + @apply mt-10 pt-4 text-sm; + border-block-start: 1px solid var(--ch-color-border); + color: var(--ch-color-text-muted); +} +.ch-footnotes ol { + @apply list-decimal space-y-1 ps-5; +} +.ch-fnback { + @apply no-underline; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/search-dialog.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/search-dialog.css new file mode 100644 index 00000000..f077d8aa --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/search-dialog.css @@ -0,0 +1,144 @@ +/* + * RFC-0029 §6: the search modal. Painted from the color roles like the overlays (RFC-0025 §3): + * centered and 40rem wide on wide screens, a full-height sheet on phones, and the page behind it + * does not scroll (`overlays.css`). + */ +.ch-search-dialog { + margin: 10dvh auto auto; + padding: 0; + border: 0; + width: 100%; + max-width: min(40rem, calc(100vw - 2rem)); + max-height: min(75dvh, 40rem); + overflow: hidden; + border-radius: var(--ch-radius-l, 0.75rem); + background: var(--ch-color-surface); + color: var(--ch-color-text); + box-shadow: 0 24px 60px -16px rgb(0 0 0 / 0.4); +} +.ch-search-dialog[open] { + display: flex; +} +.ch-search-dialog::backdrop { + background: rgb(0 0 0 / 0.5); +} +.ch-search-dialog__panel { + @apply flex min-h-0 w-full flex-col; +} +.ch-search-dialog__bar { + @apply flex items-center gap-2 px-4 py-3; + border-bottom: 1px solid var(--ch-color-border); +} +.ch-search-dialog__bar input { + @apply min-w-0 flex-1 bg-transparent py-1 text-lg outline-none; +} +.ch-search-dialog__bar input::-webkit-search-cancel-button { + display: none; +} +.ch-search-dialog__close { + @apply shrink-0 cursor-pointer rounded-md px-2 py-1 text-sm; + color: var(--ch-color-text-muted); +} +.ch-search-dialog__close:hover { + background: color-mix(in oklab, currentColor 12%, transparent); +} +.ch-search-dialog__body { + @apply min-h-0 flex-1 overflow-y-auto px-2 py-2; + overscroll-behavior: contain; +} +.ch-search-dialog__body:has(.ch-search-dialog__list:empty):has(.ch-search-dialog__note[hidden]) { + display: none; +} +.ch-search-group + .ch-search-group { + @apply mt-2; +} +.ch-search-group__label { + @apply px-3 pt-2 pb-1 text-xs font-medium; + color: var(--ch-color-text-subtle); +} +.ch-search-option { + @apply grid gap-0.5 rounded-md px-3 py-2 no-underline; + color: inherit; + scroll-margin: 0.5rem; +} +.ch-search-option[aria-selected="true"] { + background: color-mix(in oklab, var(--ch-color-link, currentColor) 12%, transparent); +} +.ch-search-option__context { + @apply text-xs; + color: var(--ch-color-text-subtle); +} +.ch-search-option__title { + @apply font-medium; +} +.ch-search-option__snippet { + @apply line-clamp-2 text-sm; + color: var(--ch-color-text-muted); +} +.ch-search-option__snippet mark { + @apply rounded-sm px-0.5; + background: color-mix(in oklab, #f59e0b 30%, transparent); + color: inherit; +} +.ch-search-option--all .ch-search-option__title { + @apply text-sm; + color: var(--ch-color-link, inherit); +} +.ch-search-dialog__note { + @apply flex flex-wrap items-center gap-2 px-3 py-3 text-sm; + color: var(--ch-color-text-muted); +} +.ch-search-dialog__note a { + @apply underline underline-offset-4; +} +.ch-search-clear { + @apply cursor-pointer text-sm underline underline-offset-4; +} +.ch-search-dialog__keys { + @apply hidden items-center gap-4 px-4 py-2 text-xs sm:flex; + border-top: 1px solid var(--ch-color-border); + color: var(--ch-color-text-subtle); +} +.ch-search-dialog__keys kbd { + @apply me-1 rounded px-1 font-mono; + border: 1px solid var(--ch-color-border); +} +.ch-search-dialog[aria-busy="true"] .ch-search-dialog__list { + opacity: 0.6; +} +/* Phones: a full-height sheet with the field at the top. */ +@media (max-width: 639px) { + .ch-search-dialog { + margin: 0; + max-width: none; + max-height: none; + height: 100dvh; + border-radius: 0; + } +} +/* The header field opens the modal once the module is here. */ +:root[data-ch-search-ready] [data-ch-search-open] input { + cursor: pointer; +} +/* Entrance at the site's motion level (RFC-0024); none under reduced motion. */ +:root:not([data-ch-motion="none"]) .ch-search-dialog { + transition: + opacity var(--ch-dur-2, 0.2s) ease-out, + translate var(--ch-dur-2, 0.2s) var(--ch-ease-out, ease-out), + display var(--ch-dur-2, 0.2s) allow-discrete, + overlay var(--ch-dur-2, 0.2s) allow-discrete; +} +:root:not([data-ch-motion="none"]) .ch-search-dialog:not([open]) { + opacity: 0; +} +@starting-style { + .ch-search-dialog[open] { + opacity: 0; + translate: 0 -0.5rem; + } +} +@media (prefers-reduced-motion: reduce) { + .ch-search-dialog { + transition: none !important; + } +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/search.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/search.css new file mode 100644 index 00000000..bf8e48b4 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/search.css @@ -0,0 +1,59 @@ +/* Search (RFC-0019 §3): the header form, the page's form, results with highlighted snippets. */ +.ch-header-search input { + @apply w-40 rounded-md border border-zinc-300 px-3 py-1.5 text-sm sm:w-48; +} +.ch-search h1 { + @apply text-3xl font-bold tracking-tight; +} +.ch-search-form { + @apply mt-4 flex gap-2; +} +.ch-search-form input { + @apply min-w-0 flex-1 rounded-md border border-zinc-300 px-3 py-2; +} +.ch-search-form button { + @apply rounded-md px-4 py-2 font-medium bg-(--ch-color-surface-inverse) text-(--ch-color-text-inverse) hover:opacity-90; +} +.ch-search-note { + @apply mt-4 rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900; +} +.ch-search-summary { + @apply mt-4 text-sm text-zinc-600; +} +.ch-search-results { + @apply mt-6 space-y-6; +} +.ch-search-context { + @apply text-xs font-medium text-zinc-500; +} +.ch-search-results h2 { + @apply text-lg font-semibold; +} +.ch-search-results h2 a { + @apply underline-offset-4 hover:underline; +} +.ch-search-snippet { + @apply mt-1 text-zinc-700; +} +.ch-search-snippet mark { + @apply rounded-sm bg-amber-100 px-0.5 text-zinc-900; +} +.ch-search-results time { + @apply mt-1 block text-sm text-zinc-500; +} +/* The built-in not-found page: the search page's form, a sentence and a link home. */ +.ch-not-found-text { + @apply mt-3 text-zinc-700; +} +.ch-not-found .ch-search-form { + @apply flex-wrap items-center; +} +.ch-not-found-prompt { + @apply w-full text-sm text-zinc-600; +} +.ch-not-found-home { + @apply mt-6; +} +.ch-not-found-home a { + @apply font-medium underline underline-offset-4; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/surface-media.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/surface-media.css new file mode 100644 index 00000000..2f1d235f --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/surface-media.css @@ -0,0 +1,123 @@ +/* RFC-0028 §3: how an image meets its frame. cover (the default) crops around the focal point the + renderer sets as object-position; the others show the whole picture, and what fills the bars is + the image's placeholder layer: its blurred self (blur-fill), its colour (color-fill) or nothing. */ +.ch-figure:is([data-fit="contain"], [data-fit="blur-fill"], [data-fit="color-fill"]) img, +.ch-carousel[data-aspect] + .ch-carousel__slide + .ch-figure:is([data-fit="contain"], [data-fit="blur-fill"], [data-fit="color-fill"]) + img { + object-fit: contain; +} +.ch-figure[data-fit="natural"] img { + aspect-ratio: auto; + height: auto; +} +.ch-figure figcaption { + @apply mt-2 text-sm text-zinc-500; +} +.ch-button--ghost { + @apply underline-offset-4 hover:underline; +} +.ch-link { + @apply font-medium underline underline-offset-4; +} +.ch-badge { + @apply inline-block w-fit rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-medium text-zinc-700; +} +.ch-badge[data-tone="info"] { + @apply bg-sky-100 text-sky-800; +} +.ch-badge[data-tone="success"] { + @apply bg-emerald-100 text-emerald-800; +} +.ch-badge[data-tone="warning"] { + @apply bg-amber-100 text-amber-900; +} +.ch-badge[data-tone="danger"] { + @apply bg-red-100 text-red-800; +} +.ch-stat { + @apply flex flex-col gap-1; +} +.ch-stat__value { + @apply text-4xl font-bold tracking-tight; +} +.ch-stat__label { + @apply text-sm font-medium text-zinc-700; +} +.ch-stat__detail { + @apply text-sm text-zinc-500; +} +.ch-quote blockquote { + @apply border-l-4 border-zinc-300 pl-4 text-lg italic; +} +.ch-quote figcaption { + @apply mt-2 text-sm text-zinc-600; +} +.ch-quote cite { + @apply font-medium not-italic text-zinc-900; +} +.ch-list { + @apply space-y-2 pl-5; +} +ul.ch-list { + @apply list-disc; +} +ol.ch-list { + @apply list-decimal; +} +.ch-list[data-marker="none"] { + @apply list-none pl-0; +} +/* RFC-0026: an icon of the set as the bullet ("check" included), in the brand color. */ +.ch-list[data-marker="icon"] { + @apply list-none pl-0; +} +.ch-list[data-marker="icon"] li { + @apply flex items-start gap-2; +} +.ch-list__marker { + @apply mt-[0.2em]; + color: var(--ch-color-brand); +} +/* Tabs without JavaScript: the n-th checked radio shows the n-th panel. */ +.ch-tabs { + @apply flex flex-wrap gap-x-1; +} +.ch-tabs__radio { + @apply sr-only; +} +.ch-tabs__tab { + @apply cursor-pointer rounded-t border-b-2 border-transparent px-4 py-2 text-sm font-medium text-zinc-600; +} +.ch-tabs__radio:checked + .ch-tabs__tab { + @apply border-current text-zinc-900; +} +.ch-tabs__radio:focus-visible + .ch-tabs__tab { + @apply outline-2 outline-offset-2; +} +.ch-tabs__panels { + @apply w-full border-t border-zinc-200 pt-4; +} +.ch-tabs__panel { + @apply hidden; +} +.ch-tabs__radio:nth-of-type(1):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(1), +.ch-tabs__radio:nth-of-type(2):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(2), +.ch-tabs__radio:nth-of-type(3):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(3), +.ch-tabs__radio:nth-of-type(4):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(4), +.ch-tabs__radio:nth-of-type(5):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(5), +.ch-tabs__radio:nth-of-type(6):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(6), +.ch-tabs__radio:nth-of-type(7):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(7), +.ch-tabs__radio:nth-of-type(8):checked ~ .ch-tabs__panels > .ch-tabs__panel:nth-child(8) { + @apply flex flex-col gap-4; +} +.ch-accordion { + @apply divide-y divide-zinc-200 border-y border-zinc-200; +} +.ch-disclosure summary { + @apply cursor-pointer py-3 font-medium; +} +.ch-disclosure__body { + @apply flex flex-col gap-3 pb-4; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/surfaces.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/surfaces.css new file mode 100644 index 00000000..b0d5c618 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/surfaces.css @@ -0,0 +1,309 @@ +/* Generated UI (RFC-0020): surfaces built by agents from the catalog's primitives. Values arrive + as data-* attributes from closed sets; nothing a model wrote reaches this file. */ +.ch-surface { + @apply py-6; +} +.ch-stack { + @apply flex flex-col gap-4; +} +.ch-row { + @apply flex flex-wrap items-center gap-4; +} +.ch-row[data-wrap="no"] { + @apply flex-nowrap; +} +.ch-grid { + @apply grid grid-cols-1 gap-4 sm:grid-cols-2; +} +.ch-grid[data-cols="1"] { + @apply sm:grid-cols-1; +} +@media (min-width: 48rem) { + .ch-grid[data-cols="3"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .ch-grid[data-cols="4"] { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .ch-grid[data-cols="5"] { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } + .ch-grid[data-cols="6"] { + grid-template-columns: repeat(6, minmax(0, 1fr)); + } +} +.ch-columns { + @apply grid grid-cols-1 gap-6 md:grid-cols-2; +} +@media (min-width: 48rem) { + .ch-columns[data-ratio="2-1"] { + grid-template-columns: 2fr 1fr; + } + .ch-columns[data-ratio="1-2"] { + grid-template-columns: 1fr 2fr; + } + .ch-columns[data-ratio="1-1-1"] { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .ch-columns[data-ratio="1-2-1"] { + grid-template-columns: 1fr 2fr 1fr; + } +} +:is(.ch-stack, .ch-row, .ch-grid, .ch-columns)[data-gap="none"] { + gap: 0; +} +:is(.ch-stack, .ch-row, .ch-grid, .ch-columns)[data-gap="s"] { + gap: 0.5rem; +} +:is(.ch-stack, .ch-row, .ch-grid, .ch-columns)[data-gap="l"] { + gap: 2rem; +} +:is(.ch-stack, .ch-row, .ch-grid, .ch-columns)[data-gap="xl"] { + gap: 3rem; +} +:is(.ch-stack, .ch-row)[data-align="center"] { + @apply items-center text-center; +} +:is(.ch-stack, .ch-row)[data-align="end"] { + @apply items-end; +} +.ch-row[data-justify="center"] { + @apply justify-center; +} +.ch-row[data-justify="end"] { + @apply justify-end; +} +.ch-row[data-justify="between"] { + @apply justify-between; +} +/* A band leaves the reading column: full width, or wider than the text. */ +.ch-band { + @apply relative py-12; + margin-inline: calc(50% - 50vw); + padding-inline: max(1rem, calc(50vw - 36rem)); +} +.ch-band[data-width="narrow"] .ch-band__inner { + @apply mx-auto max-w-2xl; +} +.ch-band[data-width="wide"] .ch-band__inner, +.ch-band:not([data-width]) .ch-band__inner { + @apply mx-auto max-w-6xl; +} +/* A band's own children (heading, text, buttons, a grid) need room between them; the theme's + spacing unit scales it with the density. */ +.ch-band__inner > * + * { + margin-block-start: calc(var(--spacing) * 4); +} +.ch-band__inner > .ch-heading + :is(.ch-grid, .ch-columns, .ch-stack, .ch-tabs, .ch-table-wrap) { + margin-block-start: calc(var(--spacing) * 6); +} +.ch-band[data-spacing="s"] { + @apply py-6; +} +.ch-band[data-spacing="l"] { + @apply py-16; +} +.ch-band[data-spacing="xl"] { + @apply py-24; +} +/* + * Surfaces (RFC-0023 §4): what a band or card is painted with, from the color roles only, so + * text keeps the contrast the token expansion checked. `data-tone` (older pages) is an alias. + */ +:is([data-surface="muted"], [data-tone="muted"]):is(.ch-band, .ch-card) { + background: var(--ch-color-surface-muted); +} +[data-surface="raised"]:is(.ch-band, .ch-card) { + background: var(--ch-color-surface-raised); + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.05), + 0 6px 20px -6px rgb(0 0 0 / 0.12); +} +:is([data-surface="brand"], [data-tone="brand"]):is(.ch-band, .ch-card) { + background: var(--ch-color-brand); + color: var(--ch-color-on-brand); +} +:is([data-surface="inverse"], [data-tone="inverse"]):is(.ch-band, .ch-card) { + background: var(--ch-color-surface-inverse); + color: var(--ch-color-text-inverse); +} +[data-surface="accent"]:is(.ch-band, .ch-card) { + background: var(--ch-color-accent); + color: var(--ch-color-on-accent); +} +/* From the brand to its gradient end: text on brand reads on both (the expansion checks it). */ +[data-surface="gradient"]:is(.ch-band, .ch-card) { + background: var(--ch-color-brand); + background-image: linear-gradient( + 135deg in oklch, + var(--ch-color-brand), + var(--ch-color-gradient) + ); + color: var(--ch-color-on-brand); +} +/* Soft light from the brand and accent over the muted surface; plain muted where unsupported. */ +[data-surface="mesh"]:is(.ch-band, .ch-card) { + background-color: var(--ch-color-surface-muted); + background-image: + radial-gradient( + at 12% 18%, + color-mix(in oklch, var(--ch-color-brand) 16%, transparent), + transparent 55% + ), + radial-gradient( + at 88% 8%, + color-mix(in oklch, var(--ch-color-accent) 14%, transparent), + transparent 50% + ), + radial-gradient( + at 55% 100%, + color-mix(in oklch, var(--ch-color-brand) 9%, transparent), + transparent 60% + ); +} +[data-surface="dots"]:is(.ch-band, .ch-card) { + background-color: var(--ch-color-surface-muted); + background-image: radial-gradient(var(--ch-color-border-strong) 1px, transparent 1.5px); + background-size: 18px 18px; +} +[data-surface="grid"]:is(.ch-band, .ch-card) { + background-color: var(--ch-color-surface-muted); + background-image: + linear-gradient(var(--ch-color-border) 1px, transparent 1px), + linear-gradient(90deg, var(--ch-color-border) 1px, transparent 1px); + background-size: 28px 28px; +} +/* A fixed grey grain (no color in it), faint on light and dark alike. */ +[data-surface="noise"]:is(.ch-band, .ch-card) { + background-color: var(--ch-color-surface-muted); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .5 0 0 0 0 .5 0 0 0 0 .5 0 0 0 .18 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} +/* Loud surfaces: their text, buttons and links follow the surface's own text color. */ +:is( + [data-surface="brand"], + [data-surface="inverse"], + [data-surface="accent"], + [data-surface="gradient"], + [data-tone="brand"], + [data-tone="inverse"] +) :is(.ch-text, .ch-stat__label, .ch-stat__detail) { + color: inherit; + opacity: 0.85; +} +:is( + [data-surface="brand"], + [data-surface="inverse"], + [data-surface="accent"], + [data-surface="gradient"], + [data-tone="brand"], + [data-tone="inverse"] + ) + .ch-button--primary { + @apply bg-(--ch-color-surface) text-(--ch-color-text); +} +:is( + [data-surface="brand"], + [data-surface="inverse"], + [data-surface="accent"], + [data-surface="gradient"], + [data-tone="brand"], + [data-tone="inverse"] + ) + .ch-button--secondary { + @apply border-current/60 text-inherit; +} +:is( + [data-surface="brand"], + [data-surface="inverse"], + [data-surface="accent"], + [data-surface="gradient"], + [data-tone="brand"], + [data-tone="inverse"] + ) + .ch-link { + color: inherit; +} +:is( + [data-surface="brand"], + [data-surface="inverse"], + [data-surface="accent"], + [data-surface="gradient"], + [data-surface="raised"] +).ch-card { + border-color: transparent; +} +/* A band with an anchor stops below the top of the window when a link jumps to it. */ +.ch-band[id] { + scroll-margin-top: 1rem; +} +.ch-card { + @apply flex flex-col gap-3 rounded-lg border border-zinc-200 p-5 no-underline; +} +a.ch-card { + @apply hover:border-zinc-400; +} +.ch-spacer { + @apply h-8; +} +.ch-spacer[data-size="s"] { + @apply h-4; +} +.ch-spacer[data-size="l"] { + @apply h-16; +} +.ch-spacer[data-size="xl"] { + @apply h-24; +} +.ch-divider { + @apply my-2 border-zinc-200; +} +.ch-heading { + @apply font-bold tracking-tight; +} +h1.ch-heading { + @apply text-4xl sm:text-5xl; +} +h2.ch-heading { + @apply text-3xl; +} +h3.ch-heading { + @apply text-xl; +} +h4.ch-heading { + @apply text-lg; +} +.ch-text { + @apply space-y-2 leading-relaxed; +} +.ch-text[data-size="s"] { + @apply text-sm; +} +.ch-text[data-size="l"] { + @apply text-xl; + /* A lead keeps a readable measure, even at a dramatic type scale. */ + max-inline-size: 48ch; +} +.ch-text[data-muted="yes"] { + @apply text-zinc-600; +} +[data-align="center"]:is(.ch-heading, .ch-text) { + @apply text-center; +} +[data-align="end"]:is(.ch-heading, .ch-text) { + @apply text-right; +} +.ch-figure img { + @apply w-full rounded-lg object-cover; +} +.ch-figure[data-ratio="16:9"] img { + aspect-ratio: 16 / 9; +} +.ch-figure[data-ratio="4:3"] img { + aspect-ratio: 4 / 3; +} +.ch-figure[data-ratio="1:1"] img { + aspect-ratio: 1; +} +.ch-figure[data-ratio="3:4"] img { + aspect-ratio: 3 / 4; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/tokens.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/tokens.css new file mode 100644 index 00000000..7d65a1f9 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/features/tokens.css @@ -0,0 +1,112 @@ +/* + * RFC-0023: the theme's greys and white are color roles (`--ch-color-<role>`, written by + * themeVariables from the site's seed, or the former greys when it has none), so a seed restyles + * them and dark mode works. Text on brand and inverse fills uses its own roles below. + */ +@theme inline { + --color-white: var(--ch-color-surface-raised); + --color-zinc-50: var(--ch-color-surface-muted); + --color-zinc-100: var(--ch-color-surface-muted); + --color-zinc-200: var(--ch-color-border); + --color-zinc-300: var(--ch-color-border-strong); + --color-zinc-400: var(--ch-color-text-subtle); + --color-zinc-500: var(--ch-color-text-subtle); + --color-zinc-600: var(--ch-color-text-muted); + --color-zinc-700: var(--ch-color-text); + --color-zinc-800: var(--ch-color-text); + --color-zinc-900: var(--ch-color-text); + /* + * RFC-0023 §2: with a type scale the sizes are fluid steps (`--ch-step-<n>`, written by + * themeVariables); without one, Tailwind's fixed sizes. Small print stays fixed. + */ + --text-base: var(--ch-step-0, 1rem); + --text-lg: var(--ch-step-1, 1.125rem); + --text-xl: var(--ch-step-2, 1.25rem); + --text-2xl: var(--ch-step-3, 1.5rem); + --text-3xl: var(--ch-step-4, 1.875rem); + --text-4xl: var(--ch-step-5, 2.25rem); + --text-5xl: var(--ch-step-6, 3rem); + /* + * RFC-0023 §3: corners and spacing follow the `radius` and `density` settings + * (`--ch-radius-*`, `--ch-space-unit`); the fallbacks are Tailwind's own values. + */ + --radius: var(--ch-radius-s, 0.25rem); + --radius-sm: var(--ch-radius-s, 0.25rem); + --radius-md: var(--ch-radius-m, 0.375rem); + --radius-lg: var(--ch-radius-l, 0.5rem); + --spacing: var(--ch-space-unit, 0.25rem); +} + +/* Design settings (src/settings.ts, RFC-0017): CSS variables and data-ch-* attributes on <html>. */ +.ch-body { + background: var(--ch-color-surface); + color: var(--ch-color-text); + /* Full-width bands (RFC-0020) use 100vw, which counts a classic scrollbar: never scroll sideways. */ + overflow-x: clip; +} +[data-ch-font="serif"] .ch-body { + font-family: ui-serif, Georgia, Cambria, "Times New Roman", serif; +} +[data-ch-font="humanist"] .ch-body { + font-family: + Seravek, "Gill Sans Nova", Ubuntu, Calibri, "DejaVu Sans", source-sans-pro, sans-serif; +} +[data-ch-font="mono"] .ch-body { + font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; +} +/* + * Font pairings (RFC-0023 §2): the families come from fonts.css and load only when used; each is + * followed by its metric-matched fallback (font-fallbacks.css), so the swap does not move the + * text. They win + * over the older typeface setting; `system` keeps it. + */ +[data-ch-type-pairing="editorial"] { + --ch-font-display: "Fraunces Variable", "Fraunces Variable Fallback", ui-serif, Georgia, serif; + --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif; +} +[data-ch-type-pairing="modern"] { + --ch-font-display: + "Space Grotesk Variable", "Space Grotesk Variable Fallback", ui-sans-serif, system-ui, + sans-serif; + --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif; +} +[data-ch-type-pairing="humanist"] { + --ch-font-display: + "Source Sans 3 Variable", "Source Sans 3 Variable Fallback", ui-sans-serif, system-ui, + sans-serif; + --ch-font-text: + "Source Sans 3 Variable", "Source Sans 3 Variable Fallback", ui-sans-serif, system-ui, + sans-serif; +} +[data-ch-type-pairing="technical"] { + --ch-font-display: + "JetBrains Mono Variable", "JetBrains Mono Variable Fallback", ui-monospace, monospace; + --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif; + --ch-font-code: + "JetBrains Mono Variable", "JetBrains Mono Variable Fallback", ui-monospace, monospace; +} +[data-ch-type-pairing="classic"] { + --ch-font-display: + "Source Serif 4 Variable", "Source Serif 4 Variable Fallback", ui-serif, Georgia, serif; + --ch-font-text: + "Source Serif 4 Variable", "Source Serif 4 Variable Fallback", ui-serif, Georgia, serif; +} +[data-ch-type-pairing]:not([data-ch-type-pairing="system"]) .ch-body { + font-family: var(--ch-font-text); +} +[data-ch-type-pairing]:not([data-ch-type-pairing="system"]) .ch-body :is(h1, h2, h3, h4) { + font-family: var(--ch-font-display); +} +[data-ch-type-pairing="technical"] .ch-body :is(code, pre, kbd) { + font-family: var(--ch-font-code); +} +/* Headings without a lonely last word, paragraphs without orphans, where supported. */ +.ch-body :is(h1, h2, h3, h4) { + text-wrap: balance; +} +.ch-body :is(p, li, blockquote) { + text-wrap: pretty; +} +.ch-richtext a { + color: var(--ch-color-link); +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/font-fallbacks.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/font-fallbacks.css new file mode 100644 index 00000000..495c7e8a --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/font-fallbacks.css @@ -0,0 +1,53 @@ +/* + * Generated by scripts/font-fallbacks.ts: do not edit. Metric-matched fallback faces for the + * pairings' web fonts (tokens.css lists each right after its web font), so the swap to the web + * font does not move the text. + */ +@font-face { + font-family: "Fraunces Variable Fallback"; + src: local("Times New Roman"), local("TimesNewRomanPSMT"); + size-adjust: 113.62%; + ascent-override: 86.08%; + descent-override: 22.44%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Inter Variable Fallback"; + src: local("Arial"), local("ArialMT"); + size-adjust: 107.12%; + ascent-override: 90.44%; + descent-override: 22.52%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Space Grotesk Variable Fallback"; + src: local("Arial"), local("ArialMT"); + size-adjust: 109.47%; + ascent-override: 89.89%; + descent-override: 26.67%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Source Sans 3 Variable Fallback"; + src: local("Arial"), local("ArialMT"); + size-adjust: 92.42%; + ascent-override: 110.80%; + descent-override: 43.28%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "Source Serif 4 Variable Fallback"; + src: local("Times New Roman"), local("TimesNewRomanPSMT"); + size-adjust: 117.91%; + ascent-override: 87.87%; + descent-override: 28.41%; + line-gap-override: 0.00%; +} +@font-face { + font-family: "JetBrains Mono Variable Fallback"; + src: local("Courier New"), local("CourierNewPSMT"); + size-adjust: 99.98%; + ascent-override: 102.02%; + descent-override: 30.00%; + line-gap-override: 0.00%; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/fonts.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/fonts.css new file mode 100644 index 00000000..3317914b --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/fonts.css @@ -0,0 +1,113 @@ +/* + * Self-hosted variable fonts for the type pairings (RFC-0023 §2): upright, weight axis, latin and + * latin-ext (Turkish) subsets from Fontsource 5.3, OFL-1.1 (licences beside the files). A browser + * downloads a file only when a page uses its family and characters. + */ +@font-face { + font-family: "Fraunces Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(/fonts/v1/fraunces-latin-ext-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Fraunces Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(/fonts/v1/fraunces-latin-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Inter Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(/fonts/v1/inter-latin-ext-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Inter Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(/fonts/v1/inter-latin-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url(/fonts/v1/jetbrains-mono-latin-ext-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "JetBrains Mono Variable"; + font-style: normal; + font-display: swap; + font-weight: 100 800; + src: url(/fonts/v1/jetbrains-mono-latin-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Source Sans 3 Variable"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + src: url(/fonts/v1/source-sans-3-latin-ext-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Source Sans 3 Variable"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + src: url(/fonts/v1/source-sans-3-latin-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Source Serif 4 Variable"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + src: url(/fonts/v1/source-serif-4-latin-ext-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Source Serif 4 Variable"; + font-style: normal; + font-display: swap; + font-weight: 200 900; + src: url(/fonts/v1/source-serif-4-latin-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +@font-face { + font-family: "Space Grotesk Variable"; + font-style: normal; + font-display: swap; + font-weight: 300 700; + src: url(/fonts/v1/space-grotesk-latin-ext-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +@font-face { + font-family: "Space Grotesk Variable"; + font-style: normal; + font-display: swap; + font-weight: 300 700; + src: url(/fonts/v1/space-grotesk-latin-wght-normal.woff2) format("woff2-variations"); + unicode-range: + U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/styles/global.css b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/global.css new file mode 100644 index 00000000..7717714a --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/styles/global.css @@ -0,0 +1,31 @@ +/* Scan `src/` only: automatic detection may walk up the file system (docs/design/starter-references.md §6). */ +@import "tailwindcss" source(".."); +@import "./fonts.css"; +@import "./font-fallbacks.css"; + +/* + * One file per feature (docs/engineering/code-structure.md §8), in cascade order. Unlayered on + * purpose: `layer(...)` would let utilities beat component classes, and later files override + * earlier ones at equal specificity (reduced motion after reveals, the transparent header after + * chrome, forced colors after icons). CSS needs every @import first, so `@theme` lives in tokens. + */ +@import "./features/tokens.css"; +@import "./features/prose.css"; +@import "./features/blocks.css"; +@import "./features/article.css"; +@import "./features/comments.css"; +@import "./features/surfaces.css"; +@import "./features/surface-media.css"; +@import "./features/live-surfaces.css"; +@import "./features/motion.css"; +@import "./features/motion-scroll.css"; +@import "./features/motion-effects.css"; +@import "./features/ambient.css"; +@import "./features/search.css"; +@import "./features/search-dialog.css"; +@import "./features/chrome.css"; +@import "./features/chrome-menus.css"; +@import "./features/overlays.css"; +@import "./features/icons.css"; +@import "./features/carousel.css"; +@import "./features/languages.css"; diff --git a/packages/cli/test/fixtures/theme-v0.5.0/src/theme.ts b/packages/cli/test/fixtures/theme-v0.5.0/src/theme.ts new file mode 100644 index 00000000..6425d6d7 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.5.0/src/theme.ts @@ -0,0 +1,49 @@ +/** + * Theme override point: map a section kind to your own component, e.g. + * import MyHero from "./components/MyHero.astro"; + * export const components = { hero: MyHero }; + */ +import type { + BlockComponents, + SectionComponents, +} from "@cloudhal/astro-renderer/components/Sections.astro"; +import type { CollectionIndexHook } from "@cloudhal/astro-renderer/navigation"; +import Testimonial from "./components/blocks/Testimonial.astro"; +import LatestPosts from "./components/LatestPosts.astro"; + +/** The article lists on pages draw as cards with covers and reading time. */ +export const components: SectionComponents = { contentList: LatestPosts }; + +/** + * Block types (RFC-0015) rendered by this theme's own components, by type name: the types this + * theme declares in `src/blocks.ts`, and any built-in it restyles, e.g. + * import Callout from "./components/Callout.astro"; + * export const blocks = { callout: Callout, testimonial: Testimonial }; + * Each gets `{ props, content, contentHtml, media }` (and `section` on pages), in page sections + * and inside article and rich-text bodies. Types not listed use Cloudhal's built-in markup. + */ +export const blocks: BlockComponents = { testimonial: Testimonial }; + +/** Entries per category/tag archive page (`/category/<slug>/page/<n>`). */ +export const ARCHIVE_PAGE_SIZE = 20; + +/** + * Sites without a `primary` menu get a derived header: their top-level pages, then (with this + * flag) the top-level categories (by `position`, then name) whose archive is served (active, + * public, with posts). Set to `false` for a pages-only header. A `primary` menu replaces it. + */ +export const NAV_CATEGORIES = true; + +/** + * Where this theme serves a collection's index, for menu items that link to a collection + * (RFC-0003 §7.1). Return `undefined` to use the default: a published page at the collection's + * permalink prefix (`/docs` for `/docs/{slug}`, RFC-0038 §5), else the first published page that + * lists the collection (the blog's home page lists `posts`); without one the item is hidden. + */ +export const collectionIndex: CollectionIndexHook = () => undefined; + +/** "Related posts" under an article: entries sharing its primary category or tags (0 hides it). */ +export const RELATED_POSTS = 3; + +// The few words the theme itself prints, per language, and the site's own wording over them. +export { strings, type ThemeStrings } from "./strings.ts"; diff --git a/packages/cli/test/old-theme-compat.test.ts b/packages/cli/test/old-theme-compat.test.ts index cffffb26..a8fe3568 100644 --- a/packages/cli/test/old-theme-compat.test.ts +++ b/packages/cli/test/old-theme-compat.test.ts @@ -5,6 +5,9 @@ * README) is built with today's packages: in 0.3.12 `entryPath(entry)` changed shape and every * pre-0.3.11 theme served empty pages. The site also has a collection with typed fields * (RFC-0041): themes from before fields render its entries and simply do not show the values. + * Its collection has RFC-0042's settings too (list fields, an `unlist` expiry, an `Event` preset) + * and a page lists it by field: old themes keep the generic `BlogPosting`, and get the expiry and + * the field listing on upgrade (both live in the renderer's sources and `ContentList.astro`). */ import { spawn } from "node:child_process"; import { cp, mkdtemp, readdir, readFile, rm, symlink } from "node:fs/promises"; @@ -18,7 +21,9 @@ import { init, readProjectConfig, snapshot } from "../src/index.ts"; const here = path.dirname(fileURLToPath(import.meta.url)); const STARTER_NODE_MODULES = path.resolve(here, "../../../starters/blog/node_modules"); const BIN = path.resolve(here, "../bin/cloudhal.ts"); -const FROZEN = ["v0.3.10", "v0.4.0"]; +const FROZEN = ["v0.3.10", "v0.4.0", "v0.5.0"]; +/** Themes from before RFC-0041 show no field values. */ +const SHOWS_FIELDS = new Set(["v0.5.0"]); const TITLE = "Old Theme Site"; /** Runs `cloudhal build` in its own process and returns its exit code and output. */ @@ -43,7 +48,11 @@ async function htmlFiles(dir: string): Promise<string[]> { .map((e) => path.join(e.parentPath, e.name)); } -/** RFC-0041: an `events` collection with typed fields and one published entry with values. */ +/** + * RFC-0041: an `events` collection with typed fields and one published entry with values; RFC-0042: + * list fields, an `unlist` expiry and an `Event` preset, a past event and an `/events` page that + * lists upcoming events by field. + */ async function addTypedFields(projectDir: string): Promise<void> { const cfg = await readProjectConfig(projectDir); const local = await openLocalStore(path.join(projectDir, cfg.dataDir)); @@ -65,10 +74,36 @@ async function addTypedFields(projectDir: string): Promise<void> { labels: { en: { label: "Agenda" } }, localized: true, }, + { + key: "startsAt", + type: "datetime", + labels: { en: { label: "Starts" } }, + localized: false, + }, + { + key: "endsAt", + type: "datetime", + labels: { en: { label: "Ends" } }, + localized: false, + }, + { key: "city", type: "text", labels: { en: { label: "City" } }, localized: false }, ], + listFields: ["startsAt"], + expiry: { field: "endsAt", action: "unlist" }, + structuredData: { + type: "Event", + properties: { + startDate: { kind: "field", field: "startsAt" }, + "location.name": { kind: "field", field: "venue" }, + "location.address.addressLocality": { kind: "field", field: "city" }, + }, + }, }, }, }); + const day = 86_400_000; + const at = (ms: number) => ({ at: new Date(ms).toISOString(), timeZone: "Europe/Istanbul" }); + const now = Date.now(); await local.store.createContentEntry({ siteId: cfg.siteId, collection: "events", @@ -80,11 +115,48 @@ async function addTypedFields(projectDir: string): Promise<void> { format: "markdown", value: "Join us for the launch of the new site, everyone is welcome.", }, - fields: { venue: "Kadıköy Hall", agenda: "- Talks\n- Lunch" }, + fields: { + venue: "Kadıköy Hall", + city: "Istanbul", + agenda: "- Talks\n- Lunch", + startsAt: at(now + 30 * day), + endsAt: at(now + 30 * day + 1), + }, + tags: [], + ...(hello?.terms ? { terms: hello.terms } : {}), + author: { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }, + } as never); + await local.store.createContentEntry({ + siteId: cfg.siteId, + collection: "events", + slug: "retro", + locale: site.defaultLocale, + status: "published", + title: "Retro night", + body: { format: "markdown", value: "An evening that already happened, with music." }, + fields: { venue: "Moda Stage", startsAt: at(now - 3 * day), endsAt: at(now - 2 * day) }, tags: [], ...(hello?.terms ? { terms: hello.terms } : {}), author: { kind: "human", id: "user_01HZX8K3Q9CD", name: "Halil" }, } as never); + await local.store.createPage((await local.store.getSite(cfg.siteId)) ?? site, { + siteId: cfg.siteId, + path: "/events", + title: "Events", + locale: site.defaultLocale, + status: "published", + sections: [ + { + id: "sec_upcomingevt1", + kind: "contentList", + collection: "events", + limit: 10, + sort: "newest", + where: [{ field: "startsAt", op: "gte", value: "now" }], + orderBy: { field: "startsAt", direction: "asc" }, + }, + ], + } as never); } finally { await local.close(); } @@ -137,7 +209,19 @@ describe.each(FROZEN)("a theme copied at %s still builds with today's packages", expect(pages.archive).toContain('href="/posts/hello-world"'); expect(pages.article).toContain("<title>Hello, world · "); expect(pages.event).toContain("<title>Launch party · "); - expect(pages.event).not.toContain("Kadıköy Hall"); // old themes do not show field values + // themes from before RFC-0041 do not show field values + if (!SHOWS_FIELDS.has(version)) expect(pages.event).not.toContain("Kadıköy Hall"); + // RFC-0042: entryHead is unchanged, so old themes keep the generic BlogPosting + expect(pages.event).toContain('"@type":"BlogPosting"'); + expect(pages.event).not.toContain('"@type":"Event"'); + // the expiry and the field listing come with the renderer: a past event keeps its page but + // leaves the listing, the sitemap and the feed + expect(await read("events", "retro", "index.html")).toContain("Retro night"); + const listing = await read("events", "index.html"); + expect(listing).toContain('href="/events/launch"'); + expect(listing).not.toContain("/events/retro"); + expect(await read("sitemap.xml")).not.toContain("/events/retro"); + expect(await read("rss.xml")).not.toContain("Retro night"); // No page of the build is empty (a theme that throws mid-stream leaves a bare document). const files = await htmlFiles(dist); diff --git a/packages/cloudflare/src/admin-worker.ts b/packages/cloudflare/src/admin-worker.ts index 6e6286ae..7e9637cb 100644 --- a/packages/cloudflare/src/admin-worker.ts +++ b/packages/cloudflare/src/admin-worker.ts @@ -284,8 +284,8 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) { let migrated = false; /** The request backstop's isolate state (RFC-0008 §8.6); plain numbers. */ const backstop: BackstopState = { lastCheck: 0, lastRun: 0 }; - /** The site Worker confirmed it enforces time gates (RFC-0008 §7.7); a plain boolean. */ - let gatesConfirmed = false; + /** The gates version the site Worker confirmed (RFC-0008 §7.7, RFC-0042 §3.6); 0: none yet. */ + let gatesConfirmed = 0; const ensureMigrated = async (sql: SqlDriver) => { if (migrated) return; try { @@ -387,8 +387,8 @@ export function createAdminWorker(options: AdminWorkerOptions = {}) { : undefined, token: purgeToken, confirmed: () => gatesConfirmed, - remember: () => { - gatesConfirmed = true; + remember: (version) => { + gatesConfirmed = version; }, log, }), diff --git a/packages/cloudflare/src/features.ts b/packages/cloudflare/src/features.ts index 66fbb383..30788f56 100644 --- a/packages/cloudflare/src/features.ts +++ b/packages/cloudflare/src/features.ts @@ -2,9 +2,10 @@ * What the site Worker enforces, asked over the `SITE` binding (RFC-0008 §7.6–§7.7). An older site * Worker ignores time gates and would show embargoed content, so the admin Worker lets nobody * write `availability` (REST, MCP, bundle import) until the site Worker answers - * `GET /__cloudhal/features` with `{ "gates": 1 }` or more (invariant S15). + * `GET /__cloudhal/features` with `{ "gates": 1 }` or more (invariant S15), nor a collection's + * `expiry` until it answers `{ "gates": 2 }` (derived gates, RFC-0042 §3.6). */ -import { GATES_FEATURE_VERSION, type GateSupport } from "@cloudhal/core-ts/availability"; +import { AVAILABILITY_GATES_VERSION, type GateSupport } from "@cloudhal/core-ts/availability"; import type { SiteFetch } from "./purge.ts"; const FEATURES_URL = "https://site.internal/__cloudhal/features"; @@ -28,27 +29,37 @@ export async function siteFeatures( /** * The store's gate capability for one admin Worker isolate. A confirmation is remembered as a - * plain value (`remember`/`confirmed`, nothing awaited across requests, ADR-0012); a refusal or an - * error is asked again on the next write that sets gates, so deploying the site Worker is enough. + * plain value (`remember`/`confirmed`, nothing awaited across requests, ADR-0012): the gates + * version the site Worker reported (`true` reads as 1, what Workers before RFC-0042 answered). A + * refusal or an error is asked again on the next write that needs gates, so deploying the site + * Worker is enough. */ export function siteGateSupport(options: { readonly fetch: SiteFetch | undefined; readonly token: string | undefined; - readonly confirmed: () => boolean; - readonly remember: () => void; + readonly confirmed: () => boolean | number; + readonly remember: (version: number) => void; readonly log?: (line: string) => void; }): GateSupport { + const known = () => { + const c = options.confirmed(); + return c === true ? AVAILABILITY_GATES_VERSION : c === false ? 0 : c; + }; return { - enforced: async () => { - if (options.confirmed()) return true; + enforced: async (_siteId, version = AVAILABILITY_GATES_VERSION) => { + if (known() >= version) return true; if (!options.fetch || !options.token) return false; try { - const features = await siteFeatures(options.fetch, options.token); - if ((features?.gates ?? 0) < GATES_FEATURE_VERSION) { - options.log?.("site Worker does not enforce time gates yet: availability is refused"); + const gates = (await siteFeatures(options.fetch, options.token))?.gates ?? 0; + if (gates > known() && gates >= AVAILABILITY_GATES_VERSION) options.remember(gates); + if (gates < version) { + options.log?.( + version > AVAILABILITY_GATES_VERSION + ? "site Worker does not enforce derived gates yet: collection expiry is refused" + : "site Worker does not enforce time gates yet: availability is refused", + ); return false; } - options.remember(); return true; } catch (e) { options.log?.(`features check failed: ${e instanceof Error ? e.message : String(e)}`); diff --git a/packages/cloudflare/test/features.test.ts b/packages/cloudflare/test/features.test.ts index ba73192f..6a357998 100644 --- a/packages/cloudflare/test/features.test.ts +++ b/packages/cloudflare/test/features.test.ts @@ -1,8 +1,9 @@ /** * The admin side of the features handshake (RFC-0008 §7.7, S15): `availability` is allowed only - * once the site Worker answers `GET /__cloudhal/features` with `{ "gates": 1 }`; a confirmation is - * kept as a plain value, a refusal is asked again. The site Worker's side, and an "old" site Worker - * without the endpoint, run on workerd in packages/cli/test/cloudflare-gates.test.ts. + * once the site Worker answers `GET /__cloudhal/features` with `{ "gates": 1 }`, a collection's + * `expiry` once it answers 2; a confirmation is kept as a plain value, a refusal is asked again. + * The site Worker's side, and an "old" site Worker without the endpoint, run on workerd in + * packages/cli/test/cloudflare-gates.test.ts. */ import { describe, expect, it } from "vitest"; import { siteFeatures, siteGateSupport } from "../src/features.ts"; @@ -72,4 +73,32 @@ describe("features handshake (admin side)", () => { }); expect(await unbound.enforced("site_a")).toBe(false); }); + + it("asks for gates 2 before a collection expiry (RFC-0042 §3.6) and remembers the version", async () => { + let confirmed: boolean | number = false; + let answer = () => Response.json({ gates: 1 }); + const s = site(() => answer()); + const gates = siteGateSupport({ + fetch: s.fetch, + token: "tok", + confirmed: () => confirmed, + remember: (v) => { + confirmed = v; + }, + }); + // an RFC-0008 site Worker: availability yes, expiry no + expect(await gates.enforced("site_a", 2)).toBe(false); + expect(confirmed).toBe(1); + expect(await gates.enforced("site_a")).toBe(true); + const asked = s.calls.length; + expect(await gates.enforced("site_a", 2)).toBe(false); + expect(s.calls.length).toBe(asked + 1); + answer = () => Response.json({ gates: 2 }); + expect(await gates.enforced("site_a", 2)).toBe(true); + expect(confirmed).toBe(2); + const after = s.calls.length; + expect(await gates.enforced("site_a", 2)).toBe(true); + expect(await gates.enforced("site_a")).toBe(true); + expect(s.calls).toHaveLength(after); + }); }); diff --git a/packages/core-ts/src/availability.ts b/packages/core-ts/src/availability.ts index 1d863098..19a67136 100644 --- a/packages/core-ts/src/availability.ts +++ b/packages/core-ts/src/availability.ts @@ -228,13 +228,25 @@ export function checkTimeZone(zone: string | undefined, previous?: string): void * gates are refused (`gates_unsupported`) until the renderer that serves the site confirms it * enforces them: the admin Worker asks the site Worker's `GET /__cloudhal/features`; on Node the * renderer and the writer are one process (`GATES_ENFORCED`). + * + * `version` is the gates feature a write needs (RFC-0042 §3.6): `1` (absent) for `availability`, + * `EXPIRY_GATES_VERSION` for a collection's `expiry` (derived gates). */ export interface GateSupport { - enforced(siteId: string): boolean | Promise<boolean>; + enforced(siteId: string, version?: number): boolean | Promise<boolean>; } /** The renderer in this process enforces gates (Node: `cloudhal dev`, `studio`, self-host). */ export const GATES_ENFORCED: GateSupport = { enforced: () => true }; -/** The value `GET /__cloudhal/features` answers with (and the admin side checks). */ -export const GATES_FEATURE_VERSION = 1; +/** + * The value `GET /__cloudhal/features` answers with: `1` time gates (RFC-0008), `2` also derived + * gates from a collection's `expiry` (RFC-0042 §3.6). + */ +export const GATES_FEATURE_VERSION = 2; + +/** The gates feature `availability` needs (RFC-0008 §7.7). */ +export const AVAILABILITY_GATES_VERSION = 1; + +/** The gates feature a collection's `expiry` needs (RFC-0042 §3.6). */ +export const EXPIRY_GATES_VERSION = 2; diff --git a/packages/core-ts/src/expiring-entries.ts b/packages/core-ts/src/expiring-entries.ts new file mode 100644 index 00000000..b622de79 --- /dev/null +++ b/packages/core-ts/src/expiring-entries.ts @@ -0,0 +1,83 @@ +/** + * RFC-0042 §3.5: publishing a change that adds a derived gate to a live entry, or moves its gate + * earlier, removes content at a set time. It is classed like RFC-0008's `schedule_unpublish` + * (§7.6, "expiry removes content, so it waits for a person"): an agent's publish or release that + * carries one is never low risk. This module finds those entries; the auto-mode checks + * (`checkAutoPublish`, `checkAutoRelease`) decide with them. + */ +import type { ContentEntry, Site } from "@cloudhal/core-contracts"; +import { derivedGate, hasExpiryRules } from "./fields/entry-gates.ts"; +import type { FieldsOriginal } from "./fields/resolve.ts"; +import type { SiteFields } from "./fields/types.ts"; +import type { SiteGraphStore, SnapshotDiff } from "./store.ts"; + +/** One version of an entry with the site rules and the original it is read with. */ +export interface GateSide { + readonly site: SiteFields; + readonly entry: ContentEntry; + readonly original?: FieldsOriginal | undefined; +} + +/** + * True when `after` has a derived gate that `before` (the live version) lacked, or an earlier + * one. A gate removed or moved later only keeps content up longer. + */ +export function advancesDerivedGate(before: GateSide, after: GateSide): boolean { + const next = derivedGate(after.site, after.entry, after.original); + if (!next) return false; + const live = derivedGate(before.site, before.entry, before.original); + return live === undefined || next.at < live.at; +} + +const groupOf = (e: ContentEntry) => { + const g = e.translation?.group; + return g && g !== e.id && e.translation?.source?.siteId === undefined ? g : undefined; +}; + +/** + * The live entries whose pending version (`diff`, from `pendingChanges`) adds or advances a + * derived gate. Empty, without a read, when neither the working site nor the live one has a rule. + */ +export async function expiringEntries( + store: SiteGraphStore, + siteId: string, + diff: SnapshotDiff, +): Promise<Set<string>> { + const out = new Set<string>(); + const site = await store.getSite(siteId); + const liveSnap = site?.publishedSnapshotId + ? await store.getSnapshot(site.publishedSnapshotId) + : undefined; + if (!site || !liveSnap) return out; + const liveRef = (id: string, kind: string) => + liveSnap.nodes.find((n) => n.id === id && n.kind === kind); + const siteRef = liveSnap.nodes.find((n) => n.kind === "site"); + const liveSite = siteRef ? await store.readSnapshotNode<Site>(siteRef) : undefined; + if (!hasExpiryRules(site) && !(liveSite && hasExpiryRules(liveSite))) return out; + const liveEntry = async (id: string) => { + const ref = liveRef(id, "contentEntry"); + return ref ? store.readSnapshotNode<ContentEntry>(ref) : undefined; + }; + for (const change of diff.changed) { + if (change.after.kind !== "contentEntry") continue; + const [working, live] = await Promise.all([ + store.getContentEntry(change.after.id), + liveEntry(change.after.id), + ]); + if (!working || !live) continue; + const wg = groupOf(working); + const lg = groupOf(live); + const after = { + site, + entry: working, + original: wg ? await store.getContentEntry(wg) : undefined, + }; + const before = { + site: liveSite ?? site, + entry: live, + original: lg ? await liveEntry(lg) : undefined, + }; + if (advancesDerivedGate(before, after)) out.add(working.id); + } + return out; +} diff --git a/packages/core-ts/src/fields/entry-gates.ts b/packages/core-ts/src/fields/entry-gates.ts new file mode 100644 index 00000000..bd1d45a5 --- /dev/null +++ b/packages/core-ts/src/fields/entry-gates.ts @@ -0,0 +1,133 @@ +/** + * RFC-0042 §3–§4 on the read side: what an entry's derived gate (`CollectionSettings.expiry`) does + * to it at an instant, and the instants a published site's gate index adds for derived gates and + * for `now` conditions on list fields. One module for the renderer's sources (live and snapshot), + * search rows and the auto-publish check, so they agree to the millisecond. Pure, Worker-safe and + * Temporal-free; `now` is always passed in. + * + * | at `now` | entry URL | listings, feeds, sitemap, search | + * |---|---|---| + * | `shown` | 200 | listed | + * | `unlisted` (`unlist` past its gate) | 200 | left out | + * | `hidden` (unpublished, `availability` closed, or `hide` past its gate) | 404 | left out | + */ +import type { ContentEntry } from "@cloudhal/core-contracts"; +import { gateState, type Publishable } from "../availability.ts"; +import { dayStartIn, expiryInstant, nextDay } from "./gate.ts"; +import { type FieldsOriginal, resolveFields } from "./resolve.ts"; +import type { SiteFields } from "./types.ts"; +import { isCalendarDay } from "./value-checks.ts"; + +/** What a collection's expiry does once an entry's derived gate passes (RFC-0042 §3.3). */ +export type ExpiryAction = "unlist" | "hide"; + +/** An entry's derived gate: the instant (epoch ms) and what happens from then on. */ +export interface DerivedGate { + readonly at: number; + readonly action: ExpiryAction; +} + +/** How the published site treats an entry at an instant. */ +export type EntryVisibility = "shown" | "unlisted" | "hidden"; + +/** The entry shape the derived gate reads. */ +export type GatedEntry = Pick<ContentEntry, "id" | "collection" | "fields"> & { + translation?: ContentEntry["translation"] | undefined; +}; + +/** True when some collection of the site derives gates from a field. */ +export function hasExpiryRules(site: SiteFields): boolean { + return Object.values(site.collections ?? {}).some((c) => c?.expiry !== undefined); +} + +/** + * The derived gate of one entry: its collection's rule applied to its resolved values (a + * translation reads the original's non-localized date). `undefined` without a rule or a value. + */ +export function derivedGate( + site: SiteFields, + entry: GatedEntry, + original?: FieldsOriginal, +): DerivedGate | undefined { + const rule = site.collections?.[entry.collection]?.expiry; + if (!rule) return undefined; + const at = expiryInstant(site, entry.collection, resolveFields(site, entry, original)); + return at === undefined ? undefined : { at, action: rule.action }; +} + +/** + * `shown`, `unlisted` or `hidden` at `now`: published with open gates (`availability`, RFC-0008), + * then the derived gate. `from` (an embargo) is never affected by a derived gate. + */ +export function entryVisibility( + entry: Publishable, + derived: DerivedGate | undefined, + now: number, +): EntryVisibility { + if (entry.status !== "published" || gateState(entry, now) !== "open") return "hidden"; + if (derived && !(now < derived.at)) return derived.action === "hide" ? "hidden" : "unlisted"; + return "shown"; +} + +/** The originals of same-site translations among `entries`, by id. */ +export function originalsOf<T extends GatedEntry>( + entries: readonly T[], +): (entry: T) => T | undefined { + const byId = new Map(entries.map((e) => [e.id, e])); + return (entry) => { + const group = entry.translation?.group; + return group && group !== entry.id ? byId.get(group) : undefined; + }; +} + +/** + * The derived gates of `entries` by entry id (only entries that have one). Originals are looked up + * among the same entries, so pass every entry a translation may read from. + */ +export function derivedGates( + site: SiteFields, + entries: readonly GatedEntry[], +): Map<string, DerivedGate> { + const out = new Map<string, DerivedGate>(); + if (!hasExpiryRules(site)) return out; + const originalOf = originalsOf(entries); + for (const e of entries) { + const gate = derivedGate(site, e, originalOf(e)); + if (gate) out.set(e.id, gate); + } + return out; +} + +/** + * RFC-0042 §4 "`now` and the cache": the instants at which a `now` condition on a list field may + * change its answer for one of `entries` — a `datetime` value's `at` (and the next millisecond, so + * `gte`/`lte` flip too), a `date` value's day start and the next day's start in the site's zone. + * Bounded by the ≤ 4 list fields per collection. + */ +export function listFieldInstants(site: SiteFields, entries: readonly GatedEntry[]): number[] { + const out = new Set<number>(); + const zone = site.timeZone ?? "UTC"; + const originalOf = originalsOf(entries); + for (const e of entries) { + const settings = site.collections?.[e.collection]; + const keys = settings?.listFields; + if (!keys?.length) continue; + const wanted = (settings?.fields ?? []).filter( + (d) => keys.includes(d.key) && (d.type === "date" || d.type === "datetime"), + ); + if (wanted.length === 0) continue; + const resolved = resolveFields(site, e, originalOf(e)); + for (const def of wanted) { + const value = resolved[def.key]?.value; + if (def.type === "date" && typeof value === "string" && isCalendarDay(value)) { + out.add(dayStartIn(value, zone)); + out.add(dayStartIn(nextDay(value), zone)); + } else if (def.type === "datetime") { + const at = (value as { at?: unknown } | undefined)?.at; + const ms = typeof at === "string" ? Date.parse(at) : Number.NaN; + if (Number.isFinite(ms)) out.add(ms).add(ms + 1); + } + } + } + return [...out]; +} diff --git a/packages/core-ts/src/fields/index.ts b/packages/core-ts/src/fields/index.ts index ff41ce17..ae0a27d4 100644 --- a/packages/core-ts/src/fields/index.ts +++ b/packages/core-ts/src/fields/index.ts @@ -21,6 +21,18 @@ export { type FieldDefinitionChange, fieldDefinitionChanges, } from "./definitions.ts"; +export { + type DerivedGate, + derivedGate, + derivedGates, + type EntryVisibility, + type ExpiryAction, + entryVisibility, + type GatedEntry, + hasExpiryRules, + listFieldInstants, + originalsOf, +} from "./entry-gates.ts"; export { type FormatFieldOptions, formatFieldValue } from "./format.ts"; export { dayStartIn, effectiveUntil, expiryInstant, fieldGateInstant, nextDay } from "./gate.ts"; export { diff --git a/packages/core-ts/src/index.ts b/packages/core-ts/src/index.ts index 60dc8981..5ae75490 100644 --- a/packages/core-ts/src/index.ts +++ b/packages/core-ts/src/index.ts @@ -40,6 +40,7 @@ export { queryContentEntries, } from "./entry-query.ts"; export * from "./errors.ts"; +export { advancesDerivedGate, expiringEntries, type GateSide } from "./expiring-entries.ts"; export * from "./fields-operations/index.ts"; export * from "./filter.ts"; export * from "./hash.ts"; diff --git a/packages/core-ts/src/search-rows.ts b/packages/core-ts/src/search-rows.ts index 08525cc3..d1ccd91c 100644 --- a/packages/core-ts/src/search-rows.ts +++ b/packages/core-ts/src/search-rows.ts @@ -4,6 +4,8 @@ * `cloudhal dev` (from the live graph) index the same things. Worker-safe. */ import type { ContentEntry, Page, Taxonomy, Term } from "@cloudhal/core-contracts"; +import { type DerivedGate, derivedGate } from "./fields/entry-gates.ts"; +import type { SiteFields } from "./fields/types.ts"; import { termName } from "./names.ts"; import { entryPath, type PermalinkSite } from "./permalinks.ts"; import { MARK_CLOSE, MARK_OPEN, SEARCH_TEXT_MAX, type SearchRow } from "./search-common.ts"; @@ -59,6 +61,21 @@ export function searchText(body: Pick<ContentEntry["body"], "format" | "value">) return clip(text); } +/** What an article row reads of the site: permalinks, and the expiry rules and zone (RFC-0042). */ +export type SearchRowSite = PermalinkSite & Pick<SiteFields, "timeZone">; + +/** + * A row's `gateUntil`: the entry's own `until`, or the earlier of it and its derived gate + * (RFC-0042 §3.4), so an unlisted or hidden entry leaves search when its gate passes. + */ +function rowUntil(entry: ContentEntry, derived: DerivedGate | undefined): string | undefined { + const own = iso(entry.availability?.until); + if (!derived) return own; + return new Date( + own === undefined ? derived.at : Math.min(Date.parse(own), derived.at), + ).toISOString(); +} + /** Readers see it: published, or (with `drafts`, as `cloudhal dev` shows them) anything not archived. */ const shown = (status: string, drafts: boolean | undefined) => status === "published" || (drafts === true && status !== "archived"); @@ -93,11 +110,12 @@ export function searchRowOf( entry: ContentEntry, options: { drafts?: boolean; - site?: PermalinkSite | undefined; + site?: SearchRowSite | undefined; original?: Pick<ContentEntry, "id" | "fields"> | undefined; } = {}, ): SearchRow | undefined { if (!shown(entry.status, options.drafts)) return undefined; + const derived = options.site ? derivedGate(options.site, entry, options.original) : undefined; const terms = entryTermIds(entry); return { nodeId: entry.id, @@ -109,7 +127,7 @@ export function searchRowOf( href: entryPath(options.site, entry), publishedAt: iso(entry.publishedAt), gateFrom: iso(entry.availability?.from), - gateUntil: iso(entry.availability?.until), + gateUntil: rowUntil(entry, derived), ...(terms.length > 0 ? { terms } : {}), }; } @@ -264,8 +282,8 @@ export function searchDocuments(input: { drafts?: boolean; /** The site's default locale, whose archive slugs are the terms' own (default: `locales[0]`). */ defaultLocale?: string; - /** The site's collection permalinks (RFC-0038), for article links. */ - site?: PermalinkSite; + /** The site's collection permalinks (RFC-0038), for article links; its expiry rules (RFC-0042). */ + site?: SearchRowSite; }): SearchRow[] { const options = { drafts: input.drafts ?? false }; const entryOptions = { ...options, site: input.site }; diff --git a/packages/core-ts/src/search.ts b/packages/core-ts/src/search.ts index e6d75941..495d39ac 100644 --- a/packages/core-ts/src/search.ts +++ b/packages/core-ts/src/search.ts @@ -19,6 +19,7 @@ */ import type { ContentEntry, NodeRef, Page, Site, Taxonomy, Term } from "@cloudhal/core-contracts"; import type { SqlDriver, SqlStatement } from "@cloudhal/storage"; +import { sameJson } from "./fields/types.ts"; import { changedPermalinks } from "./permalinks.ts"; import { CJK_TABLE, SEARCH_TABLES, searchTable, trigramQuery } from "./search-cjk.ts"; import { @@ -242,10 +243,24 @@ async function permalinksMoved( const before = base?.nodes.find((n) => n.kind === "site"); if (!after || !before || after.hash === before.hash) return false; const [a, b] = [(await read(after)) as Site, (await read(before)) as Site]; - return changedPermalinks(b, a).length > 0 || searchableChanged(b, a); + return changedPermalinks(b, a).length > 0 || searchableChanged(b, a) || expiryChanged(b, a); } -/** A translation's original in `target`, read only when its collection has searchable fields. */ +/** + * RFC-0042 §3.4: a changed expiry rule, its field's definition or the site's zone moves every + * derived gate, so entry rows get their new `gateUntil` with this publish. + */ +function expiryChanged(before: Site, after: Site): boolean { + const rules = (site: Site) => + Object.entries(site.collections ?? {}) + .filter(([, c]) => c?.expiry) + .map(([key, c]) => [key, c?.expiry, c?.fields?.find((d) => d.key === c?.expiry?.field)]); + const [b, a] = [rules(before), rules(after)]; + if (b.length === 0 && a.length === 0) return false; + return !sameJson(b, a) || before.timeZone !== after.timeZone; +} + +/** A translation's original in `target`, read when its collection has searchable fields or expiry. */ async function originalFor( entry: ContentEntry, site: Site | undefined, @@ -253,8 +268,10 @@ async function originalFor( read: (ref: NodeRef) => Promise<unknown>, ): Promise<ContentEntry | undefined> { const group = entry.translation?.group; - if (!group || group === entry.id || searchableFields(site, entry.collection).length === 0) - return undefined; + const reads = + searchableFields(site, entry.collection).length > 0 || + site?.collections?.[entry.collection]?.expiry !== undefined; + if (!group || group === entry.id || !reads) return undefined; const ref = target.nodes.find((n) => n.id === group && n.kind === "contentEntry"); return ref ? ((await read(ref)) as ContentEntry) : undefined; } diff --git a/packages/core-ts/src/store.ts b/packages/core-ts/src/store.ts index 3cfb444c..63484bf5 100644 --- a/packages/core-ts/src/store.ts +++ b/packages/core-ts/src/store.ts @@ -217,10 +217,10 @@ export class SiteGraphStore { /** * Whether `availability` may be written for this site (S15): the renderer serving it has - * confirmed it enforces gates. Imports ask it too (`importSite`'s `gates`). + * confirmed it enforces gates (`version` 2: derived gates, RFC-0042 §3.6). Imports ask it too. */ - async gatesEnforced(siteId: string): Promise<boolean> { - return (await this.#gates?.enforced(siteId)) === true; + async gatesEnforced(siteId: string, version = 1): Promise<boolean> { + return (await this.#gates?.enforced(siteId, version)) === true; } /** diff --git a/packages/core-ts/src/store/site-listings.ts b/packages/core-ts/src/store/site-listings.ts index c0617941..5e276c1c 100644 --- a/packages/core-ts/src/store/site-listings.ts +++ b/packages/core-ts/src/store/site-listings.ts @@ -6,6 +6,7 @@ * guard on the site CAS keeps "no section uses it" true (rule 4). */ import type { FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { EXPIRY_GATES_VERSION } from "../availability.ts"; import { InvariantError } from "../errors.ts"; import { isBeingRemoved } from "../fields/migration.ts"; import { sameJson } from "../fields/types.ts"; @@ -91,6 +92,33 @@ export async function refuseListFieldUses( }); } +/** The collections whose `expiry` `next` sets or changes (removing one is always allowed). */ +export function changedExpiries(stored: Site | undefined, next: Site): string[] { + return Object.entries(next.collections ?? {}) + .filter(([key, s]) => s?.expiry && !sameJson(s.expiry, stored?.collections?.[key]?.expiry)) + .map(([key]) => key); +} + +/** + * RFC-0042 §3.6: an older site Worker ignores `expiry` and would keep showing expired entries, so a + * write that sets or changes one is refused (`gates_unsupported`) until the site's renderer + * reports derived gates (`gates: 2`). + */ +export async function checkExpiryGates( + ctx: Pick<StoreContext, "graph">, + stored: Site | undefined, + next: Site, +): Promise<void> { + const changed = changedExpiries(stored, next); + if (changed.length === 0) return; + if (await ctx.graph.gatesEnforced(next.id, EXPIRY_GATES_VERSION)) return; + throw new InvariantError( + "gates_unsupported", + "this site's renderer has not confirmed it enforces derived gates (collection expiry); deploy the current site Worker first", + { siteId: next.id, collections: changed }, + ); +} + /** * Checks `next`'s stage 3 settings (when they change) and refuses list-field changes that * sections still depend on; returns the CAS guard, undefined when nothing needs one. @@ -102,6 +130,7 @@ export async function planListings( ): Promise<SiteListingsPlan | undefined> { if (stored && sameJson(stored.collections, next.collections)) return undefined; checkCollectionSettings(next); + await checkExpiryGates(ctx, stored, next); if (!stored) return undefined; const changes = listFieldChanges(stored, next); if (changes.length === 0) return undefined; diff --git a/packages/core-ts/src/sync.ts b/packages/core-ts/src/sync.ts index 2d4d151e..4ec66b15 100644 --- a/packages/core-ts/src/sync.ts +++ b/packages/core-ts/src/sync.ts @@ -12,7 +12,13 @@ import { type Asset, assertValid, type Site } from "@cloudhal/core-contracts"; import type { BlobStore, SqlDriver, SqlStatement } from "@cloudhal/storage"; import { mediaBlobKey } from "./assets.ts"; -import { checkAvailability, checkTimeZone, type GateSupport, isTimeZone } from "./availability.ts"; +import { + checkAvailability, + checkTimeZone, + EXPIRY_GATES_VERSION, + type GateSupport, + isTimeZone, +} from "./availability.ts"; import { InvariantError } from "./errors.ts"; import { canonicalJson, nodeHash, sha256Hex } from "./hash.ts"; import { checkHosts, hostStatements, hostsOf } from "./hosts.ts"; @@ -42,6 +48,18 @@ async function assertGates( "the bundle has time gates (availability) and this site's renderer has not confirmed it enforces them", { siteId }, ); + // RFC-0042 §3.6: a collection's `expiry` needs a renderer with derived gates. + const site = bundle.nodes.find((r) => r.kind === "site"); + const collections = site + ? (JSON.parse(site.body) as { collections?: Record<string, { expiry?: unknown }> }).collections + : undefined; + const expiring = Object.values(collections ?? {}).some((c) => c?.expiry !== undefined); + if (expiring && gates && !(await gates.enforced(siteId, EXPIRY_GATES_VERSION))) + throw new InvariantError( + "gates_unsupported", + "the bundle has a collection expiry and this site's renderer has not confirmed it enforces derived gates", + { siteId }, + ); } /** diff --git a/packages/core-ts/src/testing.ts b/packages/core-ts/src/testing.ts index 8943fa58..a3193bde 100644 --- a/packages/core-ts/src/testing.ts +++ b/packages/core-ts/src/testing.ts @@ -4,6 +4,7 @@ import { ContractValidationError } from "@cloudhal/core-contracts"; import type { BlobStore, SqlDriver } from "@cloudhal/storage"; import { dropAllTables } from "@cloudhal/storage/testing"; import { afterAll, beforeEach, describe, expect, it } from "vitest"; +import { GATES_ENFORCED } from "./availability.ts"; import { ConflictError, UniqueConstraintError } from "./errors.ts"; import { nodeHash } from "./hash.ts"; import { SiteGraphStore } from "./store.ts"; @@ -40,6 +41,8 @@ export function describeSiteGraphStore(label: string, factory: StoreFactory): vo sql, blobs, now: () => new Date(Date.UTC(2026, 9, 7, 20, 0, tick++)), + // Node: the renderer and the writer are one process (RFC-0042's expiry needs it). + gates: GATES_ENFORCED, }); await store.init(); }); @@ -260,6 +263,7 @@ export function describeSiteGraphStore(label: string, factory: StoreFactory): vo sql: racingDriver(sql, hook, when), blobs, now: () => new Date(Date.UTC(2026, 9, 7, 20, 0, tick++)), + gates: GATES_ENFORCED, }), }; taxonomySuite(suite); diff --git a/packages/core-ts/test/entry-gates.test.ts b/packages/core-ts/test/entry-gates.test.ts new file mode 100644 index 00000000..997d6e87 --- /dev/null +++ b/packages/core-ts/test/entry-gates.test.ts @@ -0,0 +1,262 @@ +/** + * RFC-0042 §3–§4 on the read side and at the writers: what a derived gate does to an entry + * (`entryVisibility`), the instants a gate index adds (`derivedGates`, `listFieldInstants`), search + * rows' `gateUntil`, the auto-publish classification (`advancesDerivedGate`, `expiringEntries`) and + * the writer guard (`gates: 2` before `expiry`, store and bundle import). + */ +import type { ContentEntry, FieldDefinition, Site } from "@cloudhal/core-contracts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { describe, expect, it } from "vitest"; +import { GATES_ENFORCED, type GateSupport } from "../src/availability.ts"; +import { expiringEntries } from "../src/expiring-entries.ts"; +import { + derivedGate, + derivedGates, + entryVisibility, + hasExpiryRules, + listFieldInstants, +} from "../src/fields/index.ts"; +import { advancesDerivedGate, SiteGraphStore, searchRowOf, seedStarterSite } from "../src/index.ts"; +import { exportSite, importSite } from "../src/sync.ts"; + +const L = { en: { label: "x" } }; +const FIELDS: [FieldDefinition, ...FieldDefinition[]] = [ + { key: "endsAt", type: "datetime", labels: L, localized: false }, + { key: "day", type: "date", labels: L, localized: false }, + { key: "price", type: "number", labels: L, localized: false }, +]; +const site = (action: "unlist" | "hide" = "unlist", zone = "Europe/Istanbul") => + ({ + timeZone: zone, + collections: { + events: { + fields: FIELDS, + listFields: ["endsAt", "day"], + expiry: { field: "endsAt", action }, + }, + offers: { fields: FIELDS, expiry: { field: "day", action: "hide", afterMinutes: 30 } }, + }, + }) as Pick<Site, "timeZone" | "collections">; + +const entry = (id: string, collection: string, fields: object, extra: object = {}) => + ({ + id, + collection, + fields, + status: "published", + locale: "en", + title: id, + slug: id, + body: { format: "markdown", value: "" }, + createdAt: "2026-11-01T00:00:00Z", + ...extra, + }) as unknown as ContentEntry; + +const T = Date.UTC(2026, 10, 5, 12, 0, 0); + +describe("derived gates (RFC-0042 §3)", () => { + it("unlist and hide past the gate; availability still applies; no value, no gate", () => { + const e = entry("e1", "events", { endsAt: { at: "2026-11-05T12:00:00Z", timeZone: "UTC" } }); + const unlist = derivedGate(site(), e); + expect(unlist).toEqual({ at: T, action: "unlist" }); + expect(entryVisibility(e, unlist, T - 1)).toBe("shown"); + expect(entryVisibility(e, unlist, T)).toBe("unlisted"); + expect(entryVisibility(e, derivedGate(site("hide"), e), T)).toBe("hidden"); + // its own until is earlier: hidden, whatever the rule says + const until = { ...e, availability: { until: "2026-11-05T11:00:00Z" } }; + expect(entryVisibility(until, unlist, T - 1)).toBe("hidden"); + expect(entryVisibility({ ...e, status: "draft" }, undefined, T)).toBe("hidden"); + expect(derivedGate(site(), entry("e2", "events", {}))).toBeUndefined(); + expect(derivedGate(site(), entry("e3", "posts", {}))).toBeUndefined(); + expect(hasExpiryRules({ collections: { posts: {} } })).toBe(false); + }); + + it("a date ends at the next day's start in the site's zone, plus the grace", () => { + const offer = entry("o1", "offers", { day: "2026-11-05" }); + // Istanbul is UTC+3: 6 November 00:00 there is 5 November 21:00 UTC, +30 minutes + expect(derivedGate(site(), offer)?.at).toBe(Date.UTC(2026, 10, 5, 21, 30)); + expect(derivedGate(site("unlist", "UTC"), offer)?.at).toBe(Date.UTC(2026, 10, 6, 0, 30)); + }); + + it("a translation reads its original's non-localized date", () => { + const original = entry("orig", "events", { + endsAt: { at: "2026-11-05T12:00:00Z", timeZone: "UTC" }, + }); + const tr = entry("tr1", "events", {}, { locale: "tr", translation: { group: "orig" } }); + const gates = derivedGates(site(), [original, tr]); + expect(gates.get("tr1")?.at).toBe(T); + expect(derivedGates({ collections: {} }, [original, tr]).size).toBe(0); + }); + + it("list-field instants: a datetime's at (and +1 ms), a date's day starts in the zone", () => { + const e = entry("e1", "events", { + endsAt: { at: "2026-11-05T12:00:00Z", timeZone: "UTC" }, + day: "2026-11-07", + }); + expect(listFieldInstants(site(), [e]).sort((a, b) => a - b)).toEqual([ + T, + T + 1, + Date.UTC(2026, 10, 6, 21), + Date.UTC(2026, 10, 7, 21), + ]); + // `offers` has no list fields: nothing to track + expect(listFieldInstants(site(), [entry("o", "offers", { day: "2026-11-07" })])).toEqual([]); + }); + + it("search rows: gateUntil is the earlier of until and the derived gate", () => { + const e = entry("e1", "events", { endsAt: { at: "2026-11-05T12:00:00Z", timeZone: "UTC" } }); + expect(searchRowOf(e, { site: site() })?.gateUntil).toBe("2026-11-05T12:00:00.000Z"); + const earlier = { ...e, availability: { until: "2026-11-05T10:00:00Z" } }; + expect(searchRowOf(earlier, { site: site() })?.gateUntil).toBe("2026-11-05T10:00:00.000Z"); + expect(searchRowOf(e, { site: { collections: {} } })?.gateUntil).toBeUndefined(); + expect(searchRowOf(e)?.gateUntil).toBeUndefined(); + }); + + it("advancesDerivedGate: added or earlier only", () => { + const s = site(); + const at = (iso: string) => entry("e", "events", { endsAt: { at: iso, timeZone: "UTC" } }); + const side = (e: ContentEntry) => ({ site: s, entry: e }); + const none = entry("e", "events", {}); + expect(advancesDerivedGate(side(none), side(at("2026-11-05T12:00:00Z")))).toBe(true); + expect( + advancesDerivedGate(side(at("2026-11-06T12:00:00Z")), side(at("2026-11-05T12:00:00Z"))), + ).toBe(true); + expect( + advancesDerivedGate(side(at("2026-11-05T12:00:00Z")), side(at("2026-11-06T12:00:00Z"))), + ).toBe(false); + expect(advancesDerivedGate(side(at("2026-11-05T12:00:00Z")), side(none))).toBe(false); + }); +}); + +async function open(gates?: GateSupport) { + const store = new SiteGraphStore({ + sql: await BetterSqlite3Driver.open(":memory:"), + blobs: new MemoryBlobStore(), + ...(gates ? { gates } : {}), + }); + await store.init(); + return store; +} + +const events = { + labels: { en: { singular: "Event", plural: "Events" } }, + fields: FIELDS, + expiry: { field: "endsAt", action: "unlist" as const }, +}; + +describe("the writer guard (RFC-0042 §3.6)", () => { + it("refuses expiry until the renderer reports gates 2; availability needs only 1", async () => { + const asked: (number | undefined)[] = []; + const gatesOne: GateSupport = { + enforced: (_site, version) => { + asked.push(version); + return (version ?? 1) <= 1; + }, + }; + const store = await open(gatesOne); + const seeded = await seedStarterSite(store, { name: "Guard", slug: "guard" }); + await expect( + store.updateSite({ ...seeded, collections: { ...seeded.collections, events } }), + ).rejects.toMatchObject({ code: "gates_unsupported" }); + expect(asked).toContain(2); + // other collection settings save; removing an expiry is always allowed + const { expiry: _removed, ...withoutExpiry } = events; + const plain = await store.updateSite({ + ...seeded, + collections: { ...seeded.collections, events: withoutExpiry }, + }); + expect(plain.collections?.events?.expiry).toBeUndefined(); + const enforced = await open(GATES_ENFORCED); + const s2 = await seedStarterSite(enforced, { name: "Ok", slug: "ok" }); + const saved = await enforced.updateSite({ ...s2, collections: { ...s2.collections, events } }); + expect(saved.collections?.events?.expiry).toEqual(events.expiry); + // an unchanged rule is not asked again: other edits save after the renderer went back to 1 + let version = 2; + const flipping = await open({ enforced: (_s, v) => (v ?? 1) <= version }); + const s3 = await seedStarterSite(flipping, { name: "Same", slug: "same" }); + const withRule = await flipping.updateSite({ + ...s3, + collections: { ...s3.collections, events }, + }); + version = 1; + expect((await flipping.updateSite({ ...withRule, name: "Renamed" })).name).toBe("Renamed"); + await expect( + flipping.updateSite({ + ...(await flipping.getSite(s3.id)), + collections: { + ...s3.collections, + events: { ...events, expiry: { field: "endsAt", action: "hide" } }, + }, + } as Site), + ).rejects.toMatchObject({ code: "gates_unsupported" }); + }); +}); + +describe("bundle imports and expiry (RFC-0042 §3.6)", () => { + it("imports a site with an expiry only where the renderer reports gates 2", async () => { + const sql = await BetterSqlite3Driver.open(":memory:"); + const blobs = new MemoryBlobStore(); + const store = new SiteGraphStore({ sql, blobs, gates: GATES_ENFORCED }); + await store.init(); + const seeded = await seedStarterSite(store, { name: "Bundle", slug: "bundle" }); + await store.updateSite({ ...seeded, collections: { ...seeded.collections, events } }); + const bundle = await exportSite({ sql, blobs }, seeded.id); + const asked: (number | undefined)[] = []; + await expect( + importSite({ sql, blobs }, bundle, { + gates: { + enforced: (_s, v) => { + asked.push(v); + return (v ?? 1) <= 1; + }, + }, + }), + ).rejects.toMatchObject({ code: "gates_unsupported" }); + expect(asked).toEqual([2]); + const result = await importSite({ sql, blobs }, bundle, { gates: GATES_ENFORCED }); + expect(result.nodes.unchanged).toBe(true); + }); +}); + +describe("expiringEntries (RFC-0042 §3.5)", () => { + it("finds live entries whose pending version adds or advances a derived gate", async () => { + const store = await open(GATES_ENFORCED); + const seeded = await seedStarterSite(store, { name: "Expiring", slug: "expiring" }); + const site = await store.updateSite({ + ...seeded, + collections: { ...seeded.collections, events }, + }); + const [hello] = await store.listContentEntries(site.id, "posts"); + const author = { kind: "human" as const, id: "user_01HZX8K3Q9CD", name: "Halil" }; + const make = (slug: string, fields: object) => + store.createContentEntry({ + siteId: site.id, + collection: "events", + slug, + locale: "en", + status: "published", + title: slug, + body: { format: "markdown", value: "x" }, + fields, + tags: [], + ...(hello?.terms ? { terms: hello.terms } : {}), + author, + } as never); + const dated = await make("dated", { endsAt: { at: "2026-12-01T00:00:00Z", timeZone: "UTC" } }); + const undated = await make("undated", {}); + const later = await make("later", { endsAt: { at: "2026-12-01T00:00:00Z", timeZone: "UTC" } }); + const snap = await store.createSnapshot(site.id, { actor: author, reason: "publish" }); + await store.publish((await store.getSite(site.id)) as Site, snap.id); + expect(await expiringEntries(store, site.id, await store.pendingChanges(site.id))).toEqual( + new Set(), + ); + const at = (iso: string) => ({ endsAt: { at: iso, timeZone: "UTC" } }); + await store.updateContentEntry({ ...dated, fields: at("2026-11-20T00:00:00Z") }); + await store.updateContentEntry({ ...undated, fields: at("2026-12-20T00:00:00Z") }); + await store.updateContentEntry({ ...later, fields: at("2027-01-01T00:00:00Z") }); + expect(await expiringEntries(store, site.id, await store.pendingChanges(site.id))).toEqual( + new Set([dated.id, undated.id]), + ); + }); +}); diff --git a/packages/mcp-server/src/release-risk.ts b/packages/mcp-server/src/release-risk.ts new file mode 100644 index 00000000..5fef21aa --- /dev/null +++ b/packages/mcp-server/src/release-risk.ts @@ -0,0 +1,95 @@ +/** + * The items of a release as the `auto`-mode low-risk check sees them (RFC-0008 §10.3): what each + * item does, the assets riding with articles, corrections (RFC-0019 §2.4) and derived gates added + * or moved earlier on live articles (RFC-0042 §3.5, classed like `schedule_unpublish`). + */ +import type { ReleaseItemRisk } from "@cloudhal/agent-runtime"; +import type { ContentEntry } from "@cloudhal/core-contracts"; +import { addedCorrections, advancesDerivedGate, type SiteGraphStore } from "@cloudhal/core-ts"; +import { hasExpiryRules } from "@cloudhal/core-ts/fields"; +import type { StoredReleaseItem } from "@cloudhal/schedules"; +import { nodeVersion } from "./schedules.ts"; + +type Items = readonly Pick<StoredReleaseItem, "item" | "deps">[]; + +/** The items as the low-risk check sees them (§10.3). */ +export function releaseRisk( + items: Items, + corrects: ReadonlySet<string> = new Set(), + expires: ReadonlySet<string> = new Set(), +) { + return items.map( + (p): ReleaseItemRisk => ({ + kind: p.item.kind, + nodeId: p.item.nodeId, + nodeKind: p.item.nodeKind, + live: p.item.base !== undefined, + deps: p.deps, + ...(corrects.has(p.item.nodeId) ? { corrects: true } : {}), + ...(expires.has(p.item.nodeId) ? { expires: true } : {}), + }), + ); +} + +/** A live article item's two versions: the one live now and the one the release publishes. */ +async function versions(graph: SiteGraphStore, item: StoredReleaseItem["item"]) { + if (item.kind !== "set" || item.nodeKind !== "contentEntry" || !item.base) return undefined; + const ref = { id: item.nodeId, kind: item.nodeKind }; + const after = await nodeVersion<ContentEntry>(graph, { ...ref, ...item.target }); + const before = await nodeVersion<ContentEntry>(graph, { ...ref, ...item.base }); + return after && before ? { before, after } : undefined; +} + +/** RFC-0019 §2.4: the release's articles whose target adds a correction to the live version. */ +export async function releaseCorrections( + graph: SiteGraphStore, + items: readonly Pick<StoredReleaseItem, "item">[], +): Promise<Set<string>> { + const out = new Set<string>(); + for (const { item } of items) { + if (item.kind !== "set" || item.nodeKind !== "contentEntry") continue; + const ref = { id: item.nodeId, kind: item.nodeKind }; + const after = await nodeVersion<ContentEntry>(graph, { ...ref, ...item.target }); + if (!after?.corrections?.length) continue; + const before = item.base + ? await nodeVersion<ContentEntry>(graph, { ...ref, ...item.base }) + : undefined; + if (addedCorrections(before, after).length > 0) out.add(item.nodeId); + } + return out; +} + +/** + * RFC-0042 §3.5: the release's live articles whose target adds a derived gate or moves it + * earlier, under the site's current rules (a release that changes the rules changes the site, + * which is never low risk anyway). + */ +export async function releaseExpiries( + graph: SiteGraphStore, + items: readonly Pick<StoredReleaseItem, "item">[], +): Promise<Set<string>> { + const out = new Set<string>(); + for (const { item } of items) { + const v = await versions(graph, item); + if (!v) continue; + const site = await graph.getSite(v.after.siteId); + if (!site || !hasExpiryRules(site)) continue; + const group = v.after.translation?.group; + const original = group && group !== v.after.id ? await graph.getContentEntry(group) : undefined; + const side = (entry: ContentEntry) => ({ site, entry, original }); + if (advancesDerivedGate(side(v.before), side(v.after))) out.add(item.nodeId); + } + return out; +} + +/** Every fact the low-risk check of a release needs, read from the graph. */ +export async function releaseRiskFor( + graph: SiteGraphStore, + items: Items, +): Promise<ReleaseItemRisk[]> { + return releaseRisk( + items, + await releaseCorrections(graph, items), + await releaseExpiries(graph, items), + ); +} diff --git a/packages/mcp-server/src/schedules.ts b/packages/mcp-server/src/schedules.ts index 50987f33..7d1ffc88 100644 --- a/packages/mcp-server/src/schedules.ts +++ b/packages/mcp-server/src/schedules.ts @@ -19,7 +19,6 @@ import { type OwnershipResolver, ownsEntry, type Principal, - type ReleaseItemRisk, type Role, roleScopes, } from "@cloudhal/agent-runtime"; @@ -38,7 +37,7 @@ import type { ScheduleRun, Site, } from "@cloudhal/core-contracts"; -import { addedCorrections, nodeHash, type SiteGraphStore, sha256Hex } from "@cloudhal/core-ts"; +import { nodeHash, type SiteGraphStore, sha256Hex } from "@cloudhal/core-ts"; import { type ReleasePurger, ReleaseService, @@ -58,6 +57,7 @@ import { untrustedEffectiveScopes, } from "@cloudhal/schedules"; import type { ReleaseRecord, SqlScheduleStore } from "@cloudhal/schedules/sql"; +import { releaseRiskFor } from "./release-risk.ts"; import { titleOf } from "./release-titles.ts"; /** What a deployment provides for schedules (RFC-0008 §16). */ @@ -195,41 +195,8 @@ export async function nodeBinding( return releaseHash(site.id, [item]); } -/** The items as the low-risk check sees them (§10.3). */ -export function releaseRisk( - items: readonly Pick<StoredReleaseItem, "item" | "deps">[], - corrects: ReadonlySet<string> = new Set(), -) { - return items.map( - (p): ReleaseItemRisk => ({ - kind: p.item.kind, - nodeId: p.item.nodeId, - nodeKind: p.item.nodeKind, - live: p.item.base !== undefined, - deps: p.deps, - ...(corrects.has(p.item.nodeId) ? { corrects: true } : {}), - }), - ); -} - -/** RFC-0019 §2.4: the release's articles whose target adds a correction to the live version. */ -export async function releaseCorrections( - graph: SiteGraphStore, - items: readonly Pick<StoredReleaseItem, "item">[], -): Promise<Set<string>> { - const out = new Set<string>(); - for (const { item } of items) { - if (item.kind !== "set" || item.nodeKind !== "contentEntry") continue; - const ref = { id: item.nodeId, kind: item.nodeKind }; - const after = await nodeVersion<ContentEntry>(graph, { ...ref, ...item.target }); - if (!after?.corrections?.length) continue; - const before = item.base - ? await nodeVersion<ContentEntry>(graph, { ...ref, ...item.base }) - : undefined; - if (addedCorrections(before, after).length > 0) out.add(item.nodeId); - } - return out; -} +// The low-risk view of a release's items (§10.3) lives in `release-risk.ts`. +export { releaseCorrections, releaseRisk } from "./release-risk.ts"; /** A release removes content or carries an expiry: canceling it keeps content up (§12 `cancel_schedule`). */ export async function removesContent( @@ -359,7 +326,7 @@ export async function autoReleaseVerdict( now: Date, ): Promise<AutoVerdict> { const windowEnds = autoReleaseWindowEnds(now, principal.elevation?.until); - const check = checkAutoRelease(releaseRisk(items, await releaseCorrections(graph, items))); + const check = checkAutoRelease(await releaseRiskFor(graph, items)); const no = (reason: string): AutoVerdict => ({ ok: false, reason, windowEnds, check }); if (effectiveMode(principal) !== "auto") return no("not in auto mode"); if (!principal.keyId) return no("auto-mode releases need an API key"); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index ca25bdd7..178ea775 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -21,6 +21,7 @@ import { describeOwnOnly, effectiveMode, } from "@cloudhal/agent-runtime"; +import { expiringEntries, type SiteGraphStore, type SnapshotDiff } from "@cloudhal/core-ts"; import { afterRunCall, checkRunBudget, @@ -135,6 +136,19 @@ export interface CallResult { result?: unknown; } +/** + * What the store knows about a pending publish for the `auto`-mode check: new images riding with + * articles (RFC-0004 §6.3), corrections (RFC-0019 §2.4) and derived gates added or moved earlier + * on live articles (RFC-0042 §3.5). + */ +async function autoPublishFacts(store: SiteGraphStore, siteId: string, pending: SnapshotDiff) { + return { + ridingAssets: await store.ridingAssets(siteId, pending), + corrections: await store.correctingEntries(siteId, pending), + expiries: await expiringEntries(store, siteId, pending), + }; +} + /** * Policy → audit → handler. Transports call this; it is the only path a tool runs through. * `confirmed` is set by the transport once the human said yes (MRTR response or confirm token); @@ -246,10 +260,7 @@ export async function runTool( ) { const pending = await ctx.store.pendingChanges(siteId); // New raster images used only by the articles in this publish ride along (RFC-0004 §6.3). - autoCheck = checkAutoPublish(pending, { - ridingAssets: await ctx.store.ridingAssets(siteId, pending), - corrections: await ctx.store.correctingEntries(siteId, pending), - }); + autoCheck = checkAutoPublish(pending, await autoPublishFacts(ctx.store, siteId, pending)); if (autoCheck.lowRisk) autoNote = `auto mode: published without approval (${describeAutoCheck(autoCheck)})`; else diff --git a/packages/mcp-server/test/modes.test.ts b/packages/mcp-server/test/modes.test.ts index 0ce1c74a..d4cc9260 100644 --- a/packages/mcp-server/test/modes.test.ts +++ b/packages/mcp-server/test/modes.test.ts @@ -1,6 +1,6 @@ /** Agent modes end to end over a real MCP client (ADR-0015). */ import { type AgentMode, ApprovalStore, AuditLog } from "@cloudhal/agent-runtime"; -import { SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; +import { GATES_ENFORCED, SiteGraphStore, seedStarterSite } from "@cloudhal/core-ts"; import { MemoryBlobStore, migrate, migrations } from "@cloudhal/storage"; import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -10,7 +10,7 @@ import { createCloudhalMcpServer } from "../src/server.ts"; async function setup(mode: AgentMode) { const sql = await BetterSqlite3Driver.open(":memory:"); await migrate(sql, migrations); - const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore() }); + const store = new SiteGraphStore({ sql, blobs: new MemoryBlobStore(), gates: GATES_ENFORCED }); const site = await seedStarterSite(store, { name: "Modes", slug: "modes" }); const audit = new AuditLog(sql); const approvals = new ApprovalStore(sql); @@ -93,6 +93,53 @@ describe("agent modes over MCP", () => { expect(rbApproval?.autoCheck).toBeUndefined(); }); + it("auto: a publish that makes a live article expire sooner waits for a person (RFC-0042 §3.5)", async () => { + const { store, site, approvals, call } = await setup("auto"); + const owner = { kind: "human" as const, id: "user_owner000001" }; + const publishAsPerson = async () => { + const s = await store.createSnapshot(site.id, { actor: owner, reason: "publish" }); + await store.publish( + (await store.getSite(site.id)) as NonNullable<Awaited<ReturnType<typeof store.getSite>>>, + s.id, + ); + }; + const fresh = (await store.getSite(site.id)) as NonNullable< + Awaited<ReturnType<typeof store.getSite>> + >; + await store.updateSite({ + ...fresh, + collections: { + ...fresh.collections, + posts: { + fields: [ + { + key: "endsAt", + type: "datetime", + labels: { en: { label: "Ends" } }, + localized: false, + }, + ], + expiry: { field: "endsAt", action: "hide" }, + }, + }, + }); + const created = (await call("create_entry", article(site.id, "expiring"))) + .structuredContent as { entry: { id: string; rev: number } }; + await publishAsPerson(); + const ends = { at: "2030-01-01T00:00:00Z", timeZone: "UTC" }; + const edited = await call("update_entry", { + siteId: site.id, + entryId: created.entry.id, + rev: created.entry.rev, + fields: { endsAt: ends }, + }); + expect(edited.isError, edited.text).toBeFalsy(); + const publish = await call("publish", { siteId: site.id }); + expect(publish.text).toMatch(/^approval pending:/); + const [waiting] = await approvals.list({ siteId: site.id, status: "pending" }); + expect(waiting?.autoCheck).toMatchObject({ lowRisk: false, code: "expires" }); + }); + it("auto: a publish that adds a correction waits for a person (RFC-0019 §2.4)", async () => { const { store, site, approvals, audit, call } = await setup("auto"); const owner = { kind: "human" as const, id: "user_owner000001" }; diff --git a/scripts/size-baseline.json b/scripts/size-baseline.json index 0bf11779..6323f6b8 100644 --- a/scripts/size-baseline.json +++ b/scripts/size-baseline.json @@ -26,7 +26,7 @@ "packages/core-ts/src/taxonomy.ts": 1718, "packages/mcp-server/src/menu-tools.ts": 1058, "packages/mcp-server/src/schedule-tools.ts": 1596, - "packages/mcp-server/src/schedules.ts": 866, + "packages/mcp-server/src/schedules.ts": 833, "packages/mcp-server/src/taxonomy-tools.ts": 1433, "packages/schedules/src/recurrence.ts": 939, "packages/schedules/src/scheduler.ts": 1343, @@ -520,7 +520,7 @@ "params": 6 }, "packages/mcp-server/src/tools.ts::runTool": { - "lines": 247 + "lines": 244 }, "packages/schedules/src/builtins.ts::createBuiltinRunner": { "lines": 249 diff --git a/starters/blog/src/pages/[...path].astro b/starters/blog/src/pages/[...path].astro index 198ebff9..8c8b759c 100644 --- a/starters/blog/src/pages/[...path].astro +++ b/starters/blog/src/pages/[...path].astro @@ -35,6 +35,7 @@ import { navigationCacheTags, } from "@cloudhal/astro-renderer/navigation"; import { notFoundPage, notFoundPageIds } from "@cloudhal/astro-renderer/not-found"; +import { contentListEntries } from "@cloudhal/astro-renderer/queries"; import { resolveRoute, staticPaths } from "@cloudhal/astro-renderer/routing"; import { getSource, peerEditions } from "@cloudhal/astro-renderer/runtime"; import { searchAvailable } from "@cloudhal/astro-renderer/search"; @@ -44,6 +45,7 @@ import { pageHead, searchHead, siteOrigin, + withStructuredData, } from "@cloudhal/astro-renderer/seo"; import { loadSurfaceSources, surfaceSources } from "@cloudhal/astro-renderer/surfaces"; import { @@ -269,23 +271,9 @@ if (route.kind === "page") { tags.push(listCacheTag(site.id)); // Generated UI (RFC-0020 §3): entry sources load from the same source as the page. surfaces = await loadSurfaceSources(source, route.page.sections); - const collections = [ - ...new Set( - route.page.sections.filter((s) => s.kind === "contentList").map((s) => s.collection), - ), - ]; - entries = Object.fromEntries( - await Promise.all(collections.map(async (c) => [c, await source.entries(c, locale)])), - ); - // A section with a `filter` (RFC-0002 §5) lists its own entries, keyed by section id. - for (const s of route.page.sections) - if (s.kind === "contentList" && s.filter) - entries[s.id] = await source - .entriesByFilter(`collection:${s.collection}+${s.filter}`, { locale }) - .catch((err: unknown) => { - console.warn(`cloudhal: section ${s.id} has an invalid filter: ${String(err)}`); - return []; - }); + // Each collection's newest entries; a section with a `filter` (RFC-0002 §5) or `where`/`orderBy` + // (RFC-0042 §4) lists its own, keyed by section id. + entries = await contentListEntries(source, route.page.sections, locale); } else if (route.kind === "entry") { terms = entryTaxonomy(index, route.entry); related = relatedEntries(route.entry, all, index, RELATED_POSTS); @@ -339,7 +327,7 @@ const gated = route.kind === "page" ? route.page : route.kind === "entry" ? rout const gate = gated ? gateNotice(gated, site, locale, Date.now()) : undefined; const notice = gate && { state: gate.state, at: gate.at, text: t[gate.state](gate.when) }; -const head = +const baseHead = route.kind === "page" ? pageHead(site, route.page, origin, media) : route.kind === "entry" @@ -353,6 +341,11 @@ const head = title: `${isMenu ? t.menu : t.notFoundTitle} · ${site.name}`, canonical: `${origin}${isMenu ? "/menu" : path}`, }; +// RFC-0042 §2: a collection's JSON-LD preset replaces the article's generic BlogPosting. +const head = + route.kind === "entry" + ? await withStructuredData(baseHead, source, route.entry, { origin, media }) + : baseHead; // RFC-0033 §4: self-canonical in the locale, `lang`/`dir`, and the `hreflang` set from the group. const fallback = shownIn !== locale; const alternates =