/** * 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. * * 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 { 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/`. */ export const SEARCH_TABS = ["dumps", "users", "playlists"] as const; /** One of the {@link SEARCH_TABS}. */ export type SearchTab = (typeof SEARCH_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/`, 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/`, 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 * `/`, 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/`. */ export function playlistsPath(categorySlug?: string): string { return categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists"; } /** * 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; }