diff --git a/app/routes/a.$slug.tsx b/app/routes/a.$slug.tsx new file mode 100644 index 000000000..1a2488ff8 --- /dev/null +++ b/app/routes/a.$slug.tsx @@ -0,0 +1,61 @@ +import Markdown from "markdown-to-jsx"; +import { Main } from "~/components/Main"; +import { json, type LoaderArgs, type MetaFunction } from "@remix-run/node"; +import { useLoaderData } from "@remix-run/react"; +import * as React from "react"; +import { articleBySlug } from "~/utils/markdown.server"; +import invariant from "tiny-invariant"; +import type { UseDataFunctionReturn } from "@remix-run/react/dist/components"; +import { makeTitle } from "~/utils/strings"; +import { articlePreviewUrl } from "~/utils/urls"; +import { notFoundIfFalsy } from "~/utils/remix"; + +export const meta: MetaFunction = (args) => { + invariant(args.params["slug"]); + const data = args.data as Nullable>; + + if (!data) return {}; + + const description = data.content.trim().split("\n")[0]; + + return { + title: makeTitle(data.title), + description, + "og:description": description, + "twitter:card": "summary_large_image", + "og:image": articlePreviewUrl(args.params["slug"]), + }; +}; + +export const loader = ({ params }: LoaderArgs) => { + invariant(params["slug"]); + + return json(notFoundIfFalsy(articleBySlug(params["slug"]))); +}; + +export default function ArticlePage() { + const data = useLoaderData(); + return ( +
+
+

{data.title}

+
+ by +
+ + {data.content} + +
+
+ ); +} + +function Author() { + const data = useLoaderData(); + + if (data.authorLink) { + return {data.author}; + } + + return <>{data.author}; +} diff --git a/app/styles/common.css b/app/styles/common.css index 4ef495e0e..726298178 100644 --- a/app/styles/common.css +++ b/app/styles/common.css @@ -482,6 +482,10 @@ dialog::backdrop { background-color: var(--theme-transparent); } +.article > p { + padding-block: var(--s-2-5); +} + .alert { display: flex; flex-wrap: wrap; diff --git a/app/utils/markdown.server.ts b/app/utils/markdown.server.ts new file mode 100644 index 000000000..1363a1c1e --- /dev/null +++ b/app/utils/markdown.server.ts @@ -0,0 +1,43 @@ +import matter from "gray-matter"; +import fs from "node:fs"; +import { z, ZodError } from "zod"; + +const articleDataSchema = z.object({ + title: z.string().min(1), + author: z.string().min(1), + date: z.date(), +}); + +export function articleBySlug(slug: string) { + try { + const rawMarkdown = fs.readFileSync(`content/articles/${slug}.md`, "utf8"); + const { content, data } = matter(rawMarkdown); + + const { date, ...restParsed } = articleDataSchema.parse(data); + + return { + content, + dateString: date.toLocaleDateString("en-US", { + day: "2-digit", + month: "long", + year: "numeric", + }), + authorLink: authorToLink(restParsed.author), + ...restParsed, + }; + } catch (e) { + if (!(e instanceof Error)) throw e; + + if (e.message.includes("ENOENT") || e instanceof ZodError) { + return null; + } + + throw e; + } +} + +function authorToLink(author: string) { + if (author === "Riczi") return "https://twitter.com/Riczi2k"; + + return; +} diff --git a/app/utils/urls.ts b/app/utils/urls.ts index 19147628e..d0ca3f252 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -39,6 +39,8 @@ export const badgeUrl = ({ code: Badge["code"]; extension?: "gif"; }) => `/badges/${code}${extension ? `.${extension}` : ""}`; +export const articlePreviewUrl = (slug: string) => + `/img/article-previews/${slug}.png`; export function resolveBaseUrl(url: string) { return new URL(url).host; diff --git a/content/articles/splatfest-world-premiere.md b/content/articles/splatfest-world-premiere.md new file mode 100644 index 000000000..0234ede06 --- /dev/null +++ b/content/articles/splatfest-world-premiere.md @@ -0,0 +1,19 @@ +--- +title: Splatoon 3 Splatfest World Premiere +date: 2022-08-20 +author: Riczi +--- + +The Splatoon franchise is holding a global testfire for the newest entry to their series; Splatoon 3. This will give new players a chance to try out the game for the first time before purchasing. Long-time fans of the game will also get a chance to try out new weapons and maps in anticipation for the title’s release on September 9th, 2022. + +Players will gain access to the Splatlands and training grounds starting on August 25th, 2022. Here, you will be able to customize your character’s appearance, complete a tutorial, and test out the large arsenal weapons in the game. You can get a feel for the game’s unique shooting mechanics, which differ greatly from other popular shooting games. + +After initial access, you can participate in the first Splatfest of Splatoon 3. A Splatfest pits teams against each other using different themes. Players commit to a team, then compete in Splatoon’s trademark game mode Turf War. In this mode, teams of four battle against each other to ink as much ground area as possible within a three-minute window. The team with the most ink at the end of the game wins. Simple as that! + +The [Splatfest themes](https://splatoonwiki.org/wiki/Splatfest#Splatfest_team_names) always cover fun topics. In the past, teams have been divided into Cats vs. Dogs, Pirates vs. Ninjas, and Heroes vs. Villains. Turf War battles come with fun debates about the variety of topics selected as Splatfest themes. + +In Splatoon 3, Splatfests will take on a new form. There will now be three teams to choose from rather than the traditional duo. The Splatfest World Premier, which will be held on August 27th, 2022, will be a classic game of Rock, Paper, Scissors. The Turf War games also come with a new twist, featuring [Tricolor Turf Wars](https://splatoonwiki.org/wiki/Tricolor_Turf_War) for the first time. In the second half of the Splatfest, the leading team will have to stave off a comeback from the other two teams in 4v2v2 matches! + +You can also participate in Splatfest by checking out the Salmon Run. In this game mode, you’re part of a four person squad clearing waves of Salmonids to collect their eggs. This is a fun way to try out new weapons and practice your skills while still supporting your Splatfest team. + +To access the global testfire, download the free software located here. The Splatoon 3 icon will be downloaded to your Switch home screen and will be unlocked on the appropriate date. After downloading, you will also receive a week’s worth of Nintendo Online Services for free so you can participate in the festivities. diff --git a/package-lock.json b/package-lock.json index 4d83d40b8..7a876d1f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,14 @@ "countries-list": "^2.6.1", "date-fns": "^2.29.1", "fuse.js": "^6.6.2", + "gray-matter": "^4.0.3", "i18next": "^21.9.0", "i18next-browser-languagedetector": "^6.1.5", "i18next-fs-backend": "^1.1.5", "i18next-http-backend": "^1.4.1", "just-capitalize": "^3.1.1", "just-shuffle": "^4.1.1", + "markdown-to-jsx": "^7.1.7", "node-cron": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -7035,7 +7037,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -8241,6 +8242,40 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, + "node_modules/gray-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gray-matter/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -8980,7 +9015,6 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -9593,7 +9627,6 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -9968,6 +10001,17 @@ "node": ">=0.10.0" } }, + "node_modules/markdown-to-jsx": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.7.tgz", + "integrity": "sha512-VI3TyyHlGkO8uFle0IOibzpO1c1iJDcXcS/zBrQrXQQvJ2tpdwVzVZ7XdKsyRz1NdRmre4dqQkMZzUHaKIG/1w==", + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, "node_modules/mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -13068,6 +13112,29 @@ "loose-envify": "^1.1.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/section-matter/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -13606,6 +13673,11 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, "node_modules/sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -13783,6 +13855,14 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", @@ -20638,8 +20718,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esquery": { "version": "1.4.0", @@ -21572,6 +21651,36 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "requires": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } + } + }, "gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -22119,8 +22228,7 @@ "is-extendable": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", - "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", - "dev": true + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=" }, "is-extglob": { "version": "2.1.1", @@ -22581,8 +22689,7 @@ "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" }, "kleur": { "version": "4.1.4", @@ -22865,6 +22972,12 @@ "integrity": "sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==", "dev": true }, + "markdown-to-jsx": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-7.1.7.tgz", + "integrity": "sha512-VI3TyyHlGkO8uFle0IOibzpO1c1iJDcXcS/zBrQrXQQvJ2tpdwVzVZ7XdKsyRz1NdRmre4dqQkMZzUHaKIG/1w==", + "requires": {} + }, "mathml-tag-names": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", @@ -25068,6 +25181,25 @@ "loose-envify": "^1.1.0" } }, + "section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "requires": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, "semver": { "version": "7.3.7", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz", @@ -25489,6 +25621,11 @@ "extend-shallow": "^3.0.0" } }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, "sshpk": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.17.0.tgz", @@ -25631,6 +25768,11 @@ "ansi-regex": "^5.0.1" } }, + "strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==" + }, "strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", diff --git a/package.json b/package.json index 9f71f07e2..92b1bea6a 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,14 @@ "countries-list": "^2.6.1", "date-fns": "^2.29.1", "fuse.js": "^6.6.2", + "gray-matter": "^4.0.3", "i18next": "^21.9.0", "i18next-browser-languagedetector": "^6.1.5", "i18next-fs-backend": "^1.1.5", "i18next-http-backend": "^1.4.1", "just-capitalize": "^3.1.1", "just-shuffle": "^4.1.1", + "markdown-to-jsx": "^7.1.7", "node-cron": "3.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/public/img/article-previews/splatfest-world-premiere.png b/public/img/article-previews/splatfest-world-premiere.png new file mode 100644 index 000000000..0d99bba73 Binary files /dev/null and b/public/img/article-previews/splatfest-world-premiere.png differ diff --git a/tsconfig.json b/tsconfig.json index 6029db1d6..089caacaa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,8 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "noPropertyAccessFromIndexSignature": true, - "useUnknownInCatchVariables": true + "useUnknownInCatchVariables": true, + "skipLibCheck": true }, "ts-node": { "transpileOnly": true