/** * Previews any page no other provider claims, for `rich-content-service.ts`, * from whatever metadata its HTML carries. Its reading of a page, * {@link genericPreview}, is also the base of the Bandcamp, SoundCloud and * YouTube channel previews. * * @module */ import type { RichContent } from "../../../shared/dump.ts"; import type { RichContentProvider } from "./provider.ts"; import { siteHost } from "../../../shared/url.ts"; import { absoluteHttpUrl, normalizeCssColor, type Page, parsePage, } from "../html.ts"; import { fetchPage } from "../fetch.ts"; /** Returns the path of `url` without its trailing slashes, `/` for the root. */ function pathOf(url: string): string { return new URL(url).pathname.replace(/\/+$/, "") || "/"; } /** * Strips a trailing ` | Site`, ` – Site` or the like from a ``, which * names the page and then its site; the site is in the preview's badge. */ function withoutSiteSuffix(title: string, siteName: string): string { const escaped = siteName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const suffix = new RegExp(`\\s+[|–—\\-·•]\\s+${escaped}\\s*$`, "i"); return title.replace(suffix, "") || title; } /** * Returns the preview of the page `url` that `page` read, served from * `finalUrl` after redirects. Each field falls back from OG tags to Twitter * tags, JSON-LD and the markup itself. The icon and brand color feed the * placeholder the frontend draws when there is no thumbnail. */ export function genericPreview( page: Page, url: string, finalUrl: string, ): RichContent { // An og:url naming the home page from another page means the OG tags are // the site's defaults, not this page's: their title and image are skipped. // Any other og:url is the page's canonical address, often with a slug the // link left out, and its tags are the page's. const ogUrl = page.meta("og:url"); let useOg = true; try { useOg = !ogUrl || pathOf(new URL(ogUrl, finalUrl).href) !== "/" || pathOf(finalUrl) === "/"; } catch { /* an og:url that doesn't parse names no other page */ } const ogSiteName = page.meta("og:site_name"); const pageTitle = page.title && ogSiteName ? withoutSiteSuffix(page.title, ogSiteName) : page.title; const title = (useOg ? page.meta("og:title") : undefined) ?? page.meta("twitter:title") ?? page.jsonLd.title ?? pageTitle; const description = page.meta("og:description") ?? page.meta("twitter:description") ?? page.jsonLd.description ?? page.meta("description"); // Never the favicon: a 16×16 icon cropped into a thumbnail looks broken, // and the placeholder drawn instead shows the icon properly. A tag naming // no usable URL falls through to the next. const thumbnailUrl = (useOg ? absoluteHttpUrl(page.meta("og:image"), finalUrl) : undefined) ?? absoluteHttpUrl(page.meta("twitter:image"), finalUrl) ?? page.jsonLd.thumbnailUrl ?? page.largeImage ?? page.firstContentImage; // `/favicon.ico` is a guess. When it doesn't load, the placeholder shows // the site's initial. const faviconUrl = page.bestIcon ?? `${new URL(finalUrl).origin}/favicon.ico`; const accentColor = normalizeCssColor( page.themeColor ?? page.meta("msapplication-TileColor") ?? page.maskIconColor, ); return { type: "generic", url, title, description, thumbnailUrl, faviconUrl, accentColor, siteName: ogSiteName ?? siteHost(url), }; } /** Matches every URL, so it comes last in the service's list. */ export const genericProvider: RichContentProvider = { name: "generic", matches(_url: string): boolean { return true; }, async fetch(url: string): Promise<RichContent> { const fetched = await fetchPage(url); if (fetched.html === undefined) return { type: "generic", url }; return genericPreview( parsePage(fetched.html, fetched.url), url, fetched.url, ); }, };