diff --git a/biome.json b/biome.json index 01da2b2e..0797b2ce 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,8 @@ "!**/target", "!**/.turbo", "!**/node_modules", - "!apps/studio/src/assets/geo" + "!apps/studio/src/assets/geo", + "!packages/cli/test/fixtures/theme-*" ] }, "formatter": { diff --git a/packages/astro-renderer/README.md b/packages/astro-renderer/README.md index 44134913..6233addd 100644 --- a/packages/astro-renderer/README.md +++ b/packages/astro-renderer/README.md @@ -27,6 +27,9 @@ const page = await source.pageByPath(Astro.params.path ? `/${Astro.params.path}` `.astro` files ship as source (no build step); the consumer's Astro compiles them. `astro` is a peer dependency. +## Theme-facing API stability +`cloudhal init` copies `starters/blog/src` into each project, so a site keeps calling this package's exports (and `@cloudhal/core-ts`'s) as they were when it was created. Every export a theme imports is therefore a public API: never change its meaning under the same name. A breaking change keeps a deprecated fallback for the old call. In 0.3.11 `entryPath(entry)` became `entryPath(site, entry)` without one, and every site with an older theme copy rendered empty pages until 0.3.13 restored it. `packages/cli/test/old-theme-compat.test.ts` builds frozen copies of earlier starters (`packages/cli/test/fixtures/theme-v*/`) with today's packages. Add a new frozen copy when a release changes theme-facing APIs. + ## Site analytics (RFC-0006) The renderer carries the site side of Cloudhal's cookie-less analytics (`@cloudhal/analytics`): diff --git a/packages/cli/test/fixtures/theme-v0.3.10/README.md b/packages/cli/test/fixtures/theme-v0.3.10/README.md new file mode 100644 index 00000000..79cd9414 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/README.md @@ -0,0 +1,7 @@ +# Frozen theme: the blog starter as released in v0.3.10 + +`src/` is `starters/blog/src` exactly as tagged `v0.3.10` (`git archive v0.3.10 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: `cloudhal init` copies the starter theme into each site's project, and from then on the site's theme calls `@cloudhal/astro-renderer` and `@cloudhal/core-ts` as they were at that release. In 0.3.11 the renderer's `entryPath(entry)` became `entryPath(site, entry)`; sites with an older copy rendered empty pages (status 200) on 0.3.12, and CI did not notice because it only rendered the current starter, updated in the same PR. 0.3.13 restored the old call as a deprecated overload. + +**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. When a release changes theme-facing APIs, add a new frozen copy beside it (`theme-v/`) and list the version in the test's `FROZEN`. 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.3.10/src/DESIGN.md b/packages/cli/test/fixtures/theme-v0.3.10/src/DESIGN.md new file mode 100644 index 00000000..4492ef45 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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.3.10/src/blocks.ts b/packages/cli/test/fixtures/theme-v0.3.10/src/blocks.ts new file mode 100644 index 00000000..3c29dd24 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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.3.10/src/components/Archive.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/Archive.astro new file mode 100644 index 00000000..5a147890 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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 `` 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 }, +]; +--- +
+ +
+ {kind &&

{kind}

} +

{archive.shown.name}

+ {role &&

{role}

} + {links.length > 0 && ( +
+ )} +

+ {t.posts(archive.total)} + {archive.feedPath && {t.feed}} +

+ {attrs && ( + {attrs.alt} + )} + {image && imageSrc && ( + {image.alt} + )} + {intro &&
} +
+ + {archive.pages > 1 && ( + + )} +
diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/Article.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/Article.astro new file mode 100644 index 00000000..40b15cd8 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/src/components/Article.astro @@ -0,0 +1,105 @@ +--- +/** + * 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). + */ +import { Body } from "@cloudhal/astro-renderer/components"; +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; + blockTypes?: readonly BlockType[]; +} +const { entry, terms, locale, t, media, blocks, blockTypes } = 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; +--- +
+
+ {terms.section && ( + terms.section.href ? ( + {terms.section.name} + ) : ( + {terms.section.name} + ) + )} +

{entry.title}

+ {entry.summary &&

{entry.summary}

} + +
+ + {outline.toc.length >= 3 && ( +
+ {t.onThisPage} +
    + {outline.toc.map((item) => ( +
  1. {item.text}
  2. + ))} +
+
+ )} +
+ +
+ {newest && ( +
+

{t.corrections}

+
    + {corrections.items.map((c) => ( +
  1. +

    + {t.correctionKind[c.kind]} + +

    +

    +

  2. + ))} +
+
+ )} +
diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/Breadcrumbs.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/Breadcrumbs.astro new file mode 100644 index 00000000..1332f210 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/src/components/Breadcrumbs.astro @@ -0,0 +1,25 @@ +--- +/** `Home › … › current`: the visible twin of the `BreadcrumbList` JSON-LD in ``. */ +interface Props { + label: string; + items: Array<{ name: string; href?: string | undefined }>; +} +const { label, items } = Astro.props; +--- +{items.length > 1 && ( + +)} diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/EntryList.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/EntryList.astro new file mode 100644 index 00000000..fde201e1 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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 } : {}), + }); +}; +--- + diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/LatestPosts.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/LatestPosts.astro new file mode 100644 index 00000000..0be2773d --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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"; +--- +
+ +
diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/MenuPanel.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/MenuPanel.astro new file mode 100644 index 00000000..d4dc5aea --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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: "" }; +--- +
+ {search && ( + + )} + {nav.length > 0 && ( + + )} + {utility.length > 0 && ( + + )} +
diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/NavLink.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/NavLink.astro new file mode 100644 index 00000000..ed3355bc --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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 ? ( + {item.label} +) : ( + {item.label} +)} diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/NavMenu.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/NavMenu.astro new file mode 100644 index 00000000..19408e0a --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/src/components/NavMenu.astro @@ -0,0 +1,95 @@ +--- +/** + * An item with children as a disclosure (`
`, 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 ? ( +
+ {item.href && } + +
+ {mega ? ( +
+ {loose.length > 0 && ( +
    + {loose.map((c) =>
  • )} +
+ )} + {columns.map((col) => ( +
+

{col.href ? : col.label}

+
    + {col.children.map((c) =>
  • )} +
+
+ ))} +
+ ) : ( +
    + {item.children.map((child) => ( +
  • + {child.children.length > 0 && level + 1 < MAX_LEVEL ? ( + + ) : ( + + )} +
  • + ))} +
+ )} +
+
+) : ( +
1 ? true : undefined}> + + {item.href ? : {item.label}} + + +
    + {item.children.map((child) => ( +
  • + {child.children.length > 0 && level + 1 < MAX_LEVEL ? ( + + ) : ( + + )} +
  • + ))} +
+
+)} diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/NotFound.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/NotFound.astro new file mode 100644 index 00000000..058c0d48 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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; +--- + diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/PostTerms.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/PostTerms.astro new file mode 100644 index 00000000..1605b048 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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 && ( +
+ {groups.map((group) => ( +
    + {group.terms.map((term) => ( +
  • + {term.href ? ( + + ) : ( + {term.name} + )} +
  • + ))} +
+ ))} +
+)} diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/RelatedPosts.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/RelatedPosts.astro new file mode 100644 index 00000000..73905ce6 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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 && ( + +)} diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/Search.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/Search.astro new file mode 100644 index 00000000..59637bea --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/src/components/Search.astro @@ -0,0 +1,57 @@ +--- +/** + * The search page (RFC-0019 §3, RFC-0029 §2): an ordinary `
` (no JavaScript), + * the results with their snippets (escaped text, matches in ``), 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" }); +--- + diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/SearchDialog.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/SearchDialog.astro new file mode 100644 index 00000000..3c565a40 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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, +}; +--- + +
+ +
+
+ +
+ +

+
+
diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/components/blocks/Testimonial.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/components/blocks/Testimonial.astro new file mode 100644 index 00000000..80d8c601 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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; +--- +
+
+
+ {props.author} + {props.role && {props.role}} +
+
diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/env.d.ts b/packages/cli/test/fixtures/theme-v0.3.10/src/env.d.ts new file mode 100644 index 00000000..61e0b265 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/src/env.d.ts @@ -0,0 +1,5 @@ +/// +declare module "virtual:cloudhal/config" { + const config: import("@cloudhal/astro-renderer").CloudhalRendererConfig; + export default config; +} diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/fonts.ts b/packages/cli/test/fixtures/theme-v0.3.10/src/fonts.ts new file mode 100644 index 00000000..9d982122 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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> = { + 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.3.10/src/icons.ts b/packages/cli/test/fixtures/theme-v0.3.10/src/icons.ts new file mode 100644 index 00000000..dab0bad8 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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: '' }, + * }); + */ +import { defineIconSet } from "@cloudhal/core-ts/icons"; + +export const iconSet = defineIconSet({}); diff --git a/packages/cli/test/fixtures/theme-v0.3.10/src/layouts/Base.astro b/packages/cli/test/fixtures/theme-v0.3.10/src/layouts/Base.astro new file mode 100644 index 00000000..c456aca0 --- /dev/null +++ b/packages/cli/test/fixtures/theme-v0.3.10/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 | 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 = { + 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" }; +--- + + + + {/* is:inline keeps it a classic script where it is: it must run before the body paints. */} + {needsPrefs && } + + + + {/* public/: static assets, answered before the Site Worker runs (no render, no analytics event) */} + + + + {fonts.map((href) => )} + {look.style &&