// What search engines and link previews see, generated for every page from one place: , // 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' | 'icons'; 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', icons: 'icons' } as const; export const KIND_LABEL = { theme: 'Theme', plugin: 'Plugin', strings: 'String pack', icons: 'Icon 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(signed: Catalog, options: PageOptions = {}): PageMeta[] { // Built-in items (area plugins) are in the catalog for the app's Store only; the site has its own area pages. const catalog: Catalog = { ...signed, items: signed.items.filter(item => (item.kind as string) !== 'builtin') }; 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)}`, ``, page.noindex ? '' : ``, // Every language of this page, each pointing at all the others and at itself; x-default is English. ...page.alternates.map(other => ``), ...(english ? [``] : []), '', '', ...(page.alternates.length ? [ ``, ...page.alternates.filter(other => other.lang !== page.lang).map(other => ``), ] : []), ``, ``, ...(page.noindex ? [] : [``]), ``, ``, ``, ``, '', ``, ``, ``, ``, ``, // "<" never appears raw inside the script, so no text can close it. ...page.jsonLd.map(data => ``), ]; 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 => ``), ...(english ? [``] : []), ].join(''); return ` ${xml(absolute(page.path))}${page.lastmod ? `${xml(page.lastmod)}` : ''}${links}`; }); return `\n\n${urls.join('\n')}\n\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`;