From 5456396074d44a7e9f8e52e6bc8644f4b1ee113a Mon Sep 17 00:00:00 2001 From: Madraka <8898640+Madraka@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:51:28 +0300 Subject: [PATCH] theme: use the 0.6 theme API contentListEntries for content lists (field where/orderBy, expiry), entryFields + EntryFields on entries, withStructuredData for entries, and a collection feed link on /blog and its posts only. Rendered HTML is unchanged except that link (static build diff and cloudhal dev). 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 | 65 +++++++++++++++++++++++----------- src/pages/[...path]/rss.xml.ts | 4 ++- src/strings.ts | 10 ++++++ src/theme.ts | 5 +-- 5 files changed, 69 insertions(+), 26 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 4197951..2524282 100644 --- a/src/pages/[...path].astro +++ b/src/pages/[...path].astro @@ -9,7 +9,9 @@ import { } from "@cloudhal/astro-renderer/cache-tags"; import { commentsJsonLd, loadComments } from "@cloudhal/astro-renderer/comments"; import { Comments, Sections } from "@cloudhal/astro-renderer/components"; +import { collectionFeedPath } from "@cloudhal/astro-renderer/collection-feeds"; 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 +36,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 { @@ -272,6 +277,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. @@ -283,26 +289,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 @@ -348,7 +345,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" @@ -362,6 +359,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 = @@ -375,7 +377,30 @@ 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 (cloudhal-marketing) of a collection whose +// listing page this is: a page at the collection's prefix (`/blog` for `/blog/rss.xml`) that lists +// it. Unlike the starter, other pages that list a collection (the home page) link no feed. +const SUFFIX = "/rss.xml"; +const atPrefix = (c: string) => + route.kind === "page" && collectionFeedPath(site, c) === `${route.page.path}${SUFFIX}`; +const listed = notFound + ? [] + : route.kind === "entry" + ? [route.entry.collection] + : route.kind === "page" + ? route.page.sections.flatMap((s) => + s.kind === "contentList" && atPrefix(s.collection) ? [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"]; @@ -464,7 +489,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/theme.ts b/src/theme.ts index 211d4e1..b51a66d 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -51,8 +51,9 @@ export const NAV_CATEGORIES = false; /** * 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: the first published page that lists the - * collection (the blog's home page lists `posts`); without one the item is hidden. + * (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;