waybetter-for-x/apps/web/seo.ts
2026-10-01 06:01:13 +07:00

328 lines
20 KiB
TypeScript

// What search engines and link previews see, generated for every page from one place: <title>,
// meta description, canonical, hreflang alternates, Open Graph, Twitter card, JSON-LD, plus
// sitemap.xml and robots.txt. The Store's pages come from the signed catalog, so a new item gets all
// of it with no hand-written HTML. The words come from the site's catalogs (i18n.ts): English unless
// a language is asked for. The Vite plugin (store-pages.ts), the Store checks
// (scripts/check-store.mjs) and the SEO test (tests/seo.test.ts) share these functions.
import { translator, type Translate } from './i18n.ts';
import { AUTHOR_HANDLE, SITE_URL, SOURCE_LANGUAGE, appInfo } from './site.config.ts';
import type { ItemMedia } from './store-media.ts';
import type { AreaItem } from './areas.ts';
export type StoreItem = {
apps: string[]; kind: 'theme' | 'plugin' | 'strings'; language?: string; id: string; name: string; author: string; version: string; description: string;
category: string; tags: string[]; url: string; file?: string; sha256: string; size: number; preview?: string;
permissions?: string[] | null; colors?: { bg: string; surface?: string; text: string; accent: string } | null;
license?: string; credits?: string;
};
export type Catalog = { format: number; name: string; generatedAt: string; items: StoreItem[] };
export type PageMeta = {
/** Site path with a trailing slash, in this page's language: "/store/themes/nord/", "/tr/store/themes/nord/". */
path: string;
/** The same page in English: the path without the language ("/store/themes/nord/"). */
base: string;
lang: string;
title: string;
description: string;
/** Shorter headline for link previews (defaults to the title). */
ogTitle?: string;
/** Site path of the link-preview image (1200×630 unless the size is given). */
image: string;
imageAlt: string;
imageSize?: { width: number; height: number };
jsonLd: object[];
/** YYYY-MM-DD for the sitemap. */
lastmod?: string;
/** Pages that must stay out of search results (the 404 page). */
noindex?: boolean;
/** This page in every published language, itself included (hreflang). Empty while the site has one language. */
alternates: { lang: string; path: string }[];
};
export const TITLE_MAX = 60;
export const DESCRIPTION_MIN = 50;
export const DESCRIPTION_MAX = 160;
export const KIND_FOLDER = { theme: 'themes', plugin: 'plugins', strings: 'strings' } as const;
export const KIND_LABEL = { theme: 'Theme', plugin: 'Plugin', strings: 'String pack' } as const;
const STORE_IMAGE = '/og/store.png';
export const absolute = (path: string): string => `${SITE_URL}${path}`;
/** A page's path in a language: English at the root, every other language under its code. */
export const langPath = (lang: string, path: string): string => (lang === SOURCE_LANGUAGE ? path : `/${lang}${path}`);
/** og:locale wants a territory; these are the usual ones for the site's languages. */
const OG_LOCALE: Record<string, string> = {
en: 'en_US', ja: 'ja_JP', es: 'es_ES', ar: 'ar_AR', id: 'id_ID', de: 'de_DE', pt: 'pt_BR', tr: 'tr_TR', fr: 'fr_FR', th: 'th_TH', ko: 'ko_KR',
nl: 'nl_NL', vi: 'vi_VN', 'zh-Hant': 'zh_TW', it: 'it_IT', hi: 'hi_IN', ru: 'ru_RU',
};
/** At most `max` characters: whole sentences when they fit, else whole words and an ellipsis. */
export function clip(text: string, max: number): string {
const clean = text.replace(/\s+/g, ' ').trim();
if ([...clean].length <= max) return clean;
const head = [...clean].slice(0, max).join('');
// The last sentence end (a period inside "t.co" or "1.2" is not one).
let sentence = -1;
for (const match of head.matchAll(/[.!?](?=\s|$)/g)) sentence = match.index;
if (sentence >= max * 0.5) return head.slice(0, sentence + 1);
const word = head.slice(0, max - 1).replace(/\s+\S*$/, '').replace(/[\s,;:(—–-]+$/, '');
return `${word}…`;
}
/**
* The page folder of every item. An id is unique per app and kind, so two apps may use the same
* one: then the folder carries the app id ("kick-nord").
*/
export function itemSlugs(items: StoreItem[]): Map<StoreItem, string> {
const seen = new Map<string, number>();
for (const item of items) seen.set(`${item.kind}/${item.id}`, (seen.get(`${item.kind}/${item.id}`) ?? 0) + 1);
const slugs = new Map<StoreItem, string>();
for (const item of items) slugs.set(item, (seen.get(`${item.kind}/${item.id}`) ?? 0) > 1 ? `${item.apps[0]}-${item.id}` : item.id);
return slugs;
}
export const itemPath = (item: StoreItem, slug: string): string => `/store/${KIND_FOLDER[item.kind]}/${slug}/`;
const sites = (item: StoreItem): string => item.apps.map(app => appInfo(app).site).join(', ');
const appNames = (item: StoreItem): string => item.apps.map(app => appInfo(app).name).join(', ');
/** "<Name> — plugin for X · wayBetter Store", shortened until it fits a search result. */
export function itemTitle(item: StoreItem, t: Translate = translator(SOURCE_LANGUAGE)): string {
const candidates = [
t('site.meta.item.title', { name: item.name, kind: item.kind, sites: sites(item) }),
t('site.meta.item.titleShort', { name: item.name, kind: item.kind, sites: sites(item) }),
`${item.name} · wayBetter Store`,
item.name,
];
return candidates.find(title => [...title].length <= TITLE_MAX) ?? clip(item.name, TITLE_MAX);
}
/** The item's own description, cut to what a search result shows. */
export function itemDescription(item: StoreItem, t: Translate = translator(SOURCE_LANGUAGE)): string {
const tail = t('site.meta.item.descriptionTail', { kind: item.kind, apps: appNames(item) });
const own = clip(item.description, DESCRIPTION_MAX);
if ([...own].length >= DESCRIPTION_MIN) return own;
return clip(own ? `${own} ${tail}` : tail, DESCRIPTION_MAX);
}
export type PageOptions = {
/** Item page paths that have their own preview image (public/og/items/<kind>-<slug>.jpg). */
itemImages?: Set<string>;
/** An item's own cover, screenshots and video, by item page path (store-media.ts). */
media?: Map<string, ItemMedia>;
/** Last change per site path (YYYY-MM-DD), from git when the build has it. */
lastmod?: Map<string, string>;
/** Fallback date for pages without one: the day of the build. */
buildDate?: string;
/** The language of these pages (a site language with a catalog). English when left out. */
lang?: string;
/** Every published language, for the hreflang alternates. Left out: the site has one language. */
languages?: readonly string[];
/** wayBetter for X's area plugins in this language (areas.ts): a page each, not catalog items. */
areas?: readonly AreaItem[];
};
/** Every page of the site in one language, with its meta data, in sitemap order. */
export function sitePages(catalog: Catalog, options: PageOptions = {}): PageMeta[] {
const lang = options.lang ?? SOURCE_LANGUAGE;
const t = translator(lang);
const at = (path: string) => langPath(lang, path);
const alternates = (path: string) => ((options.languages?.length ?? 0) > 1 ? options.languages!.map(code => ({ lang: code, path: langPath(code, path) })) : []);
const count = (kind: StoreItem['kind']) => catalog.items.filter(item => item.kind === kind).length;
const themes = count('theme');
const plugins = count('plugin');
const catalogDate = catalog.generatedAt.slice(0, 10);
const slugs = itemSlugs(catalog.items);
const date = (path: string, fallback?: string) => options.lastmod?.get(path) ?? fallback ?? options.buildDate;
const crumbs = (trail: [string, string][]): object => ({
'@context': 'https://schema.org', '@type': 'BreadcrumbList',
itemListElement: trail.map(([name, path], index) => ({ '@type': 'ListItem', position: index + 1, name, item: absolute(at(path)) })),
});
const publisher = { '@type': 'Organization', name: 'wayBetter', url: absolute(at('/')) };
const webSite = {
'@context': 'https://schema.org', '@type': 'WebSite', name: 'wayBetter', url: absolute(at('/')),
potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${absolute(at('/store/'))}?q={search_term_string}` }, 'query-input': 'required name=search_term_string' },
};
const store = t('site.meta.crumb.store');
type Page = Omit<PageMeta, 'path' | 'base' | 'lang' | 'alternates'> & { path: string };
const pages: Page[] = [
{
path: '/', title: t('site.meta.home.title'), description: t('site.meta.home.description'),
ogTitle: 'wayBetter', image: '/og/home.png', imageAlt: t('site.meta.home.imageAlt'),
jsonLd: [webSite, { '@context': 'https://schema.org', ...publisher }],
},
{
path: '/x/', title: t('site.meta.x.title'), description: t('site.meta.x.description'),
ogTitle: 'wayBetter for X', image: '/og/x.png', imageAlt: t('site.meta.x.imageAlt'),
jsonLd: [
{ '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'wayBetter for X', url: absolute(at('/x/')), applicationCategory: 'SocialNetworkingApplication', operatingSystem: 'Windows, macOS, Linux', license: 'https://www.gnu.org/licenses/gpl-3.0.html', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, publisher },
crumbs([['wayBetter', '/'], ['wayBetter for X', '/x/']]),
],
},
{
path: '/store/', title: t('site.meta.store.title'), description: t('site.meta.store.description', { themes, plugins }),
ogTitle: 'wayBetter Store', image: STORE_IMAGE, imageAlt: t('site.meta.store.imageAlt'),
lastmod: catalogDate,
jsonLd: [
webSite,
{
'@context': 'https://schema.org', '@type': 'CollectionPage', name: 'wayBetter Store', url: absolute(at('/store/')), isPartOf: { '@type': 'WebSite', name: 'wayBetter', url: absolute(at('/')) },
mainEntity: { '@type': 'ItemList', numberOfItems: catalog.items.length, itemListElement: catalog.items.map((item, index) => ({ '@type': 'ListItem', position: index + 1, name: item.name, url: absolute(at(itemPath(item, slugs.get(item)!))) })) },
},
crumbs([['wayBetter', '/'], [store, '/store/']]),
],
},
{
path: '/store/submit/', title: t('site.meta.submit.title'), description: t('site.meta.submit.description'),
ogTitle: t('site.meta.submit.ogTitle'), image: STORE_IMAGE, imageAlt: t('site.meta.submit.imageAlt'),
jsonLd: [crumbs([['wayBetter', '/'], [store, '/store/'], [t('site.meta.submit.ogTitle'), '/store/submit/']])],
},
{
path: '/themes/', title: t('site.meta.themes.title'),
description: t('site.meta.themes.description', { count: catalog.items.filter(item => item.kind === 'theme' && item.apps.includes('x')).length }),
image: STORE_IMAGE, imageAlt: t('site.meta.themes.imageAlt'), lastmod: catalogDate,
jsonLd: [crumbs([['wayBetter', '/'], ['wayBetter for X', '/x/'], [t('site.meta.crumb.themes'), '/themes/']])],
},
{
path: '/plugins/', title: t('site.meta.plugins.title'),
description: t('site.meta.plugins.description', { count: catalog.items.filter(item => item.kind === 'plugin' && item.apps.includes('x')).length }),
image: STORE_IMAGE, imageAlt: t('site.meta.plugins.imageAlt'), lastmod: catalogDate,
jsonLd: [crumbs([['wayBetter', '/'], ['wayBetter for X', '/x/'], [t('site.meta.crumb.plugins'), '/plugins/']])],
},
];
for (const item of catalog.items) {
const slug = slugs.get(item)!;
const path = itemPath(item, slug);
const description = itemDescription(item, t);
const own = options.itemImages?.has(path) ? `/og/items/${item.kind}-${slug}.jpg` : STORE_IMAGE;
// The item's own media: the cover is its link-preview picture, and search engines learn about
// every picture and the video.
const media = options.media?.get(path);
const cover = media?.cover;
const shots = media ? [...(cover ? [cover] : []), ...media.screenshots] : [];
const images = shots.map(shot => ({ '@type': 'ImageObject', contentUrl: absolute(shot.src), width: shot.width, height: shot.height, ...(shot.alt ? { description: shot.alt } : {}), ...(shot.caption ? { caption: shot.caption } : {}) }));
const video = media?.video
? {
'@type': 'VideoObject', name: t('site.meta.item.videoName', { name: item.name, kind: item.kind, sites: sites(item) }), description: media.video.caption ?? description,
contentUrl: absolute(media.video.src), thumbnailUrl: absolute(media.video.poster.src), uploadDate: date(path, catalogDate),
duration: `PT${Math.max(1, Math.round(media.video.seconds))}S`, width: media.video.width, height: media.video.height, encodingFormat: media.video.type,
}
: null;
const common = {
'@context': 'https://schema.org', name: item.name, description: item.description || description, url: absolute(at(path)),
...(item.author ? { author: { '@type': 'Organization', name: item.author } } : {}),
...(images.length ? { image: images } : item.preview ? { image: item.preview } : {}),
...(video ? { video } : {}),
keywords: [item.category, ...item.tags].filter(Boolean).join(', '), isAccessibleForFree: true, publisher,
};
const genre = t('site.meta.item.genre', { kind: item.kind, apps: appNames(item) });
const thing = item.kind === 'plugin'
? { ...common, '@type': 'SoftwareApplication', ...(images.length ? { screenshot: images } : {}), softwareVersion: item.version, applicationCategory: 'BrowserApplication', applicationSubCategory: genre, operatingSystem: 'Windows', fileSize: `${item.size} B`, downloadUrl: item.url, offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } }
: { ...common, '@type': 'CreativeWork', version: item.version, genre, encodingFormat: 'application/json', contentUrl: item.url };
pages.push({
path, title: itemTitle(item, t), description, ogTitle: t('site.meta.item.ogTitle', { name: item.name, kind: item.kind, sites: sites(item) }),
...(cover
? { image: cover.src, imageAlt: cover.alt, imageSize: { width: cover.width, height: cover.height } }
: { image: own, imageAlt: t('site.meta.item.imageAlt', { name: item.name, kind: item.kind, apps: appNames(item) }) }),
lastmod: catalogDate,
jsonLd: [thing, crumbs([['wayBetter', '/'], [store, '/store/'], [item.name, path]])],
});
}
// The area plugins: built into wayBetter for X, one page each (/store/areas/<id>/), nothing to install.
const xApp = { '@type': 'SoftwareApplication', name: 'wayBetter for X', url: absolute(at('/x/')) };
for (const area of options.areas ?? []) {
const path = `/store/areas/${area.id}/`;
// A short sentence (a compact script) is filled up with the names of the area's features.
const said = t('site.meta.area.description', { description: area.description, count: area.features.length });
const description = clip([...said].length >= DESCRIPTION_MIN ? said : `${said} ${area.features.map(feature => feature.label).join(' · ')}`, DESCRIPTION_MAX);
const cover = options.media?.get(path)?.cover;
pages.push({
path, title: clip(t('site.meta.area.title', { name: area.name }), TITLE_MAX), description, ogTitle: area.name,
...(cover
? { image: cover.src, imageAlt: cover.alt, imageSize: { width: cover.width, height: cover.height } }
: { image: STORE_IMAGE, imageAlt: t('site.meta.area.imageAlt', { name: area.name }) }),
lastmod: catalogDate,
jsonLd: [
{
'@context': 'https://schema.org', '@type': 'SoftwareApplication', name: area.name, description, url: absolute(at(path)),
applicationCategory: 'BrowserApplication', operatingSystem: 'Windows, macOS, Linux', isPartOf: xApp, publisher, isAccessibleForFree: true,
keywords: [area.category, ...area.tags].join(', '), ...(area.license ? { license: area.license } : {}),
featureList: area.features.map(feature => feature.label), offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
},
crumbs([['wayBetter', '/'], [store, '/store/'], [area.name, path]]),
],
});
}
return pages.map(page => ({ ...page, path: at(page.path), base: page.path, lang, alternates: alternates(page.path), lastmod: date(page.path, page.lastmod) }));
}
/** The 404 page: real meta, never indexed, never in the sitemap. */
export function notFoundPage(lang: string = SOURCE_LANGUAGE): PageMeta {
const t = translator(lang);
return {
path: langPath(lang, '/404.html'), base: '/404.html', lang, title: t('site.meta.notFound.title'), description: t('site.meta.notFound.description'),
image: '/og/home.png', imageAlt: 'wayBetter', jsonLd: [], noindex: true, alternates: [],
};
}
const attr = (value: string): string => value.replace(/[&<>"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]!);
/** The generated part of a page's <head> (replaces `<!-- seo -->`). */
export function headHtml(page: PageMeta): string {
const url = absolute(page.path);
const image = absolute(page.image);
const social = page.ogTitle ?? page.title;
const english = page.alternates.find(other => other.lang === SOURCE_LANGUAGE);
const lines = [
`<title>${attr(page.title)}</title>`,
`<meta name="description" content="${attr(page.description)}" />`,
page.noindex ? '<meta name="robots" content="noindex" />' : `<link rel="canonical" href="${attr(url)}" />`,
// Every language of this page, each pointing at all the others and at itself; x-default is English.
...page.alternates.map(other => `<link rel="alternate" hreflang="${attr(other.lang)}" href="${attr(absolute(other.path))}" />`),
...(english ? [`<link rel="alternate" hreflang="x-default" href="${attr(absolute(english.path))}" />`] : []),
'<meta property="og:site_name" content="wayBetter" />',
'<meta property="og:type" content="website" />',
...(page.alternates.length ? [
`<meta property="og:locale" content="${OG_LOCALE[page.lang] ?? page.lang}" />`,
...page.alternates.filter(other => other.lang !== page.lang).map(other => `<meta property="og:locale:alternate" content="${OG_LOCALE[other.lang] ?? other.lang}" />`),
] : []),
`<meta property="og:title" content="${attr(social)}" />`,
`<meta property="og:description" content="${attr(clip(page.description, 200))}" />`,
...(page.noindex ? [] : [`<meta property="og:url" content="${attr(url)}" />`]),
`<meta property="og:image" content="${attr(image)}" />`,
`<meta property="og:image:width" content="${page.imageSize?.width ?? 1200}" />`,
`<meta property="og:image:height" content="${page.imageSize?.height ?? 630}" />`,
`<meta property="og:image:alt" content="${attr(page.imageAlt)}" />`,
'<meta name="twitter:card" content="summary_large_image" />',
`<meta name="twitter:title" content="${attr(social)}" />`,
`<meta name="twitter:description" content="${attr(clip(page.description, 200))}" />`,
`<meta name="twitter:image" content="${attr(image)}" />`,
`<meta name="twitter:image:alt" content="${attr(page.imageAlt)}" />`,
`<meta name="twitter:creator" content="${attr(AUTHOR_HANDLE)}" />`,
// "<" never appears raw inside the script, so no text can close it.
...page.jsonLd.map(data => `<script type="application/ld+json">${JSON.stringify(data).replace(/</g, '\\u003c')}</script>`),
];
return lines.join('\n ');
}
const xml = (value: string): string => value.replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[ch]!);
/** One sitemap for every language: each address names its translations (xhtml:link), x-default included. */
export function sitemapXml(pages: PageMeta[]): string {
const listed = pages.filter(page => !page.noindex);
const translated = listed.some(page => page.alternates.length);
const urls = listed.map(page => {
const english = page.alternates.find(other => other.lang === SOURCE_LANGUAGE);
const links = [
...page.alternates.map(other => `<xhtml:link rel="alternate" hreflang="${xml(other.lang)}" href="${xml(absolute(other.path))}"/>`),
...(english ? [`<xhtml:link rel="alternate" hreflang="x-default" href="${xml(absolute(english.path))}"/>`] : []),
].join('');
return ` <url><loc>${xml(absolute(page.path))}</loc>${page.lastmod ? `<lastmod>${xml(page.lastmod)}</lastmod>` : ''}${links}</url>`;
});
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${translated ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : ''}>\n${urls.join('\n')}\n</urlset>\n`;
}
/** Everything may be crawled; the API (/v1/, /health) has nothing to index. */
export const robotsTxt = (): string => `User-agent: *\nAllow: /\nDisallow: /v1/\nDisallow: /health\n\nSitemap: ${absolute('/sitemap.xml')}\n`;