328 lines
20 KiB
TypeScript
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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`;
|