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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"!**/target",
"!**/.turbo",
"!**/node_modules",
"!apps/studio/src/assets/geo"
"!apps/studio/src/assets/geo",
"!packages/cli/test/fixtures/theme-*"
]
},
"formatter": {
Expand Down
3 changes: 3 additions & 0 deletions packages/astro-renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/test/fixtures/theme-v0.3.10/README.md
Original file line number Diff line number Diff line change
@@ -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<version>/`) 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.
42 changes: 42 additions & 0 deletions packages/cli/test/fixtures/theme-v0.3.10/src/DESIGN.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions packages/cli/test/fixtures/theme-v0.3.10/src/blocks.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
},
];
108 changes: 108 additions & 0 deletions packages/cli/test/fixtures/theme-v0.3.10/src/components/Archive.astro
Original file line number Diff line number Diff line change
@@ -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>
105 changes: 105 additions & 0 deletions packages/cli/test/fixtures/theme-v0.3.10/src/components/Article.astro
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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;
---
<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} />
{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>
Original file line number Diff line number Diff line change
@@ -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>
)}
Loading
Loading