gallery.badmanners.xyz/src/utils/qualify_local_urls_in_markdown.ts

112 lines
5 KiB
TypeScript

import { getCollection, getEntry } from "astro:content";
import type { Lang, PostWebsite, UserWebsite } from "../content/config";
import { getWebsiteLinkForUser } from "./get_website_link_for_user";
import { getUsernameForLang } from "./get_username_for_lang";
type MatchGroups =
| {
text: string;
link: string;
}
| {
text: string;
link: string;
contentPrefix: "stories" | "games" | "users";
slug: string;
};
const LOCAL_URL_REGEX =
/\[(?<text>[^\]]*)\]\((?<link>(?:\/(?<contentPrefix>stories|games|users)(?!\/?\)|\/[1-9]\d*\/?\))\/(?<slug>[^\)]+))|(?:\/[^\)]+?))\/?\)/g;
const SERIES_URLS_REGEX_PROMISE = getCollection("series").then(
(series) => new RegExp(`^(${series.map(({ data }) => data.link.replace(/\/$/, "")).join("|")})\/?$`),
);
/**
* Given a Markdown text, finds any local URLs (eg. `[click me](/stories/foo)`) and replaces them with the appropriate
* fully qualified link (eg. `[click me](https://example.com/stories/foo)`).
* @param originalText The Markdown text to modify.
* @param lang The language for translating usernames.
* @param site The base URL of the gallery, to append to the start of local links and permalinks.
* If undefined, this function will only replace `/users/...` links, while still validating the others.
* @param website Which website the Markdown text will be rendered to. If defined, any content links
* (stories, games, etc.) will be replaced by the appropriate link on that website whenever possible.
* If undefined, this function will only replace `/users/...` links (and permalinks if `site` is set),
* while still validating the others.
*/
export async function qualifyLocalURLsInMarkdown(originalText: string, lang: Lang, site?: URL, website?: PostWebsite) {
const replacements: string[] = [];
for (const match of originalText.matchAll(new RegExp(LOCAL_URL_REGEX))) {
const groups = match.groups as MatchGroups | undefined;
if (groups?.text === undefined || groups?.link === undefined) {
throw new Error(`Cannot qualify invalid local URL ${match[0]}`);
}
const { text, link } = groups;
// Any links that match a series `link` are handled separately.
if ((await SERIES_URLS_REGEX_PROMISE).test(link)) {
replacements.push(site ? `[${text}](${new URL(link, site).toString()})` : match[0]);
continue;
}
// Check if this is a special link (story, game, or user)
if ("contentPrefix" in groups && groups.contentPrefix) {
const { contentPrefix, slug } = groups;
switch (contentPrefix) {
case "stories":
const story = await getEntry("stories", slug);
if (!story) {
throw new Error(`Couldn't find story with slug "${slug}"`);
}
if (typeof website === "string" && story.data.posts[website as PostWebsite]?.link) {
replacements.push(`[${text}](${story.data.posts[website as PostWebsite]!.link})`);
continue;
}
break;
case "games":
const game = await getEntry("games", slug);
if (!game) {
throw new Error(`Couldn't find game with slug "${slug}"`);
}
if (typeof website === "string" && game.data.posts[website as PostWebsite]?.link) {
replacements.push(`[${text}](${game.data.posts[website as PostWebsite]!.link})`);
continue;
}
break;
case "users":
const user = await getEntry("users", slug);
if (!user) {
throw new Error(`Couldn't find user with id "${slug}"`);
}
// If there's a label in the link, use that if possible
if (text) {
if (typeof website === "string" && user.data.links[website as UserWebsite]?.link) {
replacements.push(`[${text}](${user.data.links[website as UserWebsite]!.link})`);
continue;
} else if (user.data.preferredLink) {
replacements.push(`[${text}](${user.data.links[user.data.preferredLink]!.link})`);
continue;
}
}
// Otherwise (i.e. label is empty), use the username for the website
replacements.push(
getWebsiteLinkForUser(user, website as UserWebsite, (user) => getUsernameForLang(user, lang)),
);
continue;
default:
const unknown: never = contentPrefix;
console.warn(`WARNING: Unknown local link prefix ${unknown}; ignoring...`);
}
}
replacements.push(website && site ? `[${text}](${new URL(link, site).toString()})` : match[0]);
}
const newText = originalText.replaceAll(new RegExp(LOCAL_URL_REGEX), () => {
const replacement = replacements.shift();
if (!replacement) {
throw new Error(`Replacements array length didn't match length of regex matches! (too few elements)`);
}
return replacement;
});
if (replacements.length > 0) {
throw new Error(`Replacements array length didn't match length of regex matches! (too many elements)`);
}
return newText;
}