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

@ -1,6 +1,6 @@
import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import type { Website } from "../../../content/config";
import { getCollection, type CollectionEntry, getEntries } from "astro:content";
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";
@ -24,72 +24,8 @@ type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: in
function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
const link = user.data.links[website];
if (link) {
if (typeof link === "string") {
switch (website) {
case "website":
break;
case "eka":
const ekaMatch = link.match(/^.*\baryion\.com\/g4\/user\/([^\/]+)\/?$/);
if (ekaMatch && ekaMatch[1]) {
return ekaMatch[1];
}
break;
case "furaffinity":
const faMatch = link.match(/^.*\bfuraffinity\.net\/user\/([^\/]+)\/?$/);
if (faMatch && faMatch[1]) {
return faMatch[1];
}
break;
case "inkbunny":
const ibMatch = link.match(/^.*\binkbunny\.net\/([^\/]+)\/?$/);
if (ibMatch && ibMatch[1]) {
return ibMatch[1];
}
break;
case "sofurry":
const sfMatch = link.match(/^(?:https?:\/\/)?([^\.]+).sofurry.com\b.*$/);
if (sfMatch && sfMatch[1]) {
return sfMatch[1].replaceAll("-", " ");
}
break;
case "weasyl":
const weasylMatch = link.match(/^.*\bweasyl\.com\/\~([^\/]+)\/?$/);
if (weasylMatch && weasylMatch[1]) {
return weasylMatch[1];
}
break;
case "twitter":
const twitterMatch = link.match(/^.*(?:\btwitter\.com|\bx\.com)\/@?([^\/]+)\/?$/);
if (twitterMatch && twitterMatch[1]) {
return twitterMatch[1];
}
break;
case "mastodon":
const mastodonMatch = link.match(/^(?:https?\:\/\/)?([^\/]+)\/(?:@|users\/)([^\/]+)\/?$/);
if (mastodonMatch && mastodonMatch[1] && mastodonMatch[2]) {
return `${mastodonMatch[2]}@${mastodonMatch[1]}`;
}
break;
case "bluesky":
const bskyMatch = link.match(/^.*\bbsky\.app\/profile\/([^\/]+)\/?$/);
if (bskyMatch && bskyMatch[1]) {
return bskyMatch[1];
}
break;
case "itaku":
const itakuMatch = link.match(/^.*\bitaku\.ee\/profile\/([^\/]+)\/?$/);
if (itakuMatch && itakuMatch[1]) {
return itakuMatch[1];
}
break;
default:
let _: never = website;
throw new Error(`Unhandled website "${website}"`);
}
} else {
return link[1].replace(/^@/, "");
}
if (link?.username) {
return link.username;
}
throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
}
@ -99,20 +35,21 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b
return !preferredLink || preferredLink == website;
}
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string {
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, lang: Lang): string {
const { links, preferredLink } = user.data;
switch (website) {
case "eka":
if ("eka" in user.data.links) {
if ("eka" in links) {
return `:icon${getUsernameForWebsite(user, "eka")}:`;
}
break;
case "furaffinity":
if ("furaffinity" in user.data.links) {
if ("furaffinity" in links) {
return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
}
break;
case "weasyl":
if ("weasyl" in user.data.links) {
if ("weasyl" in links) {
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
} else if (isPreferredWebsite(user, "furaffinity")) {
return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
@ -123,7 +60,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
}
break;
case "inkbunny":
if ("inkbunny" in user.data.links) {
if ("inkbunny" in links) {
return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
} else if (isPreferredWebsite(user, "furaffinity")) {
return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
@ -134,7 +71,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
}
break;
case "sofurry":
if ("sofurry" in user.data.links) {
if ("sofurry" in links) {
return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
} else if (isPreferredWebsite(user, "furaffinity")) {
return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
@ -143,11 +80,12 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
}
break;
default:
throw new Error(`Unhandled ExportWebsite "${website}"`);
const unknown: never = website;
throw new Error(`Unhandled export website "${unknown}"`);
}
if (user.data.preferredLink) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
if (preferredLink) {
const preferred = links[preferredLink]!;
return `[${getUsernameForLang(user, lang)}](${preferred.link})`;
}
throw new Error(
`No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`,
@ -182,14 +120,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
const description = Object.fromEntries(
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
const u = (user: CollectionEntry<"users">) =>
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website);
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang);
const storyDescription = (
[
story.data.description,
`*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`,
t(
lang,
"export_story/writing",
"export_story/authors",
authorsList.map((author) => u(author)),
),
requestersList &&
@ -216,12 +154,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
/\[([^\]]+)\]\((\/[^\)]+)\)/g,
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
);
if (exportFormat === "bbcode") {
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
} else if (exportFormat === "markdown") {
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
} else {
throw new Error(`Unhandled export format "${exportFormat}"`);
switch (exportFormat) {
case "bbcode":
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
case "markdown":
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
default:
const unknown: never = exportFormat;
throw new Error(`Unknown export format "${unknown}"`);
}
}),
);
@ -252,13 +192,12 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
.replaceAll(/\n\n\n+/g, "\n\n")
.trim();
const headers = { "Content-Type": "application/json; charset=utf-8" };
return new Response(
JSON.stringify({
story: storyText,
description,
thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
}),
{ headers },
{ headers: { "Content-Type": "application/json; charset=utf-8" } },
);
};