Better meta tags (#2083)
Some checks failed
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

* Initial

* Work

* lint

* Lint

* Remove twitter

* Progress

* Progress

* Progress

* Progress

* Progress

* Progress

* Fix
This commit is contained in:
Kalle 2025-02-07 16:01:42 +02:00 committed by GitHub
parent 5196e6f845
commit 986355050d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 723 additions and 450 deletions

View File

@ -15,15 +15,18 @@ import { UserSearch } from "~/components/UserSearch";
import { useUser } from "~/features/auth/core/user";
import { FRIEND_CODE_REGEXP_PATTERN } from "~/features/sendouq/q-constants";
import { isAdmin, isMod } from "~/permissions";
import { makeTitle } from "~/utils/strings";
import { SEED_URL, STOP_IMPERSONATING_URL, impersonateUrl } from "~/utils/urls";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/admin.server";
import { loader } from "../loaders/admin.server";
export { action, loader };
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Admin page") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Admin Panel",
location: args.location,
});
};
export default function AdminPage() {

View File

@ -1,4 +1,8 @@
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
import type {
ActionFunction,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import {
unstable_composeUploadHandlers as composeUploadHandlers,
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
@ -38,6 +42,7 @@ import {
navIconUrl,
userArtPage,
} from "~/utils/urls";
import { metaTitle } from "../../../utils/remix";
import { ART, NEW_ART_EXISTING_SEARCH_PARAM_KEY } from "../art-constants";
import { editArtSchema, newArtSchema } from "../art-schemas.server";
import { previewUrl } from "../art-utils";
@ -54,6 +59,12 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = () => {
return metaTitle({
title: "New art",
});
};
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
validate(user.isArtist, "Lacking artist role", 403);

View File

@ -12,10 +12,9 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { SendouSwitch } from "~/components/elements/Switch";
import { CrossIcon } from "~/components/icons/Cross";
import i18next from "~/modules/i18n/i18next.server";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { artPage, navIconUrl } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { ArtGrid } from "../components/ArtGrid";
import { allArtTags } from "../queries/allArtTags.server";
import {
@ -49,12 +48,16 @@ export const meta: MetaFunction = (args) => {
if (!data) return [];
return [{ title: data.title }];
return metaTags({
title: "Art",
ogTitle: "Splatoon art showcase",
description:
"Splatoon art filterable by various tags. Find artist to commission for your own custom art. Includes various styles such as traditional, digital, 3D and SFM.",
location: args.location,
});
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request);
const allTags = allArtTags();
const filteredTagName = new URL(request.url).searchParams.get(
@ -65,7 +68,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
arts: filteredTag ? showcaseArtsByTag(filteredTag.id) : showcaseArts(),
allTags,
title: makeTitle(t("pages.art")),
};
};

View File

@ -10,13 +10,13 @@ import { Main } from "~/components/Main";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { notFoundIfFalsy } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
ARTICLES_MAIN_PAGE,
articlePage,
articlePreviewUrl,
navIconUrl,
} from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { articleBySlug } from "../core/bySlug.server";
export const handle: SendouRouteHandle = {
@ -48,15 +48,14 @@ export const meta: MetaFunction = (args) => {
const description = data.content.trim().split("\n")[0];
return [
{ title: makeTitle(data.title) },
{ property: "og:title", content: data.title },
{ name: "description", content: description },
{ property: "og:description", content: description },
{ property: "og:image", content: articlePreviewUrl(args.params.slug) },
{ property: "og:type", content: "article" },
{ property: "og:site_name", content: "sendou.ink" },
];
return metaTags({
title: data.title,
description,
image: {
url: articlePreviewUrl(args.params.slug),
},
location: args.location,
});
};
export const loader = ({ params }: LoaderFunctionArgs) => {

View File

@ -1,8 +1,10 @@
import type { MetaFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
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 { mostRecentArticles } from "../core/list.server";
import "~/styles/front.css";
@ -18,6 +20,16 @@ export const handle: SendouRouteHandle = {
}),
};
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 const loader = async () => {
return {
articles: await mostRecentArticles(MAX_ARTICLES_COUNT),

View File

@ -1,4 +1,4 @@
import type { SerializeFrom } from "@remix-run/node";
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import { NavLink, Outlet, useLoaderData } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@ -10,6 +10,7 @@ import { SearchIcon } from "~/components/icons/Search";
import { useUser } from "~/features/auth/core/user";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { BADGES_DOC_LINK, BADGES_PAGE, navIconUrl } from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import * as BadgeRepository from "../BadgeRepository.server";
import "~/styles/badges.css";
@ -23,6 +24,16 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Badges",
ogTitle: "Splatoon badges (tournament prizes list)",
location: args.location,
description:
"Over 400 badge tournament prizes and counting! Check out the full list including the owners.",
});
};
export type BadgesLoaderData = SerializeFrom<typeof loader>;
export const loader = async () => {

View File

@ -16,7 +16,6 @@ import { BeakerIcon } from "~/components/icons/Beaker";
import { MAX_AP } from "~/constants";
import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSetTitle } from "~/hooks/useSetTitle";
import type { Ability as AbilityType } from "~/modules/in-game-lists";
import {
ANGLE_SHOOTER_ID,
@ -37,7 +36,6 @@ import { atOrError, nullFilledArray, removeDuplicates } from "~/utils/arrays";
import { damageTypeTranslationString } from "~/utils/i18next";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
ANALYZER_URL,
mainWeaponImageUrl,
@ -49,6 +47,7 @@ import {
} from "~/utils/urls";
import { SendouButton } from "../../../components/elements/Button";
import { SendouPopover } from "../../../components/elements/Popover";
import { metaTags } from "../../../utils/remix";
import {
MAX_LDE_INTENSITY,
damageTypeToWeaponType,
@ -84,14 +83,14 @@ import { SendouSwitch } from "~/components/elements/Switch";
export const CURRENT_PATCH = "9.2";
export const meta: MetaFunction = () => {
return [
{ title: makeTitle("Build Analyzer") },
{
name: "description",
content: "Detailed stats for any weapon and build in Splatoon 3.",
},
];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Build analyzer",
ogTitle: "Splatoon 3 build analyzer/simulator",
location: args.location,
description:
"Analyze and compare Splatoon 3 builds. Find out what exactly each combination of abilities does.",
});
};
export const handle: SendouRouteHandle = {
@ -118,8 +117,7 @@ export default function BuildAnalyzerShell() {
function BuildAnalyzerPage() {
const user = useUser();
const { t } = useTranslation(["analyzer", "common", "weapons"]);
useSetTitle(t("common:pages.analyzer"));
const { t } = useTranslation(["analyzer", "weapons"]);
const {
build,
build2,

View File

@ -1,9 +1,5 @@
import { cachified } from "@epic-web/cachified";
import type {
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@ -16,7 +12,6 @@ import {
type SendouRouteHandle,
notFoundIfNullLike,
} from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import {
BUILDS_PAGE,
@ -27,13 +22,8 @@ import {
import { popularBuilds } from "../build-stats-utils";
import { abilitiesByWeaponId } from "../queries/abilitiesByWeaponId.server";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [{ title: data.meta.title }];
};
import { meta } from "../../builds/routes/builds.$slug";
export { meta };
export const handle: SendouRouteHandle = {
i18n: ["analyzer", "builds"],
@ -63,12 +53,10 @@ export const handle: SendouRouteHandle = {
};
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request, ["builds", "weapons", "common"]);
const t = await i18next.getFixedT(request, ["builds"]);
const slug = params.slug;
const weaponId = notFoundIfNullLike(weaponNameSlugToId(slug));
const weaponName = t(`weapons:MAIN_${weaponId}`);
const cachedPopularBuilds = await cachified({
key: `popular-builds-${weaponId}`,
cache,
@ -83,11 +71,6 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
meta: {
weaponId,
slug: slug!,
title: makeTitle([
t("builds:linkButton.popularBuilds"),
weaponName,
t("common:pages.builds"),
]),
breadcrumbText: t("builds:linkButton.popularBuilds"),
},
};

View File

@ -1,9 +1,5 @@
import { cachified } from "@epic-web/cachified";
import type {
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Ability } from "~/components/Ability";
@ -16,7 +12,6 @@ import {
type SendouRouteHandle,
notFoundIfNullLike,
} from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import {
BUILDS_PAGE,
@ -29,13 +24,8 @@ import { averageAbilityPoints } from "../queries/averageAbilityPoints.server";
import "../build-stats.css";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [{ title: data.meta.title }];
};
import { meta } from "../../builds/routes/builds.$slug";
export { meta };
export const handle: SendouRouteHandle = {
i18n: ["weapons", "builds", "analyzer"],
@ -65,11 +55,9 @@ export const handle: SendouRouteHandle = {
};
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request, ["builds", "weapons", "common"]);
const t = await i18next.getFixedT(request, ["builds"]);
const weaponId = notFoundIfNullLike(weaponNameSlugToId(params.slug));
const weaponName = t(`weapons:MAIN_${weaponId}`);
const cachedStats = await cachified({
key: `build-stats-${weaponId}`,
cache,
@ -87,11 +75,6 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
weaponId,
meta: {
slug: params.slug!,
title: makeTitle([
t("builds:linkButton.abilityStats"),
weaponName,
t("common:pages.builds"),
]),
breadcrumbText: t("builds:linkButton.abilityStats"),
},
};

View File

@ -2,7 +2,6 @@ import type { LoaderFunctionArgs } from "@remix-run/node";
import { BUILDS_PAGE_BATCH_SIZE, BUILDS_PAGE_MAX_BUILDS } from "~/constants";
import { i18next } from "~/modules/i18n/i18next.server";
import { weaponIdIsNotAlt } from "~/modules/in-game-lists";
import { makeTitle } from "~/utils/strings";
import { weaponNameSlugToId } from "~/utils/unslugify.server";
import { mySlugify } from "~/utils/urls";
import { FILTER_SEARCH_PARAM_KEY } from "../builds-constants";
@ -54,7 +53,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
return {
weaponId,
weaponName,
title: makeTitle([weaponName, t("common:pages.builds")]),
builds: filteredBuilds,
limit,
slug,

View File

@ -24,7 +24,7 @@ import {
PATCHES,
} from "~/constants";
import { safeJSONParse } from "~/utils/json";
import { isRevalidation } from "~/utils/remix";
import { isRevalidation, metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import {
@ -125,12 +125,15 @@ export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
return args.defaultShouldRevalidate;
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!data) return [];
return [{ title: data.title }];
return metaTags({
title: `${args.data.weaponName} builds`,
ogTitle: `${args.data.weaponName} Splatoon 3 builds`,
description: `Collection of ${args.data.weaponName} builds from the top competitive players. Find the best combination of abilities and level up your gameplay.`,
location: args.location,
});
};
export const handle: SendouRouteHandle = {

View File

@ -3,11 +3,9 @@ import { Link } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { useSetTitle } from "~/hooks/useSetTitle";
import type { MainWeaponId } from "~/modules/in-game-lists";
import { weaponCategories, weaponIdIsNotAlt } from "~/modules/in-game-lists";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
BUILDS_PAGE,
mainWeaponImageUrl,
@ -16,17 +14,18 @@ import {
weaponBuildPage,
weaponCategoryUrl,
} from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import "~/styles/builds.css";
export const meta: MetaFunction = () => {
return [
{ title: makeTitle("Builds") },
{
name: "description",
content: "View Splatoon 3 builds for all weapons by the best players",
},
];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Builds",
ogTitle: "Splatoon 3 builds for all weapons",
description:
"View Splatoon 3 builds for all weapons by the best players. Includes collection of user submitted builds and an aggregation of ability stats.",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
@ -40,7 +39,6 @@ export const handle: SendouRouteHandle = {
export default function BuildsPage() {
const { t } = useTranslation(["common", "weapons"]);
useSetTitle(t("common:pages.builds"));
const weaponIdToSlug = (weaponId: MainWeaponId) => {
return mySlugify(t(`weapons:MAIN_${weaponId}`, { lng: "en" }));

View File

@ -5,15 +5,12 @@ import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { canEditCalendarEvent } from "~/permissions";
import { validate } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { tournamentBracketsPage } from "~/utils/urls";
import { canAddNewEvent } from "../calendar-utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request);
const user = await requireUser(request);
const url = new URL(request.url);
@ -87,7 +84,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
user.isTournamentOrganizer && !eventToEdit
? await CalendarRepository.findRecentTournamentsByAuthorId(user.id)
: undefined,
title: makeTitle([canEditEvent ? "Edit" : "New", t("pages.calendar")]),
organizations: await TournamentOrganizationRepository.findByOrganizerUserId(
user.id,
),

View File

@ -30,7 +30,6 @@ import {
tournamentManagerData,
} from "~/features/tournament-bracket/core/Tournament.server";
import { useIsMounted } from "~/hooks/useIsMounted";
import { i18next } from "~/modules/i18n/i18next.server";
import {
canDeleteCalendarEvent,
canEditCalendarEvent,
@ -42,7 +41,6 @@ import {
notFoundIfFalsy,
validate,
} from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
CALENDAR_PAGE,
calendarEditPage,
@ -55,6 +53,7 @@ import {
userPage,
} from "~/utils/urls";
import { actualNumber, id } from "~/utils/zod";
import { metaTags } from "../../../utils/remix";
import { Tags } from "../components/Tags";
import "~/styles/calendar-event.css";
@ -103,10 +102,13 @@ export const meta: MetaFunction = (args) => {
if (!data) return [];
return [
{ title: data.title },
{ name: "description", content: data.event.description },
];
return metaTags({
title: data.event.name,
location: args.location,
description:
data.event.description ??
`Splatoon competitive event hosted on ${resolveBaseUrl(data.event.bracketUrl)}`,
});
};
export const handle: SendouRouteHandle = {
@ -131,8 +133,7 @@ export const handle: SendouRouteHandle = {
},
};
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request);
export const loader = async ({ params }: LoaderFunctionArgs) => {
const parsedParams = z
.object({ id: z.preprocess(actualNumber, id) })
.parse(params);
@ -150,7 +151,6 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
return {
event,
title: makeTitle([event.name, t("pages.calendar")]),
results: await CalendarRepository.findResultsByEventId(parsedParams.id),
};
};

View File

@ -1,4 +1,4 @@
import type { MetaFunction, SerializeFrom } from "@remix-run/node";
import type { MetaFunction } from "@remix-run/node";
import { Form, useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import Compressor from "compressorjs";
@ -53,16 +53,20 @@ import { Tags } from "../components/Tags";
import "~/styles/calendar-new.css";
import "~/styles/maps.css";
import { SendouSwitch } from "~/components/elements/Switch";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/calendar.new.server";
import { loader } from "../loaders/calendar.new.server";
export { loader, action };
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!data) return [];
const what = args.data.isAddingTournament ? "tournament" : "calendar event";
return [{ title: data.title }];
return metaTags({
title: args.data.eventToEdit ? `Editing ${what}` : `New ${what}`,
location: args.location,
});
};
export const handle: SendouRouteHandle = {

View File

@ -20,7 +20,6 @@ import { getUserId } from "~/features/auth/core/user.server";
import { currentSeason } from "~/features/mmr/season";
import { HACKY_resolvePicture } from "~/features/tournament/tournament-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { i18next } from "~/modules/i18n/i18next.server";
import { joinListToNaturalString } from "~/utils/arrays";
import {
databaseTimestampToDate,
@ -32,7 +31,6 @@ import {
weekNumberToDate,
} from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import {
CALENDAR_PAGE,
@ -49,6 +47,7 @@ import type {
CalendarEventTag,
PersistedCalendarEventTag,
} from "../../../db/types";
import { metaTags } from "../../../utils/remix";
import * as CalendarRepository from "../CalendarRepository.server";
import { calendarEventTagSchema } from "../actions/calendar.new.server";
import { CALENDAR_EVENT } from "../calendar-constants";
@ -62,17 +61,26 @@ export const meta: MetaFunction = (args) => {
if (!data) return [];
return [
{ title: data.title },
{
name: "description",
content: `${data.events.length} events happening during week ${
data.displayedWeek
} including ${joinListToNaturalString(
data.events.slice(0, 3).map((e) => e.name),
)}`,
},
];
const events = data.events.slice().sort((a, b) => {
const aParticipants = a.participantCounts?.teams ?? 0;
const bParticipants = b.participantCounts?.teams ?? 0;
if (aParticipants > bParticipants) return -1;
if (aParticipants < bParticipants) return 1;
return 0;
});
return metaTags({
title: "Calendar",
ogTitle: "Splatoon competitive event calendar",
location: args.location,
description: `${data.events.length} events on sendou.ink happening during week ${
data.displayedWeek
} including ${joinListToNaturalString(
events.slice(0, 3).map((e) => e.name),
)}`,
});
};
export const handle: SendouRouteHandle = {
@ -99,7 +107,6 @@ const loaderTournamentsOnlySearchParamsSchema = z.object({
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const t = await i18next.getFixedT(request);
const url = new URL(request.url);
// separate from tags parse so they can fail independently
@ -160,7 +167,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
eventsToReport: user
? await CalendarRepository.eventsToReport(user.id)
: [],
title: makeTitle([`Week ${displayedWeek}`, t("pages.calendar")]),
};
};

View File

@ -3,18 +3,20 @@ import * as React from "react";
import { Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { useSetTitle } from "~/hooks/useSetTitle";
import { languages } from "~/modules/i18n/config";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
GITHUB_CONTRIBUTORS_URL,
RHODESMAS_FREESOUND_PROFILE_URL,
SPLATOON_3_INK,
} from "~/utils/urls";
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Contributions") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Contributions",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
@ -95,7 +97,6 @@ const TRANSLATORS: Array<{
export default function ContributionsPage() {
const { t } = useTranslation(["common", "contributions"]);
useSetTitle(t("common:pages.contributors"));
return (
<Main>

View File

@ -1,19 +1,19 @@
import type { MetaFunction } from "@remix-run/node";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { useSetTitle } from "~/hooks/useSetTitle";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import "~/styles/faq.css";
const AMOUNT_OF_QUESTIONS = 9;
export const meta: MetaFunction = () => {
return [
{ title: makeTitle("FAQ") },
{ name: "description", content: "Frequently asked questions" },
];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "FAQ",
description: "Frequently asked questions",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
@ -22,7 +22,6 @@ export const handle: SendouRouteHandle = {
export default function FAQPage() {
const { t } = useTranslation(["faq", "common"]);
useSetTitle(t("common:pages.faq"));
return (
<Main className="stack md">

View File

@ -1,9 +1,12 @@
import type { MetaFunction } from "@remix-run/node";
import { Main } from "~/components/Main";
import { makeTitle } from "~/utils/strings";
import { metaTags } from "~/utils/remix";
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Privacy Policy") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Privacy Policy",
location: args.location,
});
};
export default function PrivacyPolicyPage() {

View File

@ -5,8 +5,7 @@ import { Badge } from "~/components/Badge";
import { LinkButton } from "~/components/Button";
import { Main } from "~/components/Main";
import { CheckmarkIcon } from "~/components/icons/Checkmark";
import { useSetTitle } from "~/hooks/useSetTitle";
import { makeTitle } from "~/utils/strings";
import { metaTags } from "~/utils/remix";
import {
PATREON_HOW_TO_CONNECT_DISCORD_URL,
SENDOU_INK_PATREON_URL,
@ -16,8 +15,12 @@ import { SendouPopover } from "../../../components/elements/Popover";
import "../support.css";
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Support") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Support",
description: "Support Sendou's work on Patreon and get perks on sendou.ink",
location: args.location,
});
};
// 1 = support
@ -108,7 +111,6 @@ const PERKS = [
export default function SupportPage() {
const { t } = useTranslation();
useSetTitle(t("pages.support"));
return (
<Main className="stack lg">

View File

@ -20,7 +20,6 @@ import {
currentSeason,
} from "~/features/mmr/season";
import type { SkillTierInterval } from "~/features/mmr/tiered.server";
import { i18next } from "~/modules/i18n/i18next.server";
import {
type MainWeaponId,
type RankedModeShort,
@ -28,8 +27,8 @@ import {
} from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import { cache, ttl } from "~/utils/cache.server";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
LEADERBOARDS_PAGE,
navIconUrl,
@ -75,14 +74,13 @@ export const meta: MetaFunction = (args) => {
if (!data) return [];
return [
{ title: data.title },
{
name: "description",
content:
"Leaderboards of top Splatoon players ranked by their X Power and tournament results",
},
];
return metaTags({
title: "Leaderboards",
ogTitle: "Splatoon leaderboards",
description:
"Leaderboards of top Splatoon players ranked by their X Battle placements as well as tournament and SendouQ results. Categories per weapon and mode.",
location: args.location,
});
};
const TYPE_SEARCH_PARAM_KEY = "type";
@ -90,7 +88,6 @@ const SEASON_SEARCH_PARAM_KEY = "season";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUser(request);
const t = await i18next.getFixedT(request);
const unvalidatedType = new URL(request.url).searchParams.get(
TYPE_SEARCH_PARAM_KEY,
);
@ -159,7 +156,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
: type.startsWith("XP-WEAPON")
? weaponXPLeaderboard(Number(type.split("-")[2]) as MainWeaponId)
: null,
title: makeTitle(t("pages.leaderboards")),
season,
};
};

View File

@ -9,8 +9,8 @@ import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useSearchParamStateEncoder } from "~/hooks/useSearchParamState";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { LFG_PAGE, navIconUrl } from "~/utils/urls";
import { LFGAddFilterButton } from "../components/LFGAddFilterButton";
@ -39,8 +39,14 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Looking for group") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "LFG",
ogTitle: "Splatoon LFG (looking for players, teams & coaches)",
description:
"Find people to play Splatoon with. Create a post or browse existing ones. For looking players, teams, scrim partners and coaches alike.",
location: args.location,
});
};
export type LFGLoaderData = SerializeFrom<typeof loader>;

View File

@ -1,8 +1,8 @@
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "@remix-run/node";
import { Main } from "~/components/Main";
import { DiscordIcon } from "~/components/icons/Discord";
import { YouTubeIcon } from "~/components/icons/YouTube";
import { useSetTitle } from "~/hooks/useSetTitle";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { LINKS_PAGE, navIconUrl } from "~/utils/urls";
import links from "../links.json";
@ -15,10 +15,17 @@ export const handle: SendouRouteHandle = {
}),
};
export default function LinksPage() {
const { t } = useTranslation(["common"]);
useSetTitle(t("common:pages.links"));
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Links",
ogTitle: "Splatoon link collection",
description:
"Collection of useful Splatoon guides, Discord servers and other resources.",
location: args.location,
});
};
export default function LinksPage() {
return (
<Main>
<div className="stack md">

View File

@ -1,8 +1,4 @@
import type {
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import type { ShouldRevalidateFunction } from "@remix-run/react";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import * as React from "react";
@ -16,11 +12,9 @@ import { EditIcon } from "~/components/icons/Edit";
import type { CalendarEvent } from "~/db/types";
import { getUserId } from "~/features/auth/core/user.server";
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { type ModeWithStage, stageIds } from "~/modules/in-game-lists";
import invariant from "~/utils/invariant";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
MAPS_URL,
calendarEventPage,
@ -33,6 +27,7 @@ import { mapPoolToNonEmptyModes } from "../core/map-list-generator/utils";
import { MapPool } from "../core/map-pool";
import "~/styles/maps.css";
import { SendouSwitch } from "~/components/elements/Switch";
import { metaTags } from "~/utils/remix";
const AMOUNT_OF_MAPS_IN_MAP_LIST = stageIds.length * 2;
@ -44,11 +39,13 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ nextUrl }) => {
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [{ title: data.title }];
return metaTags({
title: "Map List Generator",
ogTitle: "Splatoon 3 map list generator",
description:
"Generate a map list based on maps you choose or a tournament's map pool.",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
@ -64,7 +61,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const url = new URL(request.url);
const calendarEventId = url.searchParams.get("eventId");
const t = await i18next.getFixedT(request);
const event = calendarEventId
? await CalendarRepository.findById({
@ -84,7 +80,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
recentEventsWithMapPools: user
? await CalendarRepository.findRecentMapPoolsByAuthorId(user.id)
: undefined,
title: makeTitle([t("pages.maps")]),
};
};

View File

@ -1,23 +1,20 @@
import type { MetaFunction } from "@remix-run/node";
import { lazy } from "react";
import { useTranslation } from "react-i18next";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSetTitle } from "~/hooks/useSetTitle";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { PLANNER_URL, navIconUrl } from "~/utils/urls";
import "../plans.css";
export const meta: MetaFunction = () => {
return [
{ title: makeTitle("Planner") },
{
name: "description",
content:
"Make the perfect Splatoon 3 battle plans by drawing on maps and adding weapon images",
},
];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Map Planner",
ogTitle: "Splatoon 3 Map planner",
description:
"Make perfect Splatoon 3 battle plans by drawing on maps and adding weapon images",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
@ -32,9 +29,7 @@ export const handle: SendouRouteHandle = {
const Planner = lazy(() => import("~/features/map-planner/components/Planner"));
export default function MapPlannerPage() {
const { t } = useTranslation(["common"]);
const isMounted = useIsMounted();
useSetTitle(t("common:pages.plans"));
if (!isMounted) return <div className="plans__placeholder" />;

View File

@ -9,7 +9,6 @@ import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import type { AnyWeapon, DamageType } from "~/features/build-analyzer";
import { possibleApValues } from "~/features/build-analyzer";
import { useSetTitle } from "~/hooks/useSetTitle";
import {
BIG_BUBBLER_ID,
BOOYAH_BOMB_ID,
@ -38,7 +37,9 @@ import {
import { useObjectDamage } from "../calculator-hooks";
import type { DamageReceiver } from "../calculator-types";
import "../calculator.css";
import type { MetaFunction } from "@remix-run/node";
import { SendouSwitch } from "~/components/elements/Switch";
import { metaTags } from "~/utils/remix";
export const CURRENT_PATCH = "9.2";
@ -53,6 +54,16 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Object Damage Calculator",
ogTitle: "Splatoon 3 object damage calculator",
description:
"Calculate how much damage weapons do to objects in Splatoon 3. The list of objects includes Crab Tank, Big Bubbler, Splash Wall, Rainmaker shield and more.",
location: args.location,
});
};
export default function ObjectDamagePage() {
const { t } = useTranslation(["analyzer"]);
const {
@ -240,7 +251,6 @@ function DamageReceiversGrid({
abilityPoints: string;
}): JSX.Element {
const { t } = useTranslation(["weapons", "analyzer", "common"]);
useSetTitle(t("common:pages.object-damage-calculator"));
return (
<div>
<div

View File

@ -32,24 +32,24 @@ import {
} from "~/permissions";
import { databaseTimestampToDate } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import {
badRequestIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import { userPage } from "~/utils/urls";
import { _action, actualNumber } from "~/utils/zod";
export const meta: MetaFunction = () => {
return [
{ title: makeTitle("Plus Server suggestions") },
{
name: "description",
content: "This month's suggestions for +1, +2 and +3.",
},
];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Plus Server suggestions",
ogTitle: "Plus Server suggestions",
description:
"This season's suggestions to the Plus Server (+1, +2 and +3).",
location: args.location,
});
};
const suggestionActionSchema = z.union([

View File

@ -11,26 +11,20 @@ import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingReposito
import { lastCompletedVoting } from "~/features/plus-voting/core";
import invariant from "~/utils/invariant";
import { roundToNDecimalPlaces } from "~/utils/number";
import { makeTitle } from "~/utils/strings";
import { PLUS_SERVER_DISCORD_URL, userPage } from "~/utils/urls";
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
import "~/styles/plus-history.css";
import { metaTags } from "~/utils/remix";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader>;
if (!data) return [];
return [
{ title: makeTitle("Plus Server voting history") },
{
name: "description",
content: `Plus Server voting results for ${
data.lastCompletedVoting.month + 1
}/${data.lastCompletedVoting.year}`,
},
];
return metaTags({
title: "Plus Server voting results",
ogTitle: "Plus Server voting results",
description:
"Plus Server (+1, +2 and +3) voting results for the latest season.",
location: args.location,
});
};
export const loader = async ({ request }: LoaderFunctionArgs) => {

View File

@ -23,14 +23,17 @@ import {
import { isVotingActive } from "~/features/plus-voting/core/voting-time";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import { badRequestIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { assertType, assertUnreachable } from "~/utils/types";
import { safeJSONParse } from "~/utils/zod";
import { PlusSuggestionComments } from "../../plus-suggestions/routes/plus.suggestions";
export const meta: MetaFunction = () => {
return [{ title: makeTitle("Plus Server voting") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Plus Server Voting",
location: args.location,
});
};
const voteSchema = z.object({

View File

@ -1,5 +1,9 @@
import { RadioGroup } from "@headlessui/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
MetaFunction,
} from "@remix-run/node";
import { useFetcher, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import * as React from "react";
@ -53,6 +57,7 @@ import {
import { settingsActionSchema } from "../q-settings-schemas.server";
import "../q-settings.css";
import { SendouSwitch } from "~/components/elements/Switch";
import { metaTags } from "~/utils/remix";
export const handle: SendouRouteHandle = {
i18n: ["q"],
@ -70,6 +75,13 @@ export const handle: SendouRouteHandle = {
],
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Settings",
location: args.location,
});
};
export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUserId(request);
const data = await parseRequestPayload({

View File

@ -1,3 +1,4 @@
import type { MetaFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Avatar } from "~/components/Avatar";
@ -8,6 +9,7 @@ import { useAutoRerender } from "~/hooks/useAutoRerender";
import { useIsMounted } from "~/hooks/useIsMounted";
import { twitchThumbnailUrlToSrc } from "~/modules/twitch/utils";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { FAQ_PAGE, sendouQMatchPage, twitchUrl, userPage } from "~/utils/urls";
import { cachedStreams } from "../core/streams.server";
@ -18,6 +20,14 @@ export const handle: SendouRouteHandle = {
i18n: ["q"],
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Streams",
description: "Streams of SendouQ matches in progress.",
location: args.location,
});
};
export const loader = async () => {
return {
streams: await cachedStreams(),

View File

@ -8,12 +8,21 @@ import {
TIERS_PAGE,
navIconUrl,
} from "~/utils/urls";
import "../q.css";
import type { MetaFunction } from "@remix-run/node";
import { Button } from "~/components/Button";
import { Image } from "~/components/Image";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "~/features/mmr/mmr-constants";
import { metaTags } from "~/utils/remix";
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Info",
description: "SendouQ guide and information.",
location: args.location,
});
};
export default function SendouQInfoPage() {
return (

View File

@ -27,13 +27,13 @@ import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useWindowSize } from "~/hooks/useWindowSize";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import {
type SendouRouteHandle,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
SENDOUQ_LOOKING_PAGE,
@ -95,8 +95,11 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = () => {
return [{ title: makeTitle("SendouQ") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Matchmaking",
location: args.location,
});
};
// this function doesn't throw normally because we are assuming

View File

@ -68,7 +68,7 @@ import {
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { inGameNameWithoutDiscriminator, makeTitle } from "~/utils/strings";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import { assertUnreachable } from "~/utils/types";
import {
@ -113,25 +113,22 @@ import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.se
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
import "../q.css";
import { SendouSwitch } from "~/components/elements/Switch";
import { metaTags } from "~/utils/remix";
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [
{
title: makeTitle(`SendouQ Match #${data.match.id}`),
},
{
name: "description",
content: `${joinListToNaturalString(
data.groupAlpha.members.map((m) => m.username),
)} vs. ${joinListToNaturalString(
data.groupBravo.members.map((m) => m.username),
)}`,
},
];
return metaTags({
title: `SendouQ - Match #${data.match.id}`,
description: `${joinListToNaturalString(
data.groupAlpha.members.map((m) => m.username),
)} vs. ${joinListToNaturalString(
data.groupBravo.members.map((m) => m.username),
)}`,
location: args.location,
});
};
export const handle: SendouRouteHandle = {

View File

@ -14,9 +14,9 @@ import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.ser
import * as QRepository from "~/features/sendouq/QRepository.server";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
SENDOUQ_LOOKING_PAGE,
@ -47,8 +47,11 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = () => {
return [{ title: makeTitle("SendouQ") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Preparing Group",
location: args.location,
});
};
export type SendouQPreparingAction = typeof action;

View File

@ -1,9 +1,13 @@
import type { MetaFunction } from "@remix-run/react";
import { Main } from "~/components/Main";
import { makeTitle } from "~/utils/strings";
import { metaTags } from "~/utils/remix";
export const meta: MetaFunction = () => {
return [{ title: makeTitle("SendouQ Rules") }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Rules",
description: "Rules everyone participating in SendouQ has to follow.",
location: args.location,
});
};
export default function SendouqRules() {

View File

@ -38,7 +38,6 @@ import {
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
LEADERBOARDS_PAGE,
@ -67,8 +66,8 @@ import { addMember } from "../queries/addMember.server";
import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server";
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
import "../q.css";
import { metaTags } from "~/utils/remix";
export const handle: SendouRouteHandle = {
i18n: ["q"],
@ -79,15 +78,13 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = () => {
return [
{ title: makeTitle("SendouQ") },
{
name: "description",
content:
"Splatoon 3 competitive ladder. Join by yourself or with your team and play ranked matches.",
},
];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ",
description:
"Splatoon 3 competitive ladder. Join by yourself or with your team and play ranked matches.",
location: args.location,
});
};
const validateCanJoinQ = async (user: { id: number; discordId: string }) => {

View File

@ -1,3 +1,4 @@
import type { MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { TierImage } from "~/components/Image";
@ -10,8 +11,18 @@ import {
import { ordinalToSp } from "~/features/mmr/mmr-utils";
import { currentOrPreviousSeason } from "~/features/mmr/season";
import { userSkills } from "~/features/mmr/tiered.server";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
export const meta: MetaFunction = (args) => {
return metaTags({
title: "SendouQ - Tiers",
description:
"Information about the tiers in SendouQ. From Leviathan+ to Iron.",
location: args.location,
});
};
export const handle: SendouRouteHandle = {
i18n: ["q"],
};

View File

@ -1,9 +1,11 @@
import type { MetaFunction } from "@remix-run/node";
import { useNavigate, useSearchParams } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { Theme, useTheme } from "~/features/theme/core/provider";
import { languages } from "~/modules/i18n/config";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { SETTINGS_PAGE, navIconUrl } from "~/utils/urls";
@ -29,6 +31,13 @@ export default function SettingsPage() {
);
}
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Settings",
location: args.location,
});
};
function LanguageSelector() {
const { t } = useTranslation(["common"]);
const { i18n } = useTranslation();

View File

@ -1,14 +1,11 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import type { UserWithPlusTier } from "~/db/types";
import { getUserId } from "~/features/auth/core/user.server";
import { i18next } from "~/modules/i18n/i18next.server";
import { sumArray } from "~/utils/number";
import { makeTitle } from "~/utils/strings";
import * as TeamRepository from "../TeamRepository.server";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await getUserId(request);
const t = await i18next.getFixedT(request);
const unsortedTeams = await TeamRepository.findAllUndisbanded();
@ -47,7 +44,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
});
return {
title: makeTitle(t("pages.t")),
teams,
teamMemberOfCount: user
? teams.filter((team) => team.members.some((m) => m.id === user.id))

View File

@ -13,7 +13,6 @@ import { Main } from "~/components/Main";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
TEAM_SEARCH_PAGE,
navIconUrl,
@ -25,13 +24,15 @@ import { loader } from "../loaders/t.$customUrl.edit.server";
import { TEAM } from "../team-constants";
import { canAddCustomizedColors, isTeamOwner } from "../team-utils";
import "../team.css";
import { metaTags } from "~/utils/remix";
export { action, loader };
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
return [{ title: makeTitle(data.team.name) }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Editing team",
location: args.location,
});
};
export const handle: SendouRouteHandle = {

View File

@ -15,7 +15,6 @@ import { SendouSwitch } from "~/components/elements/Switch";
import { TrashIcon } from "~/components/icons/Trash";
import { useUser } from "~/features/auth/core/user";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
TEAM_SEARCH_PAGE,
joinTeamPage,
@ -26,15 +25,17 @@ import type * as TeamRepository from "../TeamRepository.server";
import { TEAM_MEMBER_ROLES } from "../team-constants";
import { isTeamFull } from "../team-utils";
import "../team.css";
import { metaTags } from "~/utils/remix";
import { action } from "../actions/t.$customUrl.roster.server";
import { loader } from "../loaders/t.$customUrl.roster.server";
export { loader, action };
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
return [{ title: makeTitle(data.team.name) }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Managing team roster",
location: args.location,
});
};
export const handle: SendouRouteHandle = {

View File

@ -18,7 +18,6 @@ import { useUser } from "~/features/auth/core/user";
import { isAdmin } from "~/permissions";
import { removeDuplicates } from "~/utils/arrays";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
TEAM_SEARCH_PAGE,
bskyUrl,
@ -34,15 +33,26 @@ import { action } from "../actions/t.$customUrl.server";
import { loader } from "../loaders/t.$customUrl.server";
import { isTeamManager, isTeamMember, resolveNewOwner } from "../team-utils";
import "../team.css";
import { metaTags } from "~/utils/remix";
export { action, loader };
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
return [
{ title: makeTitle(data.team.name) },
{ name: "description", content: data.team.bio },
];
return metaTags({
title: args.data.team.name,
description: args.data.team.bio ?? undefined,
location: args.location,
image: args.data.team.avatarSrc
? {
url: userSubmittedImage(args.data.team.avatarSrc),
dimensions: {
width: 124,
height: 124,
},
}
: undefined,
});
};
export const handle: SendouRouteHandle = {

View File

@ -20,6 +20,7 @@ import { SearchIcon } from "~/components/icons/Search";
import { useUser } from "~/features/auth/core/user";
import { usePagination } from "~/hooks/usePagination";
import { joinListToNaturalString } from "~/utils/arrays";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
TEAM_SEARCH_PAGE,
@ -36,10 +37,14 @@ import { action } from "../actions/t.server";
import { loader } from "../loaders/t.server";
export { loader, action };
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
return [{ title: data.title }];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "Team Search",
ogTitle: "Splatoon team search",
description:
"List of all teams on sendou.ink and their members. Search for teams by name or member name.",
location: args.location,
});
};
export const handle: SendouRouteHandle = {

View File

@ -6,10 +6,9 @@ import type {
import { Link, useLoaderData } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import { i18next } from "~/modules/i18n/i18next.server";
import { removeDuplicates } from "~/utils/arrays";
import { metaTags } from "~/utils/remix";
import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
navIconUrl,
topSearchPage,
@ -44,30 +43,39 @@ export const handle: SendouRouteHandle = {
},
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!data) return [];
const aliasesStr =
args.data.names.aliases.length > 0
? ` (Aliases: ${args.data.names.aliases.join(", ")})`
: "";
return [
{ title: data.title },
{
name: "description",
content: `Splatoon 3 X Battle for the player ${data.placements[0].name}`,
},
];
return metaTags({
title: `${args.data.names.primary} X Battle Top 500 Placements`,
description: `Splatoon 3 X Battle results for the player ${args.data.names.primary}${aliasesStr}`,
location: args.location,
});
};
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
export const loader = async ({ params }: LoaderFunctionArgs) => {
const placements = notFoundIfFalsy(
findPlacementsByPlayerId(Number(params.id)),
);
const t = await i18next.getFixedT(request);
const primaryName = placements[0].name;
const aliases = removeDuplicates(
placements
.map((placement) => placement.name)
.filter((name) => name !== primaryName),
);
return {
placements,
title: makeTitle([placements[0].name, t("pages.xsearch")]),
names: {
primary: primaryName,
aliases,
},
};
};
@ -75,13 +83,6 @@ export default function XSearchPlayerPage() {
const { t } = useTranslation(["common"]);
const data = useLoaderData<typeof loader>();
const firstName = data.placements[0].name;
const aliases = removeDuplicates(
data.placements
.map((placement) => placement.name)
.filter((name) => name !== firstName),
);
const hasUserLinked = Boolean(data.placements[0].discordId);
return (
@ -89,15 +90,15 @@ export default function XSearchPlayerPage() {
<div>
<h2 className="text-lg">
{hasUserLinked ? (
<Link to={userPage(data.placements[0])}>{firstName}</Link>
<Link to={userPage(data.placements[0])}>{data.names.primary}</Link>
) : (
<>{firstName}</>
<>{data.names.primary}</>
)}{" "}
{t("common:xsearch.placements")}
</h2>
{aliases.length > 0 ? (
{data.names.aliases.length > 0 ? (
<div className="text-lighter text-sm">
{t("common:xsearch.aliases")} {aliases.join(", ")}
{t("common:xsearch.aliases")} {data.names.aliases.join(", ")}
</div>
) : null}
</div>

View File

@ -1,19 +1,14 @@
import type {
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { nanoid } from "nanoid";
import { useTranslation } from "react-i18next";
import { Main } from "~/components/Main";
import type { XRankPlacement } from "~/db/types";
import { i18next } from "~/modules/i18n/i18next.server";
import type { RankedModeShort } from "~/modules/in-game-lists";
import { rankedModesShort } from "~/modules/in-game-lists/modes";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { navIconUrl, topSearchPage } from "~/utils/urls";
import { PlacementsTable } from "../components/Placements";
import { findPlacementsOfMonth } from "../queries/findPlacements.server";
@ -31,21 +26,19 @@ export const handle: SendouRouteHandle = {
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [
{ title: data.title },
{ name: "description", content: "Splatoon 3 X Battle results" },
];
return metaTags({
title: "X Battle Top 500 Placements",
ogTitle: "Splatoon 3 X Battle Top 500 results browser",
description:
"Splatoon 3 X Battle results for the top 500 players for all the finished seasons in both Tentatek and Takoroka divisions.",
location: args.location,
});
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const availableMonthYears = monthYears();
const { month: latestMonth, year: latestYear } = availableMonthYears[0];
// #region parse URL params
const url = new URL(request.url);
const mode = (() => {
const mode = url.searchParams.get("mode");
@ -85,7 +78,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return latestYear;
})();
// #endregion
const placements = findPlacementsOfMonth({
mode,
@ -94,10 +86,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
year,
});
const t = await i18next.getFixedT(request);
return {
title: makeTitle(t("pages.xsearch")),
placements,
availableMonthYears,
};

View File

@ -13,8 +13,8 @@ import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { useIsMounted } from "~/hooks/useIsMounted";
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
BLANK_IMAGE_URL,
calendarEventPage,
@ -34,34 +34,20 @@ import "../tournament-organization.css";
import { loader } from "../loaders/org.$slug.server";
export { loader };
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader>;
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!data) return [];
const title = makeTitle(data.organization.name);
return [
{ title },
{
property: "og:title",
content: title,
},
{
property: "og:description",
content: data.organization.description,
},
{
property: "og:type",
content: "website",
},
{
property: "og:image",
content: data.organization.avatarUrl
? userSubmittedImage(data.organization.avatarUrl)
: undefined,
},
];
return metaTags({
title: args.data.organization.name,
location: args.location,
description: args.data.organization.description ?? undefined,
image: args.data.organization.avatarUrl
? {
url: userSubmittedImage(args.data.organization.avatarUrl),
dimensions: { width: 124, height: 124 },
}
: undefined,
});
};
export const handle: SendouRouteHandle = {

View File

@ -1,4 +1,4 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";
import clsx from "clsx";
import { useTranslation } from "react-i18next";
@ -8,9 +8,13 @@ import { Placement } from "~/components/Placement";
import { Redirect } from "~/components/Redirect";
import { SendouButton } from "~/components/elements/Button";
import { SendouPopover } from "~/components/elements/Popover";
import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tournament.server";
import type {
TournamentData,
TournamentDataTeam,
} from "~/features/tournament-bracket/core/Tournament.server";
import { tournamentTeamPageParamsSchema } from "~/features/tournament-bracket/tournament-bracket-schemas.server";
import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator";
import { metaTags } from "~/utils/remix";
import { parseParams } from "~/utils/remix.server";
import {
teamPage,
@ -18,6 +22,7 @@ import {
tournamentPage,
tournamentTeamPage,
userPage,
userSubmittedImage,
} from "~/utils/urls";
import { TeamWithRoster } from "../components/TeamWithRoster";
import {
@ -28,6 +33,29 @@ import {
import { tournamentIdFromParams } from "../tournament-utils";
import { useTournament } from "./to.$id";
export const meta: MetaFunction<typeof loader> = (args) => {
const tournamentData = (args.matches[1].data as any)
?.tournament as TournamentData;
if (!args.data || !tournamentData) return [];
const team = tournamentData.ctx.teams.find(
(t) => t.id === args.data!.tournamentTeamId,
)!;
const teamLogoUrl = team.team?.logoUrl ?? team.pickupAvatarUrl;
return metaTags({
title: `${team.name} @ ${tournamentData.ctx.name}`,
description: `${team.name} roster (${team.members.map((m) => m.username).join(", ")}) and sets in ${tournamentData.ctx.name}.`,
image: teamLogoUrl
? {
url: userSubmittedImage(teamLogoUrl),
dimensions: { width: 124, height: 124 },
}
: undefined,
location: args.location,
});
};
export const loader = ({ params }: LoaderFunctionArgs) => {
const tournamentId = tournamentIdFromParams(params);
const tournamentTeamId = parseParams({

View File

@ -22,7 +22,7 @@ import { useIsMounted } from "~/hooks/useIsMounted";
import { isAdmin } from "~/permissions";
import { databaseTimestampToDate } from "~/utils/dates";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { removeMarkdown } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
tournamentDivisionsPage,
@ -31,6 +31,7 @@ import {
tournamentRegisterPage,
userSubmittedImage,
} from "~/utils/urls";
import { metaTags } from "../../../utils/remix";
import { streamsByTournamentId } from "../core/streams.server";
import {
HACKY_resolvePicture,
@ -55,39 +56,18 @@ export const meta: MetaFunction = (args) => {
if (!data) return [];
const title = makeTitle(data.tournament.ctx.name);
const ogImage = () => {
if (
!data.tournament.ctx.logoSrc ||
data.tournament.ctx.logoSrc.startsWith("https")
) {
return data.tournament.ctx.logoSrc;
}
// opengraph does not support relative urls
return `${import.meta.env.VITE_SITE_DOMAIN}${data.tournament.ctx.logoSrc}`;
};
return [
{ title },
{
property: "og:title",
content: title,
return metaTags({
title: data.tournament.ctx.name,
description: data.tournament.ctx.description
? removeMarkdown(data.tournament.ctx.description)
: undefined,
image: {
url: data.tournament.ctx.logoSrc,
dimensions: { width: 124, height: 124 },
},
{
property: "og:description",
content: data.tournament.ctx.description,
},
{
property: "og:type",
content: "website",
},
{
property: "og:image",
content: ogImage(),
},
];
location: args.location,
url: tournamentPage(data.tournament.ctx.id),
});
};
export const handle: SendouRouteHandle = {

View File

@ -10,8 +10,8 @@ import { SubNav, SubNavLink } from "~/components/SubNav";
import { useUser } from "~/features/auth/core/user";
import { getUserId } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { metaTags } from "~/utils/remix";
import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import {
USER_SEARCH_PAGE,
navIconUrl,
@ -26,10 +26,14 @@ import {
import "~/styles/u.css";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data) return [];
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
return [{ title: makeTitle(data.user.username) }];
return metaTags({
title: args.data.user.username,
description: `${args.data.user.username}'s profile on sendou.ink including builds, tournament results, art and more.`,
location: args.location,
});
};
export const handle: SendouRouteHandle = {

View File

@ -1,4 +1,8 @@
import type { LoaderFunctionArgs, SerializeFrom } from "@remix-run/node";
import type {
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@ -9,6 +13,7 @@ import { Input } from "~/components/Input";
import { Main } from "~/components/Main";
import { SearchIcon } from "~/components/icons/Search";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { metaTags } from "~/utils/remix";
import {
type SendouRouteHandle,
parseSearchParams,
@ -27,6 +32,14 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = (args) => {
return metaTags({
title: "User Search",
description: "Search for sendou.ink users",
location: args.location,
});
};
export type UserSearchLoaderData = SerializeFrom<typeof loader>;
const searchParamsSchema = z.object({

View File

@ -18,8 +18,8 @@ import { useUser } from "~/features/auth/core/user";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useSearchParamState } from "~/hooks/useSearchParamState";
import { databaseTimestampToDate } from "~/utils/dates";
import { metaTags } from "~/utils/remix";
import { type SendouRouteHandle, notFoundIfFalsy } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
import {
VODS_PAGE,
@ -60,12 +60,15 @@ export const handle: SendouRouteHandle = {
},
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
export const meta: MetaFunction<typeof loader> = (args) => {
if (!args.data) return [];
if (!data) return [];
return [{ title: makeTitle(data.vod.title) }];
return metaTags({
title: args.data.vod.title,
description:
"Splatoon 3 VoD with timestamps to check out specific weapons as well as map and mode combinations.",
location: args.location,
});
};
export const loader = ({ params }: LoaderFunctionArgs) => {

View File

@ -1,18 +1,13 @@
import type {
LoaderFunctionArgs,
MetaFunction,
SerializeFrom,
} from "@remix-run/node";
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { useTranslation } from "react-i18next";
import { Button } from "~/components/Button";
import { WeaponCombobox } from "~/components/Combobox";
import { Label } from "~/components/Label";
import { Main } from "~/components/Main";
import { i18next } from "~/modules/i18n/i18next.server";
import { mainWeaponIds, modesShort, stageIds } from "~/modules/in-game-lists";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { makeTitle } from "~/utils/strings";
import { VODS_PAGE, navIconUrl } from "~/utils/urls";
import { VodListing } from "../components/VodListing";
import { findVods } from "../queries/findVods.server";
@ -29,16 +24,17 @@ export const handle: SendouRouteHandle = {
}),
};
export const meta: MetaFunction = (args) => {
const data = args.data as SerializeFrom<typeof loader> | null;
if (!data) return [];
return [{ title: data.title }];
export const meta: MetaFunction<typeof loader> = (args) => {
return metaTags({
title: "VODs",
ogTitle: "Splatoon 3 VODs (gameplay footage search)",
description:
"Search for Splatoon 3 VODs (gameplay footage) by mode, stage and/or weapon.",
location: args.location,
});
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18next.getFixedT(request);
const url = new URL(request.url);
const limit = Number(url.searchParams.get("limit") ?? VODS_PAGE_BATCH_SIZE);
@ -58,7 +54,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
return {
vods,
title: makeTitle(t("pages.vods")),
limit,
hasMoreVods,
};

View File

@ -1,9 +0,0 @@
import { useEffect } from "react";
import { makeTitle } from "~/utils/strings";
/** Set title on mount. Used for showing a translated title on pages without loader. */
export function useSetTitle(title: string) {
useEffect(() => {
document.title = makeTitle(title);
}, [title]);
}

View File

@ -43,8 +43,8 @@ import { useVisibilityChange } from "./hooks/useVisibilityChange";
import { DEFAULT_LANGUAGE } from "./modules/i18n/config";
import i18next, { i18nCookie } from "./modules/i18n/i18next.server";
import type { Namespace } from "./modules/i18n/resources.server";
import { isRevalidation } from "./utils/remix";
import { COMMON_PREVIEW_IMAGE, SUSPENDED_PAGE } from "./utils/urls";
import { isRevalidation, metaTags } from "./utils/remix";
import { SUSPENDED_PAGE } from "./utils/urls";
import "nprogress/nprogress.css";
import "~/styles/common.css";
@ -64,19 +64,14 @@ export const shouldRevalidate: ShouldRevalidateFunction = (args) => {
return Boolean(lang);
};
export const meta: MetaFunction = () => {
return [
{ title: "sendou.ink" },
{
name: "description",
content:
"Competitive Splatoon Hub featuring gear planner, event calendar, builds by top players, and more!",
},
{
property: "og:image",
content: COMMON_PREVIEW_IMAGE,
},
];
export const meta: MetaFunction = (args) => {
return metaTags({
title: "sendou.ink",
ogTitle: "sendou.ink - Competitive Splatoon Hub",
location: args.location,
description:
"Sendou.ink is the home of competitive Splatoon featuring daily tournaments and a seasonal ladder. Variety of tools and the largest collection of builds by top players allow you to level up your skill in Splatoon 3.",
});
};
export type RootLoaderData = SerializeFrom<typeof loader>;

View File

@ -1,7 +1,122 @@
import type { ShouldRevalidateFunctionArgs } from "@remix-run/react";
import type {
Location,
ShouldRevalidateFunctionArgs,
useLoaderData,
} from "@remix-run/react";
import { truncateBySentence } from "./strings";
import { COMMON_PREVIEW_IMAGE } from "./urls";
export function isRevalidation(args: ShouldRevalidateFunctionArgs) {
return (
args.defaultShouldRevalidate && args.nextUrl.href === args.currentUrl.href
);
}
// https://remix.run/docs/en/main/start/future-flags#serializefrom
export type SerializeFrom<T> = ReturnType<typeof useLoaderData<T>>;
interface OpenGraphArgs {
/** Title as shown by the browser in the tab etc. Appended with "| sendou.ink"*/
title: string;
/** Title as shown when shared on Bluesky, Discord etc. Also used in search results. If omitted, "title" is used instead. */
ogTitle?: string;
/** Brief description of the page's contents used by search engines and social media sharing. If the description is over 300 characters long it is automatically truncated. */
description?: string;
location: Location;
/** Optionally override location pathname. */
url?: string;
image?: {
url: string;
dimensions?: {
width: number;
height: number;
};
};
}
const ROOT_URL = "https://sendou.ink";
export function metaTitle(args: Pick<OpenGraphArgs, "title" | "ogTitle">) {
return [
{
title:
args.title === "sendou.ink" ? args.title : `${args.title} | sendou.ink`,
},
{
property: "og:title",
content: args.ogTitle ?? args.title,
},
];
}
export function metaTags(args: OpenGraphArgs) {
const truncatedDescription = args.description
? truncateBySentence(args.description, 300)
: null;
const result = [
...metaTitle(args),
args.description
? {
name: "description",
content: truncatedDescription,
}
: null,
args.description
? {
property: "og:description",
content: truncatedDescription,
}
: null,
{
property: "og:site_name",
content: "sendou.ink",
},
{
property: "og:type",
content: "website",
},
{
property: "og:url",
content: `${ROOT_URL}${args.location.pathname}`,
},
{
property: "og:image",
content: (() => {
if (args.image?.url.startsWith("http")) {
return args.image.url;
}
if (args.image) {
return `${ROOT_URL}${args.image.url}`;
}
return `${ROOT_URL}${COMMON_PREVIEW_IMAGE}`;
})(),
},
].filter((val) => val !== null);
if (!args.image) {
result.push({
property: "og:image:width",
content: "1920",
});
result.push({
property: "og:image:height",
content: "1080",
});
} else if (args.image.dimensions) {
result.push({
property: "og:image:width",
content: String(args.image.dimensions.width),
});
result.push({
property: "og:image:height",
content: String(args.image.dimensions.height),
});
}
return result;
}

View File

@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { pathnameFromPotentialURL } from "./strings";
import { pathnameFromPotentialURL, truncateBySentence } from "./strings";
describe("pathnameFromPotentialURL()", () => {
test("Resolves path name from valid URL", () => {
@ -12,3 +12,35 @@ describe("pathnameFromPotentialURL()", () => {
expect(pathnameFromPotentialURL("sendouc")).toBe("sendouc");
});
});
describe("truncateBySentence()", () => {
test("Truncates text by sentence within max length", () => {
const text = "This is the first sentence. This is the second sentence.";
expect(truncateBySentence(text, 30)).toBe("This is the first sentence.");
});
test("Returns original text if no sentences fit within max length", () => {
const text = "This is a very long sentence that exceeds the max length.";
expect(truncateBySentence(text, 10)).toBe("This is a");
});
test("Returns original text if it is shorter than max length", () => {
const text = "Short text.";
expect(truncateBySentence(text, 50)).toBe("Short text.");
});
test("Handles no senteces", () => {
const text = "One two three four five six seven eight nine ten";
expect(truncateBySentence(text, 10)).toBe("One two th");
});
test("Truncates text by sentence with newline characters", () => {
const text = "This is the first sentence\nThis is the second sentence";
expect(truncateBySentence(text, 30)).toBe("This is the first sentence");
});
test("Handles text with multiple newline characters", () => {
const text = "First line\nSecond line\nThird line";
expect(truncateBySentence(text, 20)).toBe("First line");
});
});

View File

@ -5,10 +5,6 @@ export function inGameNameWithoutDiscriminator(inGameName: string) {
return inGameName.split("#")[0];
}
export function makeTitle(title: string | string[]) {
return `${Array.isArray(title) ? title.join(" | ") : title} | sendou.ink`;
}
export function semiRandomId() {
return String(Math.random());
}
@ -68,3 +64,62 @@ export function pathnameFromPotentialURL(maybeUrl: string) {
return maybeUrl;
}
}
export function truncateBySentence(value: string, max: number) {
if (value.length <= max) {
return value;
}
const sentences = value.match(/[^.!?\n]+[.!?\n]*/g) || [];
let result = "";
for (const sentence of sentences) {
if ((result + sentence).length > max) {
break;
}
result += sentence;
}
return result.length > 0 ? result.trim() : value.slice(0, max).trim();
}
// based on https://github.com/zuchka/remove-markdown
export function removeMarkdown(value: string) {
const htmlReplaceRegex = /<[^>]*>/g;
return (
value
// Remove HTML tags
.replace(htmlReplaceRegex, "")
// Remove setext-style headers
.replace(/^[=\-]{2,}\s*$/g, "")
// Remove footnotes?
.replace(/\[\^.+?\](\: .*?$)?/g, "")
.replace(/\s{0,2}\[.*?\]: .*?$/g, "")
// Remove images
.replace(/\!\[(.*?)\][\[\(].*?[\]\)]/g, "")
// Remove inline links
.replace(/\[([^\]]*?)\][\[\(].*?[\]\)]/g, "$2")
// Remove blockquotes
.replace(/^(\n)?\s{0,3}>\s?/gm, "$1")
// Remove reference-style links?
.replace(/^\s{1,2}\[(.*?)\]: (\S+)( ".*?")?\s*$/g, "")
// Remove headers
.replaceAll("#", "")
// Remove * emphasis
.replace(/([\*]+)(\S)(.*?\S)??\1/g, "$2$3")
// Remove _ emphasis. Unlike *, _ emphasis gets rendered only if
// 1. Either there is a whitespace character before opening _ and after closing _.
// 2. Or _ is at the start/end of the string.
.replace(/(^|\W)([_]+)(\S)(.*?\S)??\2($|\W)/g, "$1$3$4$5")
// Remove code blocks
.replace(/(`{3,})(.*?)\1/gm, "$2")
// Remove inline code
.replace(/`(.+?)`/g, "$1")
// // Replace two or more newlines with exactly two? Not entirely sure this belongs here...
// .replace(/\n{2,}/g, '\n\n')
// // Remove newlines in a paragraph
// .replace(/(\S+)\n\s*(\S+)/g, '$1 $2')
// Replace strike through
.replace(/~(.*?)~/g, "$1")
);
}