164 lines
5.5 KiB
TypeScript
164 lines
5.5 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, and any other word under the
|
|
* root is a category or an app page. 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.
|
|
*
|
|
* 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
|
|
*/
|
|
|
|
/**
|
|
* 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 profile's `follow` tab: the users followed at
|
|
* `/@name/follow`, the playlists at `/@name/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];
|
|
|
|
/** Passed to {@link dumpPath} and {@link playlistPath}, usually as the item. */
|
|
export interface OwnedItem {
|
|
ownerUsername: string;
|
|
slug: string;
|
|
}
|
|
|
|
const seg = encodeURIComponent;
|
|
|
|
/**
|
|
* 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 = `/@${seg(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>`. */
|
|
export function dumpPath(dump: OwnedItem): string {
|
|
return `${userPath(dump.ownerUsername)}/dumps/${seg(dump.slug)}`;
|
|
}
|
|
|
|
/** Returns the path of a playlist's page, `/@owner/playlists/<slug>`. */
|
|
export function playlistPath(playlist: OwnedItem): string {
|
|
return `${userPath(playlist.ownerUsername)}/playlists/${seg(playlist.slug)}`;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function feedPath(categorySlug?: string, tab?: FeedTab): string {
|
|
const base = categorySlug ? `/${seg(categorySlug)}` : "";
|
|
return tab ? `${base}/${tab}` : base || "/";
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
|
|
/**
|
|
* Returns what a page path under a user names: a profile (with its tab), a
|
|
* dump (or its editor) or a playlist. 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("@") || !/^\w+$/.test(handle.slice(1))) return null;
|
|
const username = handle.slice(1);
|
|
|
|
if ((section === "dumps" || section === "playlists") && slug) {
|
|
if (section === "dumps" && more.length <= 1) {
|
|
if (more.length === 0) {
|
|
return { kind: "dump", username, slug, edit: false };
|
|
}
|
|
if (more[0] === "edit") {
|
|
return { kind: "dump", username, slug, edit: true };
|
|
}
|
|
}
|
|
if (section === "playlists" && more.length === 0) {
|
|
return { kind: "playlist", username, slug };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
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;
|
|
}
|