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:
parent
fe908a4989
commit
7bb8a952ef
54 changed files with 1005 additions and 962 deletions
|
|
@ -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" } },
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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})`,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue