Several minor improvements to typing and misc.

- Improved schema validation
- Move username parsing and other validators to schema types
- Fix astro check command
- Add JSON/YAML schema validation for data collections
- Update licenses
- Remove deployment script in favor of rsync
- Prevent unsanitized input in export-story script
- Change "eng" language to "en", per BCP47
- Clean up i18n keys and add aria attributes
- Improve MastodonComments behavior on no-JS browsers
This commit is contained in:
Bad Manners 2024-08-07 19:25:50 -03:00
parent fe908a4989
commit 7bb8a952ef
54 changed files with 1005 additions and 962 deletions

View file

@ -6,12 +6,13 @@ import AgeRestrictedModal from "../components/AgeRestrictedModal.astro";
type Props = {
pageTitle?: string;
lang?: string;
};
const { pageTitle } = Astro.props;
const { pageTitle = "Gallery", lang = "en" } = Astro.props;
---
<html lang="en">
<html lang={lang}>
<head>
<meta charset="UTF-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
@ -23,7 +24,7 @@ const { pageTitle } = Astro.props;
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle || "Gallery"} | Bad Manners</title>
<title>{pageTitle} | Bad Manners</title>
<link rel="me" href="https://meow.social/@BadManners" />
<link
rel="alternate"

View file

@ -18,8 +18,8 @@ const { props } = Astro;
const series = props.series && (await getEntry(props.series));
const authorsList = await getEntries(props.authors);
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
// const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries(
(await getCollection("tag-categories")).flatMap((category) =>
category.data.tags.map<[string, string | null]>(({ name }) =>
@ -44,10 +44,10 @@ const thumbnail =
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
---
<BaseLayout pageTitle={props.title}>
<BaseLayout pageTitle={props.title} lang={props.lang}>
<Fragment slot="head">
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
<meta property="og:description" content={props.contentWarning} />
<meta property="og:description" content={t(props.lang, "game/warnings", props.platforms, props.contentWarning)} />
<meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
{
thumbnail ? (
@ -55,7 +55,7 @@ const thumbnail =
<meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
<meta
property="og:image:alt"
content={`Cover art for ${props.title}`}
content={t(props.lang, "published_content/cover_art_alt", props.title)}
data-pagefind-meta="image_alt[content]"
/>
</Fragment>
@ -78,7 +78,9 @@ const thumbnail =
<a
href={series ? series.data.url : "/games"}
class="text-link my-1 h-9 w-9 p-2"
aria-label={`Return to ${series ? series.data.name : "games"}`}
aria-label={series
? t(props.lang, "published_content/return_to_series", series.data.name)
: t(props.lang, "game/return_to_games")}
>
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path
@ -89,7 +91,7 @@ const thumbnail =
<a
href="#description"
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
aria-label="Go to description"
aria-label={t(props.lang, "published_content/go_to_description")}
>
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path
@ -100,7 +102,7 @@ const thumbnail =
<button
data-dark-mode
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
aria-label="Toggle dark mode"
aria-label={t(props.lang, "published_content/toggle_dark_mode")}
>
<svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
<path
@ -120,7 +122,11 @@ const thumbnail =
data-pagefind-body={props.isDraft ? undefined : ""}
data-pagefind-meta="type:game"
>
<h1 id="game-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
<h1
id="game-title"
class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"
aria-label={t(props.lang, "game/title_aria_label")}
>
{props.title}
</h1>
<section
@ -136,7 +142,7 @@ const thumbnail =
{
props.isDraft ? (
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
{t(props.lang, "story/draft_warning")}
{t(props.lang, "published_content/draft_warning")}
</p>
) : null
}
@ -155,7 +161,7 @@ const thumbnail =
<img
loading="eager"
src={thumbnail.src}
alt={`Cover art for ${props.title}`}
alt={t(props.lang, "published_content/cover_art_alt", props.title)}
width={props.thumbnailWidth}
height={props.thumbnailHeight}
class="mx-auto my-5 shadow-lg"
@ -165,7 +171,7 @@ const thumbnail =
) : null
}
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
<article id="game" class="pr-1 font-serif">
<article id="game" class="pr-1 font-serif" aria-label={t(props.lang, "game/article_aria_label")}>
<Prose>
<slot />
</Prose>
@ -177,28 +183,26 @@ const thumbnail =
id="draft-warning-bottom"
class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600"
>
{t(props.lang, "story/draft_warning")}
{t(props.lang, "published_content/draft_warning")}
</p>
) : props.pubDate ? (
<p
id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
aria-label="Publish date"
aria-description={props.pubDate.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
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:${props.pubDate.toISOString().slice(0, 10)}`}
>
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))}
{t(props.lang, "published_content/publish_date", props.pubDate)}
</p>
) : 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, "story/description")}
{t(props.lang, "published_content/description")}
</h2>
<Prose>
<Markdown of={props.description} />
@ -211,14 +215,50 @@ const thumbnail =
><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>{t(props.lang, "story/to_top")}</span></a
><span>{t(props.lang, "published_content/to_top")}</span></a
>
</div>
{
relatedStories.length > 0 ? (
<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>
{relatedStories.map((story) => (
<li>
<a href={`/stories/${story.slug}`}>{story.data.title}</a>
</li>
))}
</ul>
</Prose>
</section>
) : null
}
{
relatedGames.length > 0 ? (
<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>
{relatedGames.map((game) => (
<li>
<a href={`/games/${game.slug}`}>{game.data.title}</a>
</li>
))}
</ul>
</Prose>
</section>
) : null
}
{
tags.length > 0 ? (
<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">
Tags
{t(props.lang, "published_content/tags")}
</h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{tags.map(({ id, name }) => (
@ -232,16 +272,12 @@ const thumbnail =
</section>
) : null
}
<MastodonComments
instance={props.posts.mastodon?.instance}
user={props.posts.mastodon?.user}
postId={props.posts.mastodon?.postId}
/>
{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">
<span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<span>{t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
>{t(props.lang, "story/licenses")}</a
>{t(props.lang, "published_content/licenses")}</a
>
</div>
</div>

View file

@ -17,21 +17,15 @@ import { formatCopyrightedCharacters } from "../utils/format_copyrighted_charact
type Props = CollectionEntry<"stories">["data"];
const { props } = Astro;
let prev = props.prev && (await getEntry(props.prev));
if (prev && prev.data.isDraft) {
prev = undefined;
}
let next = props.next && (await getEntry(props.next));
if (next && next.data.isDraft) {
next = undefined;
}
const prev = props.prev && (await getEntry(props.prev));
const next = props.next && (await getEntry(props.next));
const series = props.series && (await getEntry(props.series));
const authorsList = await getEntries(props.authors);
const commissionersList = props.commissioner && (await getEntries(props.commissioner));
const requestersList = props.requester && (await getEntries(props.requester));
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries(
(await getCollection("tag-categories")).flatMap((category) =>
category.data.tags.map<[string, string | null]>(({ name }) =>
@ -57,7 +51,7 @@ const thumbnail =
const wordCount = props.wordCount?.toString();
---
<BaseLayout pageTitle={props.title}>
<BaseLayout pageTitle={props.title} lang={props.lang}>
<Fragment slot="head">
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
<meta property="og:description" content={t(props.lang, "story/warnings", wordCount, props.contentWarning)} />
@ -68,7 +62,7 @@ const wordCount = props.wordCount?.toString();
<meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
<meta
property="og:image:alt"
content={`Cover art for ${props.shortTitle || props.title}`}
content={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)}
data-pagefind-meta="image_alt[content]"
/>
</Fragment>
@ -92,7 +86,7 @@ const wordCount = props.wordCount?.toString();
href={series ? series.data.url : "/stories/1"}
class="text-link my-1 h-9 w-9 p-2"
aria-label={series
? t(props.lang, "story/return_to_series", series.data.name)
? t(props.lang, "published_content/return_to_series", series.data.name)
: t(props.lang, "story/return_to_stories")}
>
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
@ -104,7 +98,7 @@ const wordCount = props.wordCount?.toString();
<a
href="#description"
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
aria-label={t(props.lang, "story/go_to_description")}
aria-label={t(props.lang, "published_content/go_to_description")}
>
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path
@ -115,7 +109,7 @@ const wordCount = props.wordCount?.toString();
<button
data-dark-mode
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
aria-label={t(props.lang, "story/toggle_dark_mode")}
aria-label={t(props.lang, "published_content/toggle_dark_mode")}
>
<svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
<path
@ -136,7 +130,7 @@ const wordCount = props.wordCount?.toString();
data-pagefind-meta="type:story"
>
{
prev || next ? (
(prev && !prev.data.isDraft) || (next && !next.data.isDraft) ? (
<div class="print:hidden">
<div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2">
{prev ? (
@ -170,7 +164,11 @@ const wordCount = props.wordCount?.toString();
</div>
) : null
}
<h1 id="story-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
<h1
id="story-title"
class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"
aria-label={t(props.lang, "story/title_aria_label")}
>
{props.title}
</h1>
<section
@ -180,13 +178,6 @@ const wordCount = props.wordCount?.toString();
<Authors lang={props.lang}>
{authorsList.map((author) => <UserComponent user={author} lang={props.lang} />)}
</Authors>
{
props.isDraft ? (
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
{t(props.lang, "story/draft_warning")}
</p>
) : null
}
{
requestersList && (
<Requesters lang={props.lang}>
@ -205,6 +196,13 @@ const wordCount = props.wordCount?.toString();
</Commissioners>
)
}
{
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
}
<div id="content-warning">
<p>
{t(props.lang, "story/warnings", wordCount, props.contentWarning)}
@ -218,7 +216,7 @@ const wordCount = props.wordCount?.toString();
<img
loading="eager"
src={thumbnail.src}
alt={`Cover art for ${props.shortTitle || props.title}`}
alt={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)}
width={props.thumbnailWidth}
height={props.thumbnailHeight}
class="mx-auto my-5 shadow-lg"
@ -227,7 +225,7 @@ const wordCount = props.wordCount?.toString();
) : null
}
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
<article id="story" class="pr-1 font-serif">
<article id="story" class="pr-1 font-serif" aria-label={t(props.lang, "story/article_aria_label")}>
<Prose>
<slot />
</Prose>
@ -239,28 +237,26 @@ const wordCount = props.wordCount?.toString();
id="draft-warning-bottom"
class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600"
>
{t(props.lang, "story/draft_warning")}
{t(props.lang, "published_content/draft_warning")}
</p>
) : props.pubDate ? (
<p
id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
aria-label="Publish date"
aria-description={props.pubDate.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
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:${props.pubDate.toISOString().slice(0, 10)}`}
>
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))}
{t(props.lang, "published_content/publish_date", props.pubDate)}
</p>
) : 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, "story/description")}
{t(props.lang, "published_content/description")}
</h2>
<Prose>
<Markdown of={props.description} />
@ -292,7 +288,7 @@ const wordCount = props.wordCount?.toString();
><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>{t(props.lang, "story/to_top")}</span></a
><span>{t(props.lang, "published_content/to_top")}</span></a
>
</div>
{
@ -335,13 +331,31 @@ const wordCount = props.wordCount?.toString();
relatedStories.length > 0 ? (
<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">
Related stories
{t(props.lang, "published_content/related_stories")}
</h2>
<Prose>
<ul>
{relatedStories.map((stories) => (
{relatedStories.map((story) => (
<li>
<a href={`/stories/${stories.slug}`}>{stories.data.title}</a>
<a href={`/stories/${story.slug}`}>{story.data.title}</a>
</li>
))}
</ul>
</Prose>
</section>
) : null
}
{
relatedGames.length > 0 ? (
<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>
{relatedGames.map((game) => (
<li>
<a href={`/games/${game.slug}`}>{game.data.title}</a>
</li>
))}
</ul>
@ -353,7 +367,7 @@ const wordCount = props.wordCount?.toString();
tags.length > 0 ? (
<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, "story/tags")}
{t(props.lang, "published_content/tags")}
</h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{tags.map(({ id, name }) => (
@ -367,16 +381,12 @@ const wordCount = props.wordCount?.toString();
</section>
) : null
}
<MastodonComments
instance={props.posts.mastodon?.instance}
user={props.posts.mastodon?.user}
postId={props.posts.mastodon?.postId}
/>
{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">
<span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<span>{t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
>{t(props.lang, "story/licenses")}</a
>{t(props.lang, "published_content/licenses")}</a
>
</div>
</div>