Files
gerbeur/shared/paths.ts
2026-10-10 09:14:55 +00:00

269 lines
9.3 KiB
TypeScript

/**
* Builds and parses the site's page paths, by the one rule they all follow:
* the path is a hierarchy, `@name` is a user (`@name@host` one whose account
* lives on another instance), and any other word under the
* root is a dump category or an app page, `/playlists` holding the playlist
* categories. Under a user each segment is a fixed tab name, and under `dumps`
* or `playlists` a slug. A view of a page is one more segment, and a default
* view is the bare path. A category narrowing a list on another page, a
* profile's or a search's, is a query, `?category=`, not a segment, and so is
* the order of a list of playlists, `?sort=`, the default order being the
* bare path, and the playlists page's list of the viewer's own, `?mine=1`.
*
* The client links every page through the builders (`src/utils/shared/urls.ts`
* wraps the dump and playlist ones), the emails prefix them with the
* frontend's URL, and the server's matchers (the Open Graph tags, the
* previews and backlinks of links to this site) read paths with
* {@link parsePagePath}. Migration 0021 rewrote the v3 links in stored text
* with the builders.
*
* @module
*/
import type { CategoryKind } from "./category.ts";
import {
parsePlaylistSort,
PLAYLIST_SORTS,
type PlaylistSort,
} from "./playlist.ts";
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
import { HANDLE_RE, splitHandle } from "./validation.ts";
/**
* The feed tabs, in the tab bar's order. Each is a path segment: `/hot` for
* every dump, `/music/hot` for a category. They are reserved at the root, so
* no category can take one.
*/
export const FEED_TABS = ["hot", "new", "journal", "follow"] as const;
/** One of the {@link FEED_TABS}. */
export type FeedTab = (typeof FEED_TABS)[number];
/**
* The tabs of a profile, each a segment after `/@name`. The bare `/@name` is
* the overview.
*/
export const PROFILE_TABS = [
"dumps",
"upvoted",
"playlists",
"follow",
"invitees",
"manage",
] as const;
/** One of the {@link PROFILE_TABS}. */
export type ProfileTab = (typeof PROFILE_TABS)[number];
/**
* The views of a `follow` tab, of what is followed: the users, at the bare
* tab, or the playlists. A profile's lists them, `/@name/follow` and
* `/@name/follow/playlists`, and a feed's shows their dumps, `/follow` and
* `/follow/playlists`.
*/
export const FOLLOW_VIEWS = ["users", "playlists"] as const;
/** One of the {@link FOLLOW_VIEWS}. */
export type FollowView = (typeof FOLLOW_VIEWS)[number];
/**
* The tabs of the search page: the dumps at `/search`, the others at
* `/search/<tab>`.
*/
export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
/** One of the {@link SEARCH_TABS}. */
export type SearchTab = (typeof SEARCH_TABS)[number];
/**
* The tabs of the playlists page, in the tab bar's order: each order of every
* public playlist, `?sort=`, then the viewer's own playlists, `?mine=1`,
* which only a signed-in user is offered.
*/
export const PLAYLISTS_TABS = [...PLAYLIST_SORTS, "mine"] as const;
/** One of the {@link PLAYLISTS_TABS}. */
export type PlaylistsTab = (typeof PLAYLISTS_TABS)[number];
/**
* The views of the community page, one per digest cadence: the week at
* `/community`, the month at `/community/monthly`.
*/
export const COMMUNITY_VIEWS: readonly DigestCadence[] = DIGEST_CADENCES;
/** Passed to {@link dumpPath} and {@link playlistPath}, usually as the item. */
export interface OwnedItem {
ownerUsername: string;
slug: string;
}
const seg = encodeURIComponent;
/**
* Writes a handle as a path segment. A host's characters are all safe in a
* path, so `/@alice@music.example` keeps its `@` rather than `%40`.
*/
function handleSeg(handle: string): string {
const { name, host } = splitHandle(handle);
return host ? `${seg(name)}@${host}` : seg(name);
}
/**
* Returns the path of `username`'s profile, of one of its tabs, or of a view
* of the `follow` tab. The default view, `users`, is the bare tab.
*/
export function userPath(
username: string,
tab?: ProfileTab,
view?: FollowView,
): string {
const base = `/@${handleSeg(username)}`;
if (!tab) return base;
return view && view !== FOLLOW_VIEWS[0]
? `${base}/${tab}/${view}`
: `${base}/${tab}`;
}
/**
* Returns the path of a dump's page, `/@owner/dumps/<slug>`, or of its
* editor, one segment more.
*/
export function dumpPath(dump: OwnedItem, view?: "edit"): string {
const path = `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
return view ? `${path}/${view}` : path;
}
/**
* Returns the path of a playlist's page, `/@owner/playlists/<slug>`, or of
* the page with its editor open, one segment more.
*/
export function playlistPath(playlist: OwnedItem, view?: "edit"): string {
const path = `${userPath(playlist.ownerUsername)}/playlists/${
seg(playlist.slug)
}`;
return view ? `${path}/${view}` : path;
}
/**
* Returns the path of a feed: every dump's at the root, or a category's at
* `/<slug>`, on `tab`, or on the viewer's default tab without one. The
* `follow` tab takes a `view`, the users followed by default.
*/
export function feedPath(
categorySlug?: string,
tab?: FeedTab,
view?: FollowView,
): string {
const base = categorySlug ? `/${seg(categorySlug)}` : "";
if (!tab) return base || "/";
return tab === "follow" && view && view !== FOLLOW_VIEWS[0]
? `${base}/${tab}/${view}`
: `${base}/${tab}`;
}
/**
* Returns the path of the page of every public playlist, `/playlists`, or of a
* playlist category's at `/playlists/<slug>`, on the tab `tab` names: an
* order, the default one without it, or the viewer's own playlists.
*/
export function playlistsPath(
categorySlug?: string,
tab?: PlaylistsTab,
): string {
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
return `${path}${tab === "mine" ? "?mine=1" : playlistSortQuery(tab)}`;
}
/**
* Returns the tab of the playlists page its query names: the viewer's own
* playlists with a `mine`, else the order `sort` names, as
* `parsePlaylistSort` reads it.
*/
export function parsePlaylistsTab(params: URLSearchParams): PlaylistsTab {
return params.has("mine") ? "mine" : parsePlaylistSort(params.get("sort"));
}
/**
* Returns the query string that puts a list of playlists in the order `sort`
* names, with its `?`, or `""` for the default order. `extra` holds the
* list's other parameters, such as a profile's `category`.
*/
export function playlistSortQuery(
sort: PlaylistSort = PLAYLIST_SORTS[0],
extra?: URLSearchParams,
): string {
const params = new URLSearchParams(extra);
if (sort === PLAYLIST_SORTS[0]) params.delete("sort");
else params.set("sort", sort);
const query = params.toString();
return query ? `?${query}` : "";
}
/**
* Returns the path of the page of the category of `kind` at `slug`: a dump
* category's feed ({@link feedPath}), or a playlist category's playlists
* ({@link playlistsPath}). The category chips link to it.
*/
export function categoryPath(kind: CategoryKind, slug: string): string {
return kind === "dump" ? feedPath(slug) : playlistsPath(slug);
}
/**
* Returns the path of the search page on `tab`, searching `query` when it is
* given.
*/
export function searchPath(tab: SearchTab = "dumps", query?: string): string {
const base = tab === SEARCH_TABS[0] ? "/search" : `/search/${tab}`;
return query ? `${base}?q=${seg(query)}` : base;
}
/** Returns the path of the community page on `view`, the week by default. */
export function communityPath(view: DigestCadence = "weekly"): string {
return view === COMMUNITY_VIEWS[0] ? "/community" : `/community/${view}`;
}
/**
* Returned by {@link parsePagePath} for a path under a user. `rest` holds the
* segments after `/@name`, a profile tab and its view.
*/
export type PagePath =
| { kind: "user"; username: string; rest: string[] }
| { kind: "dump"; username: string; slug: string; edit: boolean }
| { kind: "playlist"; username: string; slug: string; edit: boolean };
/**
* Returns what a page path under a user names: a profile (with its tab), a
* dump or a playlist (or its editor). It returns null for any other path, a
* feed or an app page, and for one under a user that no page answers. The
* names are decoded but not looked up, so the user or the item may not exist.
*/
export function parsePagePath(pathname: string): PagePath | null {
let segments: string[];
try {
segments = pathname.replace(/\/+$/, "").split("/").slice(1).map(
decodeURIComponent,
);
} catch {
return null;
}
const [handle, section, slug, ...more] = segments;
if (!handle?.startsWith("@") || !HANDLE_RE.test(handle.slice(1))) {
return null;
}
const username = handle.slice(1);
if ((section === "dumps" || section === "playlists") && slug) {
if (more.length > 1 || (more.length === 1 && more[0] !== "edit")) {
return null;
}
const kind = section === "dumps" ? "dump" : "playlist";
return { kind, username, slug, edit: more.length === 1 };
}
const rest = segments.slice(1);
if (rest.length === 0) return { kind: "user", username, rest };
if (!(PROFILE_TABS as readonly string[]).includes(rest[0])) return null;
if (rest.length === 1) return { kind: "user", username, rest };
if (
rest.length === 2 && rest[0] === "follow" &&
(FOLLOW_VIEWS as readonly string[]).includes(rest[1]) &&
rest[1] !== FOLLOW_VIEWS[0]
) return { kind: "user", username, rest };
return null;
}