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); }