mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 23:58:36 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const ARTICLES_FOLDER_PATH = "content/articles";
|
||||
@@ -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(),
|
||||
});
|
||||
56
apps/web-react/app/features/articles/core/bySlug.server.ts
Normal file
56
apps/web-react/app/features/articles/core/bySlug.server.ts
Normal 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];
|
||||
}
|
||||
35
apps/web-react/app/features/articles/core/list.server.ts
Normal file
35
apps/web-react/app/features/articles/core/list.server.ts
Normal 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);
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
9
apps/web-react/app/features/articles/loaders/a.server.ts
Normal file
9
apps/web-react/app/features/articles/loaders/a.server.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
127
apps/web-react/app/features/articles/routes/a.$slug.tsx
Normal file
127
apps/web-react/app/features/articles/routes/a.$slug.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
}
|
||||
12
apps/web-react/app/features/articles/routes/a.module.css
Normal file
12
apps/web-react/app/features/articles/routes/a.module.css
Normal 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);
|
||||
}
|
||||
68
apps/web-react/app/features/articles/routes/a.tsx
Normal file
68
apps/web-react/app/features/articles/routes/a.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user