--- import type { ImageMetadata } from "astro"; import { getImage } from "astro:assets"; import { type CollectionEntry, getEntry, getCollection } from "astro:content"; import { Markdown } from "@astropub/md"; import { slug } from "github-slugger"; import { DEFAULT_LANG, t, type Lang } from "../i18n"; import BaseLayout from "./BaseLayout.astro"; import CopyrightedCharacters from "../components/CopyrightedCharacters.astro"; import Prose from "../components/Prose.astro"; import MastodonComments from "../components/MastodonComments.astro"; import type { CopyrightedCharacters as CopyrightedCharactersType } from "../content/config"; import { qualifyLocalURLsInMarkdown } from "../utils/qualify_local_urls_in_markdown"; interface RelatedContent { link: string; title: string; } type Props = { /* Content attributes */ title: string; lang: Lang; isDraft: boolean; pubDate?: Date; description: string; summary?: string; tags: string[]; thumbnail?: ImageMetadata; thumbnailWidth?: number; thumbnailHeight?: number; copyrightedCharacters?: CopyrightedCharactersType; series?: CollectionEntry<"series">; prev?: RelatedContent; next?: RelatedContent; relatedStories?: CollectionEntry<"stories">[]; relatedGames?: CollectionEntry<"games">[]; posts: { mastodon?: { link: string; instance: string; user: string; postId: string; }; }; /* Layout attributes */ publishedContentType: "story" | "game"; labelReturnTo: RelatedContent; labelPreviousContent: string; labelNextContent: string; labelTitleSection: string; labelInformationSection: string; labelArticleSection: string; }; const { props } = Astro; const series = props.series && (await getEntry(props.series)); const categorizedTags = Object.fromEntries( (await getCollection("tag-categories")).flatMap((category) => category.data.tags.map<[string, string | null]>(({ name }) => typeof name === "string" ? [name, name] : [name[DEFAULT_LANG], name[props.lang] ?? null], ), ), ); const description = await qualifyLocalURLsInMarkdown(props.description, props.lang); const summary = props.summary && (await qualifyLocalURLsInMarkdown(props.summary, props.lang)); const tags = props.tags.map<{ id: string; name: string }>((tag) => { const tagSlug = slug(tag); if (!(tag in categorizedTags)) { console.warn(`WARNING: Tag "${tag}" doesn't have a category in the "tag-categories" collection`); return { id: tagSlug, name: tag }; } if (categorizedTags[tag] == null) { console.warn(`WARNING: No "${props.lang}" translation for tag "${tag}"`); return { id: tagSlug, name: tag }; } return { id: tagSlug, name: categorizedTags[tag] }; }); const thumbnail = props.thumbnail && (await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight })); --- <BaseLayout pageTitle={props.title} lang={props.lang}> <Fragment slot="head"> <meta property="og:title" content={props.title} data-pagefind-meta="title[content]" /> <slot name="head-description" /> <meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" /> { thumbnail ? ( <Fragment> <meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" /> <meta property="og:image:alt" content={t(props.lang, "published_content/cover_art_alt", props.title)} data-pagefind-meta="image_alt[content]" /> </Fragment> ) : null } </Fragment> <div id="top" class="min-w-screen relative min-h-screen bg-radial from-bm-300 to-bm-600 px-1 pb-16 pt-20 dark:from-green-700 dark:to-green-950 print:bg-none print:pb-0 print:pt-0" > <div id="toolbox-buttons" aria-label="Toolbox" class="pointer-events-none absolute top-0 h-[80vh] py-2 pl-2 lg:inset-y-0 lg:h-full" > <div class="pointer-events-auto sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden" > <a href={series ? series.data.link : props.labelReturnTo.link} class="text-link my-1 p-2" aria-labelledby="label-return-to" > <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} viewBox="0 0 512 512" aria-hidden="true" > <path d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z" ></path> </svg> <span class="sr-only" id="label-return-to" >{ series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title }</span > </a> <a href="#description" class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700" aria-labelledby="label-go-to-description" > <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} viewBox="0 0 512 512" aria-hidden="true" > <path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z" ></path> </svg> <span class="sr-only" id="label-go-to-description" >{t(props.lang, "published_content/go_to_description")}</span > </a> <button data-dark-mode style={{ display: "none" }} class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700" aria-labelledby="label-toggle-dark-mode" > <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} viewBox="0 0 512 512" class="hidden dark:block" aria-hidden="true" > <path d="M361.5 1.2c5 2.1 8.6 6.6 9.6 11.9L391 121l107.9 19.8c5.3 1 9.8 4.6 11.9 9.6s1.5 10.7-1.6 15.2L446.9 256l62.3 90.3c3.1 4.5 3.7 10.2 1.6 15.2s-6.6 8.6-11.9 9.6L391 391 371.1 498.9c-1 5.3-4.6 9.8-9.6 11.9s-10.7 1.5-15.2-1.6L256 446.9l-90.3 62.3c-4.5 3.1-10.2 3.7-15.2 1.6s-8.6-6.6-9.6-11.9L121 391 13.1 371.1c-5.3-1-9.8-4.6-11.9-9.6s-1.5-10.7 1.6-15.2L65.1 256 2.8 165.7c-3.1-4.5-3.7-10.2-1.6-15.2s6.6-8.6 11.9-9.6L121 121 140.9 13.1c1-5.3 4.6-9.8 9.6-11.9s10.7-1.5 15.2 1.6L256 65.1 346.3 2.8c4.5-3.1 10.2-3.7 15.2-1.6zM160 256a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zm224 0a128 128 0 1 0 -256 0 128 128 0 1 0 256 0z" ></path> </svg> <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} viewBox="0 0 512 512" class="block dark:hidden" aria-hidden="true" > <path d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" ></path> </svg> <span class="sr-only" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span> </button> </div> </div> <main class="h-entry mx-auto max-w-3xl rounded-lg bg-stone-50 px-2 pb-4 pt-1 shadow-sm dark:bg-stone-900 print:max-w-full print:bg-none print:shadow-none" data-pagefind-body={props.isDraft ? undefined : ""} data-pagefind-meta={`type:${props.publishedContentType}`} > { props.prev || props.next ? ( <div class="print:hidden"> <div id="nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2"> {props.prev ? ( <a href={props.prev.link} class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 font-light underline dark:border-stone-600" aria-label={props.labelPreviousContent} > <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} class="mr-1" viewBox="0 0 320 512" aria-hidden="true" > <path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" /> </svg> <span>{props.prev.title}</span> </a> ) : ( <div class="h-full border-r border-stone-400 dark:border-stone-600" aria-hidden="true" /> )} {props.next ? ( <a href={props.next.link} class="text-link flex items-center justify-center px-1 py-3 font-light underline" aria-label={props.labelNextContent} > <span>{props.next.title}</span> <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} class="ml-1" viewBox="0 0 320 512" aria-hidden="true" > <path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" /> </svg> </a> ) : ( <div aria-hidden="true" /> )} </div> <hr class="mx-auto mb-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" /> </div> ) : null } <h1 id="section-title" class="p-name px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100" aria-label={props.labelTitleSection} > {props.title} </h1> <section id="section-information" class="p-summary mt-1 space-y-2 px-2 font-serif font-light italic text-stone-600 dark:text-stone-200" aria-label={props.labelInformationSection} > <slot name="section-information" /> { props.isDraft ? ( <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600"> {t(props.lang, "published_content/draft_warning")} </p> ) : null } <slot name="section-content-warning" /> </section> { thumbnail ? ( <Fragment> <hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> <img loading="eager" src={thumbnail.src} alt={t(props.lang, "published_content/cover_art_alt", props.title)} width={props.thumbnailWidth} height={props.thumbnailHeight} class="u-photo mx-auto my-5 shadow-lg" /> </Fragment> ) : null } <hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> <article id="content" class="e-content pr-1 font-serif" aria-label={props.labelArticleSection}> <slot name="section-article" /> </article> <hr class="mx-auto mb-6 mt-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> { props.isDraft ? ( <p id="draft-warning-bottom" class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600" > {t(props.lang, "published_content/draft_warning")} </p> ) : props.pubDate ? ( <time id="publish-date" datetime={props.pubDate.toISOString().slice(0, 10)} class="dt-published mt-2 block px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200" aria-label={t(props.lang, "published_content/publish_date_aria_label")} aria-description={ t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined } data-pagefind-index-attrs="aria-description" data-pagefind-meta="date[datetime]" > {t(props.lang, "published_content/publish_date", props.pubDate)} </time> ) : null } <section id="description" class="px-2 font-serif" aria-describedby="title-description"> <h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> {t(props.lang, "published_content/description")} </h2> <Prose> <Markdown of={description} /> <CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} /> </Prose> </section> { summary ? ( <section id="summary" class="px-2 font-serif" aria-describedby="title-summary"> <h2 id="title-summary" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> {t(props.lang, "published_content/summary")} </h2> <details class="mb-6 mt-1 rounded-lg border border-stone-400 bg-stone-50 text-stone-800 dark:border-stone-500 dark:bg-stone-900 dark:text-stone-100"> <summary class="rounded-lg bg-stone-200 px-2 py-1 dark:bg-stone-800"> {t(props.lang, "published_content/reveal_summary")} </summary> <div class="px-2 py-1"> <Prose> <Markdown of={summary} /> </Prose> </div> </details> </section> ) : null } <div class="pr-3 text-right print:hidden"> <a href="#top" class="text-link inline-flex items-center underline" aria-labelledby="label-to-top" ><svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} class="mr-1" viewBox="0 0 384 512" aria-hidden="true" ><path d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z" ></path></svg ><span id="label-to-top">{t(props.lang, "published_content/to_top")}</span></a > </div> { props.prev || props.next ? ( <Fragment> <hr class="mx-auto mt-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" /> <div id="nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2"> {props.prev ? ( <a href={props.prev.link} class="text-link flex items-center justify-center border-r border-stone-400 px-1 py-3 font-light underline dark:border-stone-600" aria-label={props.labelPreviousContent} > <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} class="mr-1" viewBox="0 0 320 512" aria-hidden="true" > <path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z" /> </svg> <span>{props.prev.title}</span> </a> ) : ( <div class="h-full border-r border-stone-400 dark:border-stone-600" /> )} {props.next ? ( <a href={props.next.link} class="text-link flex items-center justify-center px-1 py-3 font-light underline" aria-label={props.labelNextContent} > <span>{props.next.title}</span> <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} class="ml-1" viewBox="0 0 320 512" aria-hidden="true" > <path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0l192 192z" /> </svg> </a> ) : ( <div /> )} </div> <hr class="mx-auto mb-5 w-full max-w-2xl border-stone-400 dark:border-stone-600" /> </Fragment> ) : null } { props.relatedStories?.length ? ( <section id="related" aria-describedby="title-related" class="my-5"> <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> {t(props.lang, "published_content/related_stories")} </h2> <Prose> <ul> {props.relatedStories.map((story) => ( <li> <a href={`/stories/${story.slug}`}>{story.data.title}</a> </li> ))} </ul> </Prose> </section> ) : null } { props.relatedGames?.length ? ( <section id="related" aria-describedby="title-related" class="my-5"> <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> {t(props.lang, "published_content/related_games")} </h2> <Prose> <ul> {props.relatedGames.map((game) => ( <li> <a href={`/games/${game.slug}`}>{game.data.title}</a> </li> ))} </ul> </Prose> </section> ) : null } { tags.length ? ( <section id="tags" aria-describedby="title-tags" class="my-5"> <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> {t(props.lang, "published_content/tags")} </h2> <ul class="p-category flex flex-wrap gap-x-2 gap-y-3 px-3"> {tags.map(({ id, name }) => ( <li> <a class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm hover:underline focus:underline dark:bg-bm-600 dark:text-white print:bg-none" href={`/tags/${id}`} > {name} </a> </li> ))} </ul> </section> ) : null } {props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null} </main> <div class="pt-6 text-center text-xs text-black dark:text-white" aria-label={t(props.lang, "published_content/copyright_aria_label")} > <span set:html={t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} /><span> |</span> <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank" >{t(props.lang, "published_content/licenses")}</a > </div> </div> </BaseLayout>