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

View file

@ -1,12 +1,10 @@
import type { APIRoute } from "astro";
const content = { isAlive: true };
const headers = { "Content-Type": "application/json; charset=utf-8" };
export const GET: APIRoute = () => {
if (import.meta.env.PROD) {
return new Response(null, { status: 404 });
}
return new Response(JSON.stringify(content), { headers });
return new Response(JSON.stringify({ isAlive: true }), {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
};

View file

@ -24,8 +24,7 @@ function toNoonUTCDate(date: Date) {
const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
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>`;
return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
}
return userName;
};

View file

@ -18,17 +18,6 @@ export const getStaticPaths: GetStaticPaths = async () => {
};
const game = Astro.props;
if (!game.data.isDraft) {
if (!game.data.pubDate) {
throw new Error(`Missing "pubDate" for published game ${game.data.title} ("${game.slug}")`);
}
if (!game.data.thumbnail) {
throw new Error(`Missing "thumbnail" for published game ${game.data.title} ("${game.slug}")`);
}
if (game.data.tags.length == 0) {
throw new Error(`Missing "tags" for published game ${game.data.title} ("${game.slug}")`);
}
}
const { Content } = await game.render();
---

View file

@ -1,5 +1,5 @@
---
import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content";
import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
import { Image } from "astro:assets";
import GalleryLayout from "../layouts/GalleryLayout.astro";
import { t } from "../i18n";

View file

@ -1,19 +0,0 @@
import type { APIRoute } from "astro";
const licenses = `
The briefcase logo and any unattributed characters are copyrighted and trademarked by me.
The source code of this website is licensed under the MIT License. The content hosted on it (i.e. stories, games, and respective thumbnails/images) is copyrighted by me and distributed under the CC BY-NC-ND 4.0 license.
The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1.
The generic SVG icons were created by Font Awesome and are distributed under the CC-BY-4.0 license.
All third-party trademarks and attributed characters belong to their respective owners, and I'm not affiliated with any of them.
`.trim();
const headers = { "Content-Type": "text/plain; charset=utf-8" };
export const GET: APIRoute = () => {
return new Response(licenses, { headers });
};

View file

@ -20,21 +20,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
const story = Astro.props;
const readingTime = getReadingTime(story.body);
if (!story.data.isDraft) {
if (!story.data.wordCount) {
throw new Error(`Missing "wordCount" for published story ${story.data.title} ("${story.slug}")`);
}
if (!story.data.pubDate) {
throw new Error(`Missing "pubDate" for published story ${story.data.title} ("${story.slug}")`);
}
if (!story.data.thumbnail) {
throw new Error(`Missing "thumbnail" for published story ${story.data.title} ("${story.slug}")`);
}
if (story.data.tags.length == 0) {
throw new Error(`Missing "tags" for published story ${story.data.title} ("${story.slug}")`);
}
}
if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 135) {
if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 150) {
console.warn(
`"wordCount" differs greatly from actual word count for published story ${story.data.title} ("${story.slug}") ` +
`(expected ~${story.data.wordCount}, found ${readingTime.words})`,

View file

@ -10,65 +10,53 @@ interface Tag {
description?: string;
}
const [stories, games, tagCategories] = await Promise.all([
const [stories, games, tagCategories, seriesCollection] = await Promise.all([
getCollection("stories"),
getCollection("games"),
getCollection("tag-categories"),
getCollection("series"),
]);
const tagsSet = new Set<string>();
const uncategorizedTagsSet = new Set<string>();
const draftOnlyTagsSet = new Set<string>();
const seriesCollection = await getCollection("series");
// Add tags from non-drafts to set; then, add tags only from drafts to separate set
[stories, games]
.flat()
.sort((a, b) => (a.data.isDraft ? 1 : b.data.isDraft ? -1 : 0))
.forEach((value) => {
if (value.data.isDraft) {
value.data.tags.forEach((tag) => {
if (!tagsSet.has(tag)) {
draftOnlyTagsSet.add(tag);
}
});
} else {
value.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
}
});
[stories, games].flat().forEach(({ data: { isDraft, tags } }) => {
if (isDraft) {
tags.forEach((tag) => draftOnlyTagsSet.add(tag));
} else {
tags.forEach((tag) => uncategorizedTagsSet.add(tag));
}
});
// Tags from published content shouldn't be included in drafts-only set
uncategorizedTagsSet.forEach((tag) => draftOnlyTagsSet.delete(tag));
const uncategorizedTagsSet = new Set(tagsSet);
const uniqueSlugs = new Set<string>();
const categorizedTags = tagCategories
.sort((a, b) => {
if (a.data.index == b.data.index) {
throw new Error(
`Found tag categories with same index value ${a.data.index} ("${a.data.name}", "${b.data.name}")`,
);
throw new Error(`Found tag categories with same index value ${a.data.index} ("${a.id}", "${b.id}")`);
}
return a.data.index - b.data.index;
})
.map((category) => {
const tagList = category.data.tags.map<Tag>(({ name, description }) => {
description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ");
const tag = typeof name === "string" ? name : name["eng"];
const id = slug(tag);
return { id, name: tag, description };
const tag = typeof name === "string" ? name : name.en;
return { id: slug(tag), name: tag, description };
});
tagList.forEach(({ name }, index) => {
if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) {
throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`);
tagList.forEach(({ id, name }) => {
if (uniqueSlugs.has(id)) {
throw new Error(`Duplicated tag "${name}" found in multiple tag-categories entries`);
}
uniqueSlugs.add(id);
});
return {
name: category.data.name,
id: category.id,
id: slug(category.data.name),
tags: tagList.filter(({ name }) => {
if (draftOnlyTagsSet.has(name)) {
console.log(`Omitting draft-only tag "${name}"`);
// console.log(`Omitting draft-only tag "${name}"`);
return false;
}
if (tagsSet.has(name)) {
uncategorizedTagsSet.delete(name);
}
uncategorizedTagsSet.delete(name);
return true;
}),
};

View file

@ -1,7 +1,7 @@
---
import type { GetStaticPaths } from "astro";
import { Image } from "astro:assets";
import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content";
import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content";
import { Markdown } from "@astropub/md";
import { slug } from "github-slugger";
import GalleryLayout from "../../layouts/GalleryLayout.astro";
@ -45,24 +45,22 @@ export const getStaticPaths: GetStaticPaths = async () => {
const tagDescriptions = tagCategories.reduce(
(acc, category) => {
category.data.tags.forEach(({ name, description, related }) => {
if (related) {
related = related.filter((relatedTag) => {
if (relatedTag == name) {
console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
return false;
}
if (!tags.has(relatedTag)) {
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
return false;
}
return true;
});
}
const key = typeof name === "string" ? name : name["eng"];
related = related.filter((relatedTag) => {
if (relatedTag == name) {
console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
return false;
}
if (!tags.has(relatedTag)) {
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
return false;
}
return true;
});
const key = typeof name === "string" ? name : name.en;
if (key in acc) {
throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`);
}
acc[key] = { description, related };
acc[key] = { description, related: related.length > 0 ? related : undefined };
});
return acc;
},