From 40681177ac7fa78b1ab6944659297133bfdd21f4 Mon Sep 17 00:00:00 2001
From: Madraka <8898640+Madraka@users.noreply.github.com>
Date: Tue, 27 Oct 2026 00:48:39 +0300
Subject: [PATCH] feat(theme): the 0.6.2 blog starter's theme
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The theme copy under src/ is now the blog starter that @cloudhal/cli
0.6.2 ships. It had stayed on an earlier starter while the pins moved:
- Article.astro and strings.ts: typed fields as a "Details" list under
the cover (RFC-0041 §9), with the words in en, tr and ja.
- [...path].astro: content lists through contentListEntries (RFC-0042
field queries), collection feed links, structured data
(withStructuredData) and entry fields.
- [...path]/rss.xml.ts: collection feed paths (RFC-0042 §5).
- tokens.css: body links no longer recolour surface buttons
(.ch-richtext a:not(.ch-button)).
The playground's own files stay as they were: src/playground/,
astro.config.mjs, tsconfig.json, public/_headers, the favicons and the
demo media. The replaced lines were all the earlier starter's; none were
playground edits.
Co-Authored-By: Claude Opus 5.5
Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
---
src/components/Article.astro | 11 +++++--
src/pages/[...path].astro | 57 ++++++++++++++++++++++------------
src/pages/[...path]/rss.xml.ts | 4 ++-
src/strings.ts | 10 ++++++
src/styles/features/tokens.css | 3 +-
5 files changed, 60 insertions(+), 25 deletions(-)
diff --git a/src/components/Article.astro b/src/components/Article.astro
index 40b15cd..3c0f1e6 100644
--- a/src/components/Article.astro
+++ b/src/components/Article.astro
@@ -3,9 +3,11 @@
* 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).
+ * 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 } from "@cloudhal/astro-renderer/components";
+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";
@@ -22,8 +24,10 @@ interface Props {
media?: MediaContext;
blocks?: Record;
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 } = Astro.props;
+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) =>
@@ -73,6 +77,7 @@ const author =
+ {fields && }
{outline.toc.length >= 3 && (
{t.onThisPage}
diff --git a/src/pages/[...path].astro b/src/pages/[...path].astro
index 4fd8c18..83c3b72 100644
--- a/src/pages/[...path].astro
+++ b/src/pages/[...path].astro
@@ -10,6 +10,7 @@ import {
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,
@@ -34,15 +35,18 @@ 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";
import {
archiveHead,
+ collectionFeedLink,
entryHead,
pageHead,
searchHead,
siteOrigin,
+ withStructuredData,
} from "@cloudhal/astro-renderer/seo";
import { loadSurfaceSources, surfaceSources } from "@cloudhal/astro-renderer/surfaces";
import {
@@ -256,6 +260,7 @@ let entries: Record = {};
let surfaces: Awaited> = {};
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.
@@ -267,26 +272,17 @@ 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);
+ // 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
@@ -332,7 +328,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"
@@ -346,6 +342,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 =
@@ -359,7 +360,23 @@ const localized = localizeHead(head, site, {
alternates,
...(gated?.translation ? { translation: gated.translation } : {}),
});
-const meta = fallback || notFound ? { ...localized, robots: "noindex,follow" } : localized;
+// RFC-0042 §5: the feed of the entry's collection, or of each collection a page lists.
+const listed = notFound
+ ? []
+ : route.kind === "entry"
+ ? [route.entry.collection]
+ : route.kind === "page"
+ ? route.page.sections.flatMap((s) => (s.kind === "contentList" ? [s.collection] : []))
+ : [];
+const feeds = [
+ ...localized.feeds,
+ ...[...new Set(listed)].flatMap((c) => collectionFeedLink(site, c, origin, locale) ?? []),
+];
+const meta = {
+ ...localized,
+ feeds,
+ ...(fallback || notFound ? { robots: "noindex,follow" } : {}),
+};
Astro.response.headers.set("content-language", shownIn);
// The pairing's fonts as a Link header too (Early Hints), not only as in .
const pairing = themeVariables(site, themeSettings).attributes["data-ch-type-pairing"];
@@ -448,7 +465,7 @@ const searchForm = searchAvailable(config, source)
{ name: route.entry.title },
]}
/>
-
+
diff --git a/src/pages/[...path]/rss.xml.ts b/src/pages/[...path]/rss.xml.ts
index cd4a49b..17e6908 100644
--- a/src/pages/[...path]/rss.xml.ts
+++ b/src/pages/[...path]/rss.xml.ts
@@ -19,7 +19,9 @@ 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 (`//rss.xml`, RFC-0033).
+ * this host, and each other locale's site feed (`//rss.xml`, RFC-0033). The paths include
+ * each collection's feed (`/rss.xml`, RFC-0042 §5): this route answers 404 for it and the
+ * renderer's middleware answers with the feed.
*/
export async function getStaticPaths() {
const source = await getSource(config);
diff --git a/src/strings.ts b/src/strings.ts
index 1cb24c9..0a9d60b 100644
--- a/src/strings.ts
+++ b/src/strings.ts
@@ -21,6 +21,10 @@ const STRINGS = {
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" },
@@ -102,6 +106,9 @@ const STRINGS = {
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`,
@@ -178,6 +185,9 @@ const STRINGS = {
updated: (when: string) => `${when} 更新`,
minRead: (n: number) => `${n} 分で読めます`,
onThisPage: "このページの内容",
+ details: "詳細",
+ yes: "はい",
+ no: "いいえ",
corrections: "訂正と更新",
correctionKind: { correction: "訂正", clarification: "補足", update: "更新" },
appended: (kind: string) => `${kind}を追記`,
diff --git a/src/styles/features/tokens.css b/src/styles/features/tokens.css
index 7d65a1f..50ba886 100644
--- a/src/styles/features/tokens.css
+++ b/src/styles/features/tokens.css
@@ -107,6 +107,7 @@
.ch-body :is(p, li, blockquote) {
text-wrap: pretty;
}
-.ch-richtext a {
+/* Links in a body, not buttons: a surface Button keeps its own colours (on-brand on brand). */
+.ch-richtext a:not(.ch-button) {
color: var(--ch-color-link);
}