Files

322 lines
11 KiB
TypeScript
Raw Permalink Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
LISTING STATE - URL search parameters
----------------------------------------------------------------------------
Every filter, the sort order and the page number live in the query string,
so a filtered listing is a real URL: shareable, bookmarkable, back-button
correct, and reachable by a crawler that only follows links.
Pure and client-safe - the same helpers parse in the loader and build hrefs
in the component, which is what keeps the two in step.
PARAMETER NAMES (shared by /en and /ar - the locale lives in the path):
resources ?type= &topic= &author= &partner= &year= &sort= &page=
knowledge-hub ?type= &topic= &q= &page=
news-and-updates ?tab= &page=
========================================================================= */
import { data } from "react-router";
import {
DEFAULT_LOCALE,
isLocale,
localizedPath,
translate,
type Locale,
type TranslationKey,
} from "~/i18n";
/** Entries per page. 12 divides cleanly into the 1 / 2 / 3 column grids. */
export const PAGE_SIZE = 12;
/** Longest search term we will forward to the CMS. */
const MAX_QUERY_LENGTH = 100;
/** Slugs and enum values are `[a-z0-9-]` - anything else is dropped, never sent. */
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export type SortOrder = "newest" | "oldest";
export const SORT_ORDERS = ["newest", "oldest"] as const satisfies readonly SortOrder[];
/* ---------------------------------------------------------------------------
PARSING
------------------------------------------------------------------------ */
/** A 1-based page number. Anything unparseable, zero or negative becomes 1. */
export function parsePage(params: URLSearchParams): number {
const raw = Number.parseInt(params.get("page") ?? "", 10);
return Number.isFinite(raw) && raw > 0 ? Math.min(raw, 10_000) : 1;
}
/** One of `allowed`, or `fallback`. Used for every enum-backed filter. */
export function parseEnum<T extends string>(
params: URLSearchParams,
key: string,
allowed: readonly T[],
fallback: T,
): T {
const value = params.get(key);
return allowed.find((option) => option === value) ?? fallback;
}
/** One of `allowed`, or undefined when the parameter is absent or unknown. */
export function parseOptionalEnum<T extends string>(
params: URLSearchParams,
key: string,
allowed: readonly T[],
): T | undefined {
const value = params.get(key);
return allowed.find((option) => option === value);
}
/**
* A taxonomy slug. Validated rather than trusted: the value is interpolated
* into a Strapi filter, so only the shape a slug can actually have is passed on.
*/
export function parseSlug(params: URLSearchParams, key: string): string | undefined {
const value = params.get(key)?.trim().toLowerCase();
if (!value || value.length > 80 || !SLUG_PATTERN.test(value)) return undefined;
return value;
}
/**
* An exact value for a filter on a plain STRING field - `resource.authors` is
* free text, not a taxonomy, so its filter cannot be a slug. Trimmed, length
* capped and stripped of control characters before it is interpolated into a
* Strapi `$eq`, so only a value a reader could have picked out of the select
* is ever forwarded.
*/
export function parseExactText(
params: URLSearchParams,
key: string,
maxLength = 200,
): string | undefined {
const raw = params.get(key);
if (typeof raw !== "string") return undefined;
// eslint-disable-next-line no-control-regex
const value = raw.replace(/[\u0000-\u001F\u007F]/g, "").trim();
if (value.length === 0 || value.length > maxLength) return undefined;
return value;
}
/** A four-digit year inside a plausible range, or undefined. */
export function parseYear(params: URLSearchParams, key = "year"): number | undefined {
const raw = Number.parseInt(params.get(key) ?? "", 10);
if (!Number.isFinite(raw) || raw < 1900 || raw > 2200) return undefined;
return raw;
}
/** A free-text search term, trimmed and length-capped. */
export function parseQuery(params: URLSearchParams, key = "q"): string | undefined {
const value = params.get(key)?.trim();
if (!value) return undefined;
return value.slice(0, MAX_QUERY_LENGTH);
}
export function parseSort(params: URLSearchParams): SortOrder {
return parseEnum(params, "sort", SORT_ORDERS, "newest");
}
/* ---------------------------------------------------------------------------
STRAPI QUERY FRAGMENTS
------------------------------------------------------------------------ */
/** `"publicationDate:desc"` / `":asc"`, with a stable tiebreaker. */
export function sortFor(field: string, order: SortOrder): string[] {
return order === "oldest" ? [`${field}:asc`, "title:asc"] : [`${field}:desc`, "title:asc"];
}
/** A whole calendar year as a half-open range on a date field. */
export function yearFilter(year: number): { $gte: string; $lt: string } {
return {
$gte: `${year}-01-01`,
$lt: `${year + 1}-01-01`,
};
}
/** Drop the keys whose value is undefined, so an empty filter is not sent. */
export function compactFilters(
filters: Record<string, unknown>,
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(filters).filter(([, value]) => value !== undefined),
);
}
/* ---------------------------------------------------------------------------
HREFS
------------------------------------------------------------------------ */
/**
* The same listing with one parameter changed. `undefined` removes it, and
* changing anything other than the page resets back to page 1 - a filter that
* kept you on page 7 of a shorter result set is the classic empty page.
*/
export function withParam(
params: URLSearchParams,
key: string,
value: string | number | undefined,
): string {
const next = new URLSearchParams(params);
if (value === undefined || value === "") next.delete(key);
else next.set(key, String(value));
if (key !== "page") next.delete("page");
if (key === "page" && String(value) === "1") next.delete("page");
const search = next.toString();
return search ? `?${search}` : "";
}
/** The listing with every filter cleared. */
export function clearedHref(pathname: string): string {
return pathname;
}
/* ---------------------------------------------------------------------------
PAGINATION MODEL
------------------------------------------------------------------------ */
export interface PageLink {
readonly page: number;
readonly isCurrent: boolean;
}
export interface PaginationModel {
readonly page: number;
readonly pageCount: number;
readonly total: number;
readonly from: number;
readonly to: number;
readonly previous: number | undefined;
readonly next: number | undefined;
readonly pages: readonly PageLink[];
}
/**
* A windowed page list: first, last, and up to two neighbours either side of
* the current page. Gaps are rendered as an ellipsis by the component.
*/
export function buildPagination(
page: number,
pageSize: number,
total: number,
): PaginationModel {
const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
const current = Math.min(Math.max(1, page), pageCount);
const pageWindow = new Set<number>([1, pageCount, current]);
for (let offset = 1; offset <= 2; offset += 1) {
if (current - offset >= 1) pageWindow.add(current - offset);
if (current + offset <= pageCount) pageWindow.add(current + offset);
}
const pages = [...pageWindow]
.sort((a, b) => a - b)
.map((value) => ({ page: value, isCurrent: value === current }));
return {
page: current,
pageCount,
total,
from: total === 0 ? 0 : (current - 1) * pageSize + 1,
to: Math.min(current * pageSize, total),
previous: current > 1 ? current - 1 : undefined,
next: current < pageCount ? current + 1 : undefined,
pages,
};
}
/* ---------------------------------------------------------------------------
OPTION LISTS
------------------------------------------------------------------------ */
export interface SelectOption {
readonly value: string;
readonly label: string;
}
/** Turn an enum plus its catalogue keys into `<option>`s in the page locale. */
export function enumOptions<T extends string>(
values: readonly T[],
keys: Readonly<Record<T, TranslationKey>>,
locale: Locale,
): SelectOption[] {
return values.map((value) => ({
value,
label: translate(locale, keys[value]),
}));
}
/** Years as `<option>`s. Digits follow the locale's `latn` numbering system. */
export function yearOptions(years: readonly number[], locale: Locale): SelectOption[] {
return years.map((year) => ({
value: String(year),
label: new Intl.NumberFormat(`${locale}-u-nu-latn`, {
useGrouping: false,
}).format(year),
}));
}
/* ---------------------------------------------------------------------------
ROUTE CONTEXT
------------------------------------------------------------------------ */
/**
* `params.lang` as a `Locale`. The ":lang" layout has already validated the
* segment, so this only ever narrows the type - but it narrows it honestly:
* an impossible value throws the same 404 the layout would have.
*/
export function requireLocale(value: string | undefined): Locale {
if (isLocale(value)) return value;
throw data("Not Found", { status: 404, statusText: "Not Found" });
}
/** What `buildMeta` needs from the root loader. */
export interface RootSeo {
readonly locale: Locale;
readonly pathname: string;
readonly siteUrl: string;
}
const FALLBACK_SITE_URL = "https://sharper.ly";
/**
* The root loader's data, read out of a `meta` function's `matches`.
*
* React Router 8 exposes it as `match.loaderData` (v7's `match.data` is gone),
* and it is looked up BY ROUTE ID rather than by index: `matches[0]` is only
* the root by convention, and the entry is undefined while the root loader is
* still in flight or on an error render - a `meta` that throws takes the whole
* document with it.
*/
export function readRootSeo(matches: readonly unknown[]): RootSeo {
for (const match of matches) {
if (!match || typeof match !== "object") continue;
const { id, loaderData } = match as { id?: unknown; loaderData?: unknown };
if (id !== "root" || !loaderData || typeof loaderData !== "object") continue;
const record = loaderData as Record<string, unknown>;
const locale = isLocale(record.locale) ? record.locale : DEFAULT_LOCALE;
return {
locale,
pathname:
typeof record.pathname === "string"
? record.pathname
: localizedPath("/", locale),
siteUrl:
typeof record.siteUrl === "string" ? record.siteUrl : FALLBACK_SITE_URL,
};
}
return {
locale: DEFAULT_LOCALE,
pathname: localizedPath("/", DEFAULT_LOCALE),
siteUrl: FALLBACK_SITE_URL,
};
}