initial commit
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
/* ============================================================================
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user