This is some HTML text.
-This is some HTML text.
-
+
404
+
+ Page introuvable
+ Page not found
+
+
+ La page que vous cherchez n'existe pas ou a été déplacée.
+ The page you're looking for doesn't exist or has been moved.
+
+
+ home
+ Retour à l'accueil
+ Back to home
+
+
+
+
+
diff --git a/src/pages/[slug].astro b/src/pages/[slug].astro
new file mode 100644
index 0000000..ac64d8d
--- /dev/null
+++ b/src/pages/[slug].astro
@@ -0,0 +1,46 @@
+---
+/**
+ * Catch-all redirect: /{id} → /articles/{id}, /authors/{id}, or /collections/{id}
+ * Handles docs.moddy.app/{anything} → proper canonical URL
+ */
+import { getAllArticles, getAllAuthors, getAllLabels } from '../utils/content.ts';
+
+export async function getStaticPaths() {
+ const [articles, authors, labels] = await Promise.all([
+ getAllArticles(),
+ getAllAuthors(),
+ getAllLabels(),
+ ]);
+
+ const paths: Array<{ params: { slug: string }; props: { destination: string } }> = [];
+
+ for (const article of articles) {
+ paths.push({
+ params: { slug: article.id },
+ props: { destination: `/articles/${article.id}` },
+ });
+ }
+ for (const author of authors) {
+ // Only add if not already taken by an article
+ if (!articles.find(a => a.id === author.id)) {
+ paths.push({
+ params: { slug: author.id },
+ props: { destination: `/authors/${author.id}` },
+ });
+ }
+ }
+ for (const label of labels) {
+ if (!articles.find(a => a.id === label.id) && !authors.find(a => a.id === label.id)) {
+ paths.push({
+ params: { slug: label.id },
+ props: { destination: `/collections/${label.id}` },
+ });
+ }
+ }
+
+ return paths;
+}
+
+const { destination } = Astro.props;
+return Astro.redirect(destination, 301);
+---
diff --git a/src/pages/articles/[id].astro b/src/pages/articles/[id].astro
new file mode 100644
index 0000000..cdc85a2
--- /dev/null
+++ b/src/pages/articles/[id].astro
@@ -0,0 +1,388 @@
+---
+import Base from '../../layouts/Base.astro';
+import Nav from '../../components/Nav.astro';
+import { getAllArticles, getArticle, getArticleContent, getAuthor, getLabel } from '../../utils/content.ts';
+import { fetchDiscordUser, accentColorToHex } from '../../utils/discord.ts';
+import { marked } from 'marked';
+
+export async function getStaticPaths() {
+ const articles = await getAllArticles();
+ return articles.map(a => ({ params: { id: a.id } }));
+}
+
+const { id } = Astro.params;
+const meta = await getArticle(id!);
+if (!meta) return Astro.redirect('/404');
+
+const frMd = await getArticleContent(id!, 'fr');
+const enMd = await getArticleContent(id!, 'en');
+const frHtml = await marked(frMd);
+const enHtml = await marked(enMd);
+
+const authorsList = await Promise.all(
+ meta.authors.map(async (authorId) => {
+ const author = await getAuthor(authorId);
+ if (!author) return null;
+ const needsDiscord = author.avatarUrl === 'DISCORD' || author.avatarDecorationUrl === 'DISCORD' || author.bannerColor === 'DISCORD';
+ let discord = null;
+ if (needsDiscord && author.discordId) discord = await fetchDiscordUser(author.discordId);
+ return {
+ ...author,
+ resolvedAvatarUrl: author.avatarUrl === 'DISCORD' ? (discord?.avatar_url ?? null) : author.avatarUrl,
+ resolvedAvatarDecoration: author.avatarDecorationUrl === 'DISCORD' ? (discord?.avatar_decoration_data?.asset_url ?? null) : author.avatarDecorationUrl,
+ };
+ })
+);
+const authors = authorsList.filter(Boolean) as NonNullable