Files
gerbeur/shared/dump.ts
khannurien 2c3ba6ad11 v4: link previews are read once, in one place, and no longer take an error page, a relative image or a mask icon for the page's — each provider fetched its page and scanned it with regular expressions of its own, the generic one some fifteen times, and Bandcamp and SoundCloud read only OG tags. fetchPage (remote/fetch.ts) now fetches every page, follows redirects to the address it was served from, and refuses an error status, so a 404 or a bot challenge is unreachable rather than a preview titled "Just a moment...", and a refresh no longer overwrites good metadata with it. parsePage (remote/html.ts) reads it in one linear pass with a real attribute reader: unquoted values, a > inside quotes, og: tags under name as well as property, and nothing from comments or scripts. Every URL a page names is resolved against where it was served from, so a relative og:image works; entities are decoded in one pass, &eacute; and &rsquo; included, and one that names no character no longer fails the preview (bandcamp-tralbum.ts shares the decoder). The mask icon is no longer the favicon, JSON-LD prefers the page's node over the site's, tracking pixels and lazy placeholders aren't thumbnails, an og:url with a slug keeps the OG tags, and a <title> loses its site suffix. genericPreview is the base of the Bandcamp, SoundCloud and YouTube channel previews; SoundCloud takes www., m. and on.soundcloud.com short links, embedding the page they lead to, and Bandcamp keeps only its own player. The service tidies every preview: text on one line, titles and descriptions capped, only http(s) images and players. RichContent.type is now RichContentType, and the unused videoId is gone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:46:32 +00:00

162 lines
5.0 KiB
TypeScript

/**
* A dump is what users post: a link or a file, with a title and a note. This
* module types it as the API sends it, with its link preview and the bodies
* that create and edit it. The backend's `dump-service.ts` produces these types
* and the frontend's dump pages and create form consume them.
*
* @module
*/
/**
* Names the kind of link a {@link RichContent} previews: a site the player
* plays its own way, or `generic` for any other page, this site's included.
*/
export type RichContentType = "youtube" | "bandcamp" | "soundcloud" | "generic";
/**
* Built by a provider of `rich-content-service.ts` when a link is posted or
* previewed, and stored with the dump as JSON. The frontend's
* `RichContentCard` and player read it.
*/
export interface RichContent {
/** The player picks how to play the link by it. */
type: RichContentType;
url: string;
siteName?: string;
title?: string;
description?: string;
/**
* A real preview image (og:image, a large content image, a video still).
* Never a favicon — an absent value means "this page offers no artwork",
* which is what makes the generated placeholder possible.
*/
thumbnailUrl?: string;
/** Used as the placeholder's glyph. */
faviconUrl?: string;
/** The page's declared brand color, normalized to `#rrggbb`. */
accentColor?: string;
/**
* The player a YouTube, Bandcamp or SoundCloud link plays in, framed by the
* frontend.
*/
embedUrl?: string;
}
/**
* Returned by every endpoint that answers dumps, and pushed to clients by the
* `dump_created` and `dump_updated` socket messages. A URL dump has `url` and
* `richContent`, a file dump the `file…` fields. A private dump reaches its
* poster only.
*/
export interface Dump {
id: string;
kind: "url" | "file";
title: string;
/**
* Names the dump in its page's path, `dumpPath` (`shared/paths.ts`) building
* it with {@link ownerUsername}. Made from the title on creation, it changes
* only when edited, and is unique among its poster's dumps.
*/
slug: string;
/** The poster's note on the dump, in Markdown. */
comment?: string;
userId: string;
/** The poster's current name, which the dump's path starts with. */
ownerUsername: string;
createdAt: Date;
updatedAt?: Date;
url?: string;
richContent?: RichContent;
fileName?: string;
fileMime?: string;
fileSize?: number;
voteCount: number;
/** Deleted comments are not counted. */
commentCount: number;
isPrivate: boolean;
/** Set when the poster uploaded a custom thumbnail. */
thumbnailMime?: string;
categoryIds?: string[];
}
/**
* Returned by `GET /api/dumps?url=` for each dump already posted with the
* link being typed, and shown by the create form's `DuplicateNotice`. It holds
* only what the notice displays and links to.
*/
export interface DumpUrlMatch {
id: string;
slug: string;
title: string;
/** The poster's username. */
ownerUsername: string;
createdAt: Date;
voteCount: number;
commentCount: number;
}
/**
* Returned by `GET /api/remote/preview` to the create form, which shows it before the
* link is posted.
*/
export interface LinkPreview {
/**
* False when the page couldn't be fetched. `richContent` is then a stub
* holding the host name, and this flag is how the form tells a dead link
* from a page with no preview.
*/
reached: boolean;
/**
* Null only for a link that doesn't parse, which the route rejects before
* fetching anyway.
*/
richContent: RichContent | null;
}
/**
* Passed by the create form to `POST /api/dumps` as a JSON body, to post a
* link. A file is posted as a multipart form instead.
*/
export interface CreateUrlDumpRequest {
url: string;
/** Replaces the title read from the page, when the poster edited it. */
title?: string;
comment?: string;
isPrivate?: boolean;
categoryIds?: string[];
}
/**
* Passed to `PUT /api/dumps/:dumpId` by the edit page, and by the dump page's
* inline title edit. A field left out stays as it is.
*/
export interface UpdateDumpRequest {
/** Ignored for a file dump. A changed link has its preview fetched again. */
url?: string;
title?: string;
/**
* A new slug, which the edit page's slug field sends. A title change never
* changes the slug by itself.
*/
slug?: string;
/** `null` removes the note. */
comment?: string | null;
isPrivate?: boolean;
/** Replaces the current categories. */
categoryIds?: string[];
}
/**
* Returns a file dump's version, which the file and thumbnail URLs carry as
* `?v=`: the frontend's `dumpFileUrl` and `dumpThumbnailUrl`, and the digest's
* thumbnails. It changes whenever the file may have, so the file and thumbnail
* routes cache a URL naming the current one for good and revalidate any other.
* Without its dates, a dump gets a version no route serves as current.
*/
export function dumpFileVersion(
dump: { fileSize?: number; createdAt?: Date; updatedAt?: Date },
): string {
const at = dump.updatedAt ?? dump.createdAt;
return `${dump.fileSize ?? 0}-${at ? at.getTime() : 0}`;
}