Add static checking to i18n and improve types

This commit is contained in:
Bad Manners 2024-07-24 21:48:46 -03:00
parent f8ac450ab5
commit 579e5879e1
16 changed files with 126 additions and 82 deletions

View file

@ -5,8 +5,8 @@ Static website built in Astro + Typescript + TailwindCSS.
## Requirements
- Node.js 20+
- LFTP, for remote deployment script
- LibreOffice, for story export script
- (optional) LFTP, for the remote deployment script.
- (optional) LibreOffice, for the story export script.
## Development
@ -42,7 +42,7 @@ npm run build
Then, if you're using LFTP:
1. Create a new `.env` file at the root of the project:
1. Create a new `.env` file at the root of the project with your credentials (SSH, SFTP, WebDav, etc.) if you haven't already:
```env
DEPLOY_LFTP_HOST=https://example-webdav-server.com
@ -51,6 +51,8 @@ DEPLOY_LFTP_PASSWORD=sup3r_s3cr3t_password
DEPLOY_LFTP_TARGETFOLDER=sites/gallery.badmanners.xyz/
```
2. Run the following command: `npm run deploy-lftp`
2. Run the deploy command:
Otherwise, to deploy over SSH: `scp -r ./dist/* my-ssh-server:./gallery.badmanners.xyz/`
```bash
npm run deploy-lftp
```

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "gallery-badmanners-xyz",
"version": "1.5.3",
"version": "1.5.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gallery-badmanners-xyz",
"version": "1.5.3",
"version": "1.5.4",
"dependencies": {
"@astrojs/check": "^0.8.2",
"@astrojs/rss": "^4.0.7",

View file

@ -1,13 +1,14 @@
{
"name": "gallery-badmanners-xyz",
"type": "module",
"version": "1.5.3",
"version": "1.5.4",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro check --minimumSeverity warning && astro build",
"build": "npm run check && astro build",
"preview": "astro preview",
"sync": "astro sync",
"check": "astro check --minimumSeverity warning",
"astro": "astro",
"prettier": "prettier --write .",
"deploy-lftp": "dotenv tsx scripts/deploy-lftp.ts --",

View file

@ -140,7 +140,6 @@ const { instance, user, postId } = Astro.props;
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
}
const data: { ancestors: Comment[]; descendants: Comment[] } = await response.json();
// console.log(data);
const commentsList: HTMLElement[] = [];
const commentMap: Record<string, number> = {};

View file

@ -1,7 +1,7 @@
---
import { type CollectionEntry, getEntry } from "astro:content";
import { t } from "../i18n";
import { type Lang } from "../content/config";
import { getUsernameForLang } from "../utils/get_username_for_lang";
type Props = {
lang: Lang;
@ -12,7 +12,7 @@ let { user, lang } = Astro.props;
if (user.data.isAnonymous) {
user = await getEntry("users", "anonymous");
}
const username = t(lang, user.data.nameLang as any) || user.data.name;
const username = getUsernameForLang(user, lang);
let link: string | null = null;
if (user.data.preferredLink) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];

View file

@ -1,7 +1,6 @@
import { defineCollection, reference, z } from "astro:content";
export const adjustDateForUTCOffset = (date: Date) =>
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
// Constants
export const WEBSITE_LIST = [
"website",
@ -15,6 +14,10 @@ export const WEBSITE_LIST = [
"bluesky",
"itaku",
] as const;
export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
export const DEFAULT_LANG = "eng";
// Validators
const ekaPostUrlRegex = /^(?:https:\/\/)(?:www\.)?aryion\.com\/g4\/view\/([1-9]\d*)\/?$/;
const furaffinityPostUrlRegex = /^(?:https:\/\/)(?:www\.)?furaffinity\.net\/view\/([1-9]\d*)\/?$/;
@ -27,9 +30,16 @@ const mastodonPostUrlRegex = /^(?:https:\/\/)((?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@([a
const refineAuthors = (value: { id: any } | any[]) => "id" in value || value.length > 0;
const refineCopyrightedCharacters = (value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1;
const lang = z.enum(["eng", "tok"]).default("eng");
// Transformers
export const adjustDateForUTCOffset = (date: Date) =>
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
// Types
const lang = z.enum(["eng", "tok"]).default(DEFAULT_LANG);
const website = z.enum(WEBSITE_LIST);
const platform = z.enum(["web", "windows", "linux", "macos", "android", "ios"]);
const platform = z.enum(GAME_PLATFORMS);
const mastodonPost = z
.object({
instance: z.string(),
@ -60,8 +70,11 @@ const copyrightedCharacters = z
export type Lang = z.output<typeof lang>;
export type Website = z.infer<typeof website>;
export type GamePlatform = z.infer<typeof platform>;
export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
// Content collections
const storiesCollection = defineCollection({
type: "content",
schema: ({ image }) =>
@ -144,6 +157,8 @@ const gamesCollection = defineCollection({
}),
});
// Data collections
const usersCollection = defineCollection({
type: "data",
schema: ({ image }) =>
@ -186,7 +201,7 @@ const tagCategoriesCollection = defineCollection({
z.object({
name: z.union([
z.string(),
z.record(lang, z.string()).refine((tag) => "eng" in tag, `object-formatted tag must have an "eng" key`),
z.intersection(z.object({ [DEFAULT_LANG]: z.string() }), z.record(lang, z.string())),
]),
description: z.string().optional(),
related: z.array(z.string()).optional(),
@ -196,10 +211,8 @@ const tagCategoriesCollection = defineCollection({
});
export const collections = {
// Content collections
stories: storiesCollection,
games: gamesCollection,
// Data collections
users: usersCollection,
series: seriesCollection,
"tag-categories": tagCategoriesCollection,

View file

@ -1,14 +1,8 @@
import { type Lang } from "../content/config";
import { type GamePlatform, type Lang } from "../content/config";
import { DEFAULT_LANG } from "../content/config";
export { DEFAULT_LANG } from "../content/config";
export const DEFAULT_LANG = "eng" satisfies Lang;
type Translation = string | ((...args: any[]) => string);
export type TranslationRecord = { [DEFAULT_LANG]: Translation } & {
[L in Exclude<Lang, typeof DEFAULT_LANG>]?: Translation;
};
export const UI_STRINGS: Record<string, TranslationRecord> = {
export const UI_STRINGS = {
"util/join_names": {
eng: (names: string[]) =>
names.length <= 1
@ -90,11 +84,10 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
tok: "lipu lawa",
},
"story/authors": {
eng: (authorsList: string[]) =>
`by ${(UI_STRINGS["util/join_names"]!.eng as (arg: string[]) => string)(authorsList)}`,
eng: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].eng(authorsList)}`,
tok: (authorsList: string[]) =>
authorsList.length > 1
? `lipu ni li tan jan ni: ${(UI_STRINGS["util/join_names"]!.tok as (arg: string[]) => string)(authorsList)}`
? `lipu ni li tan jan ni: ${UI_STRINGS["util/join_names"].tok(authorsList)}`
: `lipu ni li tan ${authorsList[0]}`,
},
"story/commissioned_by": {
@ -102,7 +95,7 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
if (typeof commissionersList === "string") {
commissionersList = [commissionersList];
}
return `Commissioned by ${(UI_STRINGS["util/join_names"]!.eng as (arg: string[]) => string)(commissionersList)}`;
return `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`;
},
},
"story/requested_by": {
@ -110,7 +103,7 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
if (typeof requestersList === "string") {
requestersList = [requestersList];
}
return `Requested by ${(UI_STRINGS["util/join_names"]!.eng as (arg: string[]) => string)(requestersList)}`;
return `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`;
},
},
"story/draft_warning": {
@ -120,17 +113,17 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
eng: (owner: string, charactersList: string[]) =>
charactersList.length == 1
? `${charactersList[0]} is © ${owner}`
: `${(UI_STRINGS["util/join_names"]!.eng as (arg: string[]) => string)(charactersList)} are © ${owner}`,
: `${UI_STRINGS["util/join_names"].eng(charactersList)} are © ${owner}`,
},
"characters/all_characters_are_copyrighted_by": {
eng: (owner: string) => `All characters are © ${owner}`,
},
"game/platforms": {
eng: (platforms: string[]) => {
eng: (platforms: GamePlatform[]) => {
const translatedPlatforms = platforms.map(
(platform) => (UI_STRINGS[`game/platform_${platform}`]?.eng as string | undefined) || platform,
(platform) => (UI_STRINGS[`game/platform_${platform}`].eng as string | undefined) || platform,
);
return `A game for ${(UI_STRINGS["util/join_names"]!.eng as (arg: string[]) => string)(translatedPlatforms)}`;
return `A game for ${UI_STRINGS["util/join_names"].eng(translatedPlatforms)}`;
},
},
"game/platform_web": {
@ -152,27 +145,25 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
eng: "iOS",
},
"game/warnings": {
eng: (platforms: string[], contentWarning: string) =>
`${(UI_STRINGS["game/platforms"]!.eng as (arg: string[]) => string)(platforms)}. ${contentWarning}`,
eng: (platforms: GamePlatform[], contentWarning: string) =>
`${UI_STRINGS["game/platforms"].eng(platforms)}. ${contentWarning}`,
},
};
} as const;
export function t(lang: Lang, stringOrSource: string | TranslationRecord, ...args: any[]): string {
if (typeof stringOrSource === "object") {
const translation = stringOrSource[lang] || stringOrSource[DEFAULT_LANG];
if (typeof translation === "function") {
return translation(...args);
}
return translation;
type TranslationKey = keyof typeof UI_STRINGS;
type Translation<A extends any[]> = string | ((...args: A) => string);
type TranslationArgs<T extends Translation<any[]>> = T extends (...args: infer A) => string ? A : [];
type TranslationEntry<T extends Translation<any[]>> = { [DEFAULT_LANG]: T } & {
[L in Exclude<Lang, typeof DEFAULT_LANG>]?: T;
};
type TranslationKeyArgs<K extends TranslationKey> =
(typeof UI_STRINGS)[K] extends TranslationEntry<infer T> ? TranslationArgs<T> : never;
export function t<K extends TranslationKey>(lang: Lang, key: K, ...args: TranslationKeyArgs<K>): string {
if (key in UI_STRINGS) {
const translation: Translation<TranslationKeyArgs<K>> =
(UI_STRINGS[key] as any)[lang] || UI_STRINGS[key][DEFAULT_LANG];
return typeof translation === "function" ? translation(...args) : translation;
}
if (UI_STRINGS[stringOrSource]) {
const translation = UI_STRINGS[stringOrSource][lang] || UI_STRINGS[stringOrSource][DEFAULT_LANG];
if (typeof translation === "function") {
return translation(...args);
}
return translation;
}
// console.warn(`No translation map found for "${stringOrSource}"`);
// return stringOrSource;
throw new Error(`No translation map found for "${stringOrSource}"`);
throw new Error(`No translation map found for "${key}"`);
}

View file

@ -25,7 +25,12 @@ const { pageTitle } = Astro.props;
<meta name="generator" content={Astro.generator} />
<title>{pageTitle || "Gallery"} | Bad Manners</title>
<link rel="me" href="https://meow.social/@BadManners" />
<link rel="alternate" type="application/rss+xml" title="Gallery | Bad Manners" href={`${Astro.site}feed.xml`} />
<link
rel="alternate"
type="application/rss+xml"
title="Gallery | Bad Manners"
href={new URL("/feed.xml", Astro.site)}
/>
<slot name="head" />
</head>
<body>

View file

@ -22,15 +22,19 @@ const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrighte
// 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]>(({ name }) =>
typeof name === "string" ? [name, name] : [t(DEFAULT_LANG, name as any), t(props.lang, name as any)],
category.data.tags.map<[string, string | null]>(({ name }) =>
typeof name === "string" ? [name, name] : [name[DEFAULT_LANG], name[props.lang] ?? null],
),
),
);
const tags = props.tags.map<[string, string]>((tag) => {
const tagSlug = slug(tag);
if (!(tag in categorizedTags)) {
console.log(`Tag "${tag}" doesn't have a category in the "tag-categories" collection!`);
console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
return [tagSlug, tag];
}
if (categorizedTags[tag] == null) {
console.warn(`No "${props.lang}" translation for tag "${tag}"`);
return [tagSlug, tag];
}
return [tagSlug, categorizedTags[tag]!];

View file

@ -34,15 +34,19 @@ const relatedStories = (await getEntries(props.relatedStories)).filter((story) =
// 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]>(({ name }) =>
typeof name === "string" ? [name, name] : [t(DEFAULT_LANG, name as any), t(props.lang, name as any)],
category.data.tags.map<[string, string | null]>(({ name }) =>
typeof name === "string" ? [name, name] : [name[DEFAULT_LANG], name[props.lang] ?? null],
),
),
);
const tags = props.tags.map<[string, string]>((tag) => {
const tagSlug = slug(tag);
if (!(tag in categorizedTags)) {
console.log(`Tag "${tag}" doesn't have a category in the "tag-categories" collection!`);
console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`);
return [tagSlug, tag];
}
if (categorizedTags[tag] == null) {
console.warn(`No "${props.lang}" translation for tag "${tag}"`);
return [tagSlug, tag];
}
return [tagSlug, categorizedTags[tag]!];

View file

@ -4,6 +4,7 @@ import type { Lang, Website } from "../../../content/config";
import { t } from "../../../i18n";
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
import { getUsernameForLang } from "../../../utils/get_username_for_lang";
interface ExportWebsiteInfo {
website: Website;
@ -156,9 +157,9 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
if (user.data.isAnonymous) {
return t(lang, anonymousUser.data.nameLang as any) || anonymousUser.data.name;
return getUsernameForLang(anonymousUser, lang);
}
return t(lang, user.data.nameLang as any) || user.data.name;
return getUsernameForLang(user, lang);
}
type Props = {
@ -179,14 +180,15 @@ export const getStaticPaths: GetStaticPaths = async () => {
}));
};
const ANONYMOUS_USER = await getEntry("users", "anonymous");
export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => {
const { lang } = story.data;
const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters);
const authorsList = await getEntries([story.data.authors].flat());
const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
const requester = story.data.requester && (await getEntry(story.data.requester));
const anonymousUser = await getEntry("users", "anonymous");
const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang);
const anonymousFallback = getNameForUser(ANONYMOUS_USER, ANONYMOUS_USER, lang);
const description = Object.fromEntries(
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
@ -234,10 +236,10 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
`${t(
lang,
"story/authors",
authorsList.map((author) => getNameForUser(author, anonymousUser, lang)),
authorsList.map((author) => getNameForUser(author, ANONYMOUS_USER, lang)),
)}\n` +
(commissioner ? `${t(lang, "story/commissioned_by", getNameForUser(commissioner, anonymousUser, lang))}\n` : "") +
(requester ? `${t(lang, "story/requested_by", getNameForUser(requester, anonymousUser, lang))}\n` : "");
(commissioner ? `${t(lang, "story/commissioned_by", getNameForUser(commissioner, ANONYMOUS_USER, lang))}\n` : "") +
(requester ? `${t(lang, "story/requested_by", getNameForUser(requester, ANONYMOUS_USER, lang))}\n` : "");
const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}`
.replaceAll(/\n\n\n+/g, "\n\n")

View file

@ -6,6 +6,7 @@ import sanitizeHtml from "sanitize-html";
import { t } from "../i18n";
import type { Lang } from "../content/config";
import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
import { getUsernameForLang } from "../utils/get_username_for_lang";
type FeedItem = RSSFeedItem & {
pubDate: Date;
@ -20,7 +21,7 @@ function toNoonUTCDate(date: Date) {
}
const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
const userName = user.data.nameLang[lang] || user.data.name;
const userName = getUsernameForLang(user, lang);
if (user.data.preferredLink) {
const link = user.data.links[user.data.preferredLink]!;
return `<a href="${typeof link === "string" ? link : link[0]}">${userName}</a>`;
@ -40,7 +41,7 @@ export const GET: APIRoute = async ({ site }) => {
return rss({
title: "Gallery | Bad Manners",
description: "Stories, games, and (possibly) more by Bad Manners",
site: site as URL,
site: site!,
items: [
await Promise.all(
stories.map<Promise<FeedItem>>(async ({ data, slug, body }) => ({
@ -48,7 +49,7 @@ export const GET: APIRoute = async ({ site }) => {
pubDate: toNoonUTCDate(data.pubDate!),
link: `/stories/${slug}`,
description:
`${t(data.lang, "story/warnings", data.wordCount, data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}`
`${t(data.lang, "story/warnings", data.wordCount || "???", data.contentWarning.trim())}\n\n${markdownToPlaintext(data.description)}`
.replaceAll(/[\n ]+/g, " ")
.trim(),
categories: ["story"],
@ -72,7 +73,7 @@ export const GET: APIRoute = async ({ site }) => {
(data.commissioner
? `<p>${t(data.lang, "export_story/commissioned_by", getLinkForUser(users.find((user) => user.id === data.commissioner!.id)!, data.lang))}</p>`
: "") +
`<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning.trim())}</em></p>` +
`<hr><p><em>${t(data.lang, "story/warnings", data.wordCount || "???", data.contentWarning.trim())}</em></p>` +
`<hr>${await markdown(body)}` +
`<hr>${await markdown(data.description)}`,
),

View file

@ -28,7 +28,7 @@ const latestItems: LatestItemsEntry[] = [
thumbnail: story.data.thumbnail,
href: `/stories/${story.slug}`,
title: story.data.title,
altText: t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim()),
altText: t(story.data.lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim()),
pubDate: story.data.pubDate!,
})),
games.map<LatestItemsEntry>((game) => ({

View file

@ -28,8 +28,8 @@ const totalPages = Math.ceil(page.total / page.size);
<p class="text-center font-light text-stone-950 dark:text-white">
{
page.start == page.end
? `Displaying story ${page.start + 1}`
: `Displaying stories ${page.start + 1} - ${page.end + 1}`
? `Displaying story #${page.start + 1}`
: `Displaying stories #${page.start + 1}${page.end + 1}`
} / {page.total}
</p>
<div class="mx-auto mb-6 mt-2 flex w-fit rounded-lg border border-stone-400 dark:border-stone-500">
@ -71,7 +71,12 @@ const totalPages = Math.ceil(page.total / page.size);
<a
class="text-link hover:underline focus:underline"
href={`/stories/${story.slug}`}
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
title={t(
story.data.lang,
"story/warnings",
story.data.wordCount || "???",
story.data.contentWarning.trim(),
)}
>
{story.data.thumbnail ? (
<div class="flex aspect-square max-w-[192px] justify-center">

View file

@ -85,7 +85,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
const { tag, description, stories, games, related } = Astro.props;
if (!description) {
console.warn(`Tag "${tag}" has no description!`);
console.log(`Tag "${tag}" has no description`);
}
const count = stories.length + games.length;
let totalWorksWithTag: string = "";
@ -132,7 +132,12 @@ if (count == 1) {
<a
class="text-link hover:underline focus:underline"
href={`/stories/${story.slug}`}
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
title={t(
story.data.lang,
"story/warnings",
story.data.wordCount || "???",
story.data.contentWarning.trim(),
)}
>
{story.data.thumbnail ? (
<div class="flex aspect-square max-w-[192px] justify-center">

View file

@ -0,0 +1,12 @@
import type { CollectionEntry } from "astro:content";
import type { Lang } from "../content/config";
export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
if (user.data.nameLang) {
if (user.data.nameLang[lang]) {
return user.data.nameLang[lang];
}
throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`);
}
return user.data.name;
}