Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1 @@
export const ARTICLES_FOLDER_PATH = "content/articles";

View File

@@ -0,0 +1,14 @@
import { z } from "zod";
const authorName = z.string().min(1);
const author = z.union([
authorName,
z.object({ name: authorName, link: z.string().url() }),
]);
export const articleDataSchema = z.object({
title: z.string().min(1),
author: z.union([author, z.array(author)]),
date: z.date(),
});

View File

@@ -0,0 +1,56 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import { ZodError, type z } from "zod";
import { ARTICLES_FOLDER_PATH } from "../articles-constants";
import { articleDataSchema } from "../articles-schemas.server";
const RESOLVED_ARTICLES_DIR = path.resolve(ARTICLES_FOLDER_PATH);
export function articleBySlug(slug: string) {
const validFiles = fs.globSync("*.md", { cwd: RESOLVED_ARTICLES_DIR });
if (!validFiles.includes(`${slug}.md`)) return null;
try {
const rawMarkdown = fs.readFileSync(
path.join(RESOLVED_ARTICLES_DIR, `${slug}.md`),
"utf8",
);
const { content, data } = matter(rawMarkdown);
const { date, ...restParsed } = articleDataSchema.parse(data);
return {
content,
date,
authors: normalizeAuthors(restParsed.author),
title: restParsed.title,
};
} catch (e) {
if (!(e instanceof Error)) throw e;
if (e.message.includes("ENOENT") || e instanceof ZodError) {
return null;
}
throw e;
}
}
export function normalizeAuthors(
authors: z.infer<typeof articleDataSchema>["author"],
): Array<{ name: string; link: string | null }> {
if (Array.isArray(authors)) {
return authors.map((author) => {
if (typeof author === "string") {
return { name: author, link: null };
}
return author;
});
}
if (typeof authors === "string") {
return [{ name: authors, link: null }];
}
return [authors];
}

View File

@@ -0,0 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import { ARTICLES_FOLDER_PATH } from "../articles-constants";
import { articleDataSchema } from "../articles-schemas.server";
import { type articleBySlug, normalizeAuthors } from "./bySlug.server";
export async function mostRecentArticles(count: number) {
const files = await fs.promises.readdir(ARTICLES_FOLDER_PATH);
const articles: Array<
Omit<NonNullable<ReturnType<typeof articleBySlug>>, "content"> & {
slug: string;
}
> = [];
for (const file of files) {
const rawMarkdown = fs.readFileSync(
path.join(ARTICLES_FOLDER_PATH, file),
"utf8",
);
const { data } = matter(rawMarkdown);
const { date, ...restParsed } = articleDataSchema.parse(data);
articles.push({
date,
slug: file.replace(".md", ""),
authors: normalizeAuthors(restParsed.author),
title: restParsed.title,
});
}
return articles
.sort((a, b) => b.date.getTime() - a.date.getTime())
.slice(0, count);
}

View File

@@ -0,0 +1,12 @@
import type { LoaderFunctionArgs } from "react-router";
import invariant from "~/utils/invariant";
import { notFoundIfNullish } from "~/utils/remix.server";
import { articleBySlug } from "../core/bySlug.server";
export const loader = ({ params }: LoaderFunctionArgs) => {
invariant(params.slug);
const article = notFoundIfNullish(articleBySlug(params.slug));
return { ...article, slug: params.slug };
};

View File

@@ -0,0 +1,9 @@
import { mostRecentArticles } from "../core/list.server";
const MAX_ARTICLES_COUNT = 100;
export const loader = async () => {
return {
articles: await mostRecentArticles(MAX_ARTICLES_COUNT),
};
};

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import { Markdown } from "~/components/Markdown";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
ARTICLES_MAIN_PAGE,
articlePreviewUrl,
navIconUrl,
} from "~/utils/urls";
import { metaTags, type SerializeFrom } from "../../../utils/remix";
import { loader } from "../loaders/a.$slug.server";
export { loader };
export const handle: SendouRouteHandle = {
breadcrumb: ({ match }) => {
const data = match.loaderData as SerializeFrom<typeof loader> | undefined;
if (!data) return [];
return [
{
imgPath: navIconUrl("articles"),
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
},
];
},
};
export const meta: MetaFunction = (args) => {
invariant(args.params.slug);
const data = args.loaderData as SerializeFrom<typeof loader> | null;
if (!data) return [];
const description = data.content.trim().split("\n")[0];
return metaTags({
title: data.title,
description,
image: {
url: articlePreviewUrl(args.params.slug),
},
location: args.location,
});
};
export default function ArticlePage() {
const data = useLoaderData<typeof loader>();
return (
<Main>
<article className="article">
<h1>{data.title}</h1>
<div className="text-sm text-lighter">
by <Author /> •{" "}
<LocaleTime
date={new Date(data.date)}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
// article dates are calendar dates parsed as UTC midnight;
// formatting in the viewer's zone would shift the day west of UTC
timeZone: "UTC",
}}
/>
</div>
<Markdown>
{contentWithoutLeadingTitle(data.content, data.title)}
</Markdown>
</article>
</Main>
);
}
function normalizeText(text: string) {
return text
.replace(/\*+/g, "")
.replace(/…/g, "...")
.replace(/\\!/g, "!")
.trim();
}
function contentWithoutLeadingTitle(content: string, title: string) {
const trimmed = content.trimStart();
const firstLineEnd = trimmed.indexOf("\n");
const firstLine =
firstLineEnd === -1 ? trimmed : trimmed.slice(0, firstLineEnd);
if (
firstLine.startsWith("# ") &&
normalizeText(firstLine.slice(2)) === normalizeText(title)
) {
return trimmed.slice(firstLine.length).trimStart();
}
return content;
}
function Author() {
const data = useLoaderData<typeof loader>();
return data.authors.map((author, i) => {
if (!author.link) return author.name;
const authorLink = author.link.includes("https://sendou.ink")
? author.link.replace("https://sendou.ink", "")
: author.link;
return (
<React.Fragment key={author.name}>
{author.link.includes("https://sendou.ink") ? (
<Link to={authorLink}>{author.name}</Link>
) : (
<a href={author.link}>{author.name}</a>
)}
{i < data.authors.length - 1 ? " & " : ""}
</React.Fragment>
);
});
}

View File

@@ -0,0 +1,12 @@
.list {
display: flex;
flex-direction: column;
padding: 0;
gap: var(--s-6);
list-style: none;
}
.title {
color: var(--color-text-accent);
font-size: var(--font-md);
}

View File

@@ -0,0 +1,68 @@
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData } from "react-router";
import { LocaleTime } from "~/components/LocaleTime";
import { Main } from "~/components/Main";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { ARTICLES_MAIN_PAGE, articlePage, navIconUrl } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { loader } from "../loaders/a.server";
import styles from "./a.module.css";
export { loader };
export const handle: SendouRouteHandle = {
breadcrumb: () => ({
imgPath: navIconUrl("articles"),
href: ARTICLES_MAIN_PAGE,
type: "IMAGE",
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Articles",
ogTitle: "Splatoon articles",
description:
"Articles about the competitive side of Splatoon. Written by various community members.",
location: args.location,
});
};
export default function ArticlesMainPage() {
const { t, i18n } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
return (
<Main className="stack lg">
<ul className={styles.list}>
{data.articles.map((article) => (
<li key={article.title}>
<Link to={articlePage(article.slug)} className={styles.title}>
{article.title}
</Link>
<div className="text-xs text-lighter">
{t("common:articles.by", {
author: new Intl.ListFormat(i18n.language, {
style: "short",
}).format(article.authors.map((a) => a.name)),
})}{" "}
•{" "}
<LocaleTime
date={new Date(article.date)}
options={{
day: "numeric",
month: "numeric",
year: "numeric",
// article dates are calendar dates parsed as UTC midnight;
// formatting in the viewer's zone would shift the day west of UTC
timeZone: "UTC",
}}
/>
</div>
</li>
))}
</ul>
</Main>
);
}