// Build-time pages from the wayBetter Store catalog (public/store/catalog.json, the same signed // file the apps read; format: waybetter/docs/STORE.md) and the site's languages (locales/, i18n.ts). // Everything public is generated here, so a new item in the catalog needs no hand-written HTML, meta // tags or sitemap entry: // /store/ the storefront: every app's items, search and filters (src/store.ts) // /store/themes// one page per item (store/_item.html is the frame; pages exist only // /store/plugins// in the build, and in the dev server through a middleware) // /store/areas// one page per area plugin of wayBetter for X (built into the app, // not in the catalog: nothing to install; areas.ts) // /themes/, /plugins/ wayBetter for X's galleries // //… every page again in each language that has a catalog (English is // the root); these pages too exist only in the build // every page's title, description, canonical, hreflang, Open Graph, Twitter card, JSON-LD (seo.ts) // /sitemap.xml, /robots.txt // Cards are in the HTML, so the pages work without JavaScript. "Install in wayBetter" is a // waybetter://install link: the app opens its install screen (name, author, source, sha256, // permissions, code) and installs nothing until the visitor confirms there. // // A Store item's own words (name, description, tags, picture captions) are its author's and stay // English on every page; on a translated page they are marked lang="en". import { fileURLToPath } from 'node:url'; import { compilePack, coverage as packCoverage } from '../../waybetter/packages/runtime/src/stringPacks.ts'; import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { posix, resolve } from 'node:path'; import type { Plugin } from 'vite'; import { localizeHtml, messageHtml, stripMarks } from './i18n-html.ts'; import { PAGE_SOURCES, clientMessages, languages, resetCatalogs, translator, type Language, type Translate } from './i18n.ts'; import { KIND_FOLDER, KIND_LABEL, absolute, headHtml, itemPath, itemSlugs, notFoundPage, robotsTxt, sitePages, sitemapXml, type Catalog, type PageMeta, type StoreItem } from './seo.ts'; import { APPS, FORGE_HOST, FRAMEWORK_REPO_URL, REPO_PUBLIC, REPO_URL, SOURCE_LANGUAGE, appInfo, repoFile, repoLinks } from './site.config.ts'; import { hasMedia, pictures, readItemMedia, type ItemMedia } from './store-media.ts'; import { AREAS_SINCE, areaFolder, areaItems, areaPath, type AreaItem } from './areas.ts'; type Item = StoreItem; const STORE_BASE = 'https://waybetter.fyi/store/'; /** This site's app in the shared catalog (the /themes/ and /plugins/ galleries are its items). */ export const APP = 'x'; const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, ch => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]!); /** Path of a store file below the site root (served from public/store by the same site). */ const local = (url: string) => (url.startsWith(STORE_BASE) ? `store/${url.slice(STORE_BASE.length)}` : url); /** A store file as a page links it (`root` is the way to the site root); a file on another host keeps its URL. */ const href = (root: string, url: string) => (url.startsWith(STORE_BASE) ? `${root}${local(url)}` : url); /** A store picture for an : a root path, which Vite knows as a public file and rewrites for the page. */ const picture = (url: string | undefined) => (url?.startsWith(STORE_BASE) ? `/${local(url)}` : url ?? ''); export const installLink = (item: Pick) => `waybetter://install?kind=${item.kind}&url=${encodeURIComponent(item.url)}`; const fileName = (item: Item) => (item.kind === 'plugin' ? item.file! : decodeURIComponent(new URL(item.url).pathname.split('/').pop() || `${item.id}.json`)); /** What each permission is called and means, in plain words (docs/PLUGINS.md → Permissions): `site.gen.permission..label|plain`. */ const PERMISSIONS = new Set(['storage', 'ui', 'clipboard', 'account', 'users', 'commands', 'network', 'network:none']); const permissionKey = (p: string) => `site.gen.permission.${p === 'network:none' ? 'networkNone' : p}`; const CATEGORIES = new Set(['reading', 'writing', 'filters', 'focus', 'tools', 'dark', 'light', 'language', 'icons']); /** * A description for display. A plugin header keeps at most 300 characters, so a long one reaches * the catalog cut mid-word: end it at the last whole word instead. */ const tidy = (text: string) => (/[.!?)…"”]$/.test(text.trim()) ? text.trim() : `${text.trim().replace(/\s+\S*$/, '')}…`); const kb = (bytes: number) => (bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`); const permissionTone = (p: string) => (p === 'network:none' ? 'is-ok' : p === 'account' || p === 'users' || p === 'network' ? 'is-warn' : ''); type Site = { catalog: Catalog; slugs: Map; dates: Map; featured: Item[]; media: Map; areaMedia: Map }; /** * What a page is written with: its language and words, the way back to that language's home * (`rel`: "../", "../../../"; links between pages stay inside the language) and the way to the site * root (`root`), where the files every language shares are (downloads, pictures). */ type Page = { site: Site; lang: string; dir: 'ltr' | 'rtl'; t: Translate; rel: string; root: string }; const kindLabel = (page: Page, kind: Item['kind']) => page.t(`site.gen.kind.${kind}`); const permissionLabel = (page: Page, p: string) => (PERMISSIONS.has(p) ? page.t(`${permissionKey(p)}.label`) : p); /** A string pack's coverage of its site's strings (the pack as published, against the app's inventory). */ const coverageCache = new Map(); function coverageOf(item: Item): { translated: string; translatable: string } | null { if (item.kind !== 'strings') return null; if (coverageCache.has(item.url)) return coverageCache.get(item.url)!; let value: { translated: string; translatable: string } | null = null; try { const here = fileURLToPath(new URL('.', import.meta.url)); const pack = JSON.parse(readFileSync(resolve(here, 'public/store', new URL(item.url).pathname.replace(/^\/store\//, '')), 'utf8')); const inventory = JSON.parse(readFileSync(resolve(here, '../desktop/strings/x.web.inventory.json'), 'utf8')); const numbers = packCoverage(inventory, compilePack(pack, inventory)).all!; value = { translated: numbers.translated.toLocaleString('en-US'), translatable: numbers.translatable.toLocaleString('en-US') }; } catch { value = null; } coverageCache.set(item.url, value); return value; } const category = (page: Page, id: string) => (CATEGORIES.has(id) ? page.t(`site.gen.category.${id}`) : id ? id[0]!.toUpperCase() + id.slice(1) : page.t('site.gen.category.other')); /** Marks an item's own (English) words on a translated page. */ const own = (page: Page) => (page.lang === SOURCE_LANGUAGE ? '' : page.dir === 'rtl' ? ' lang="en" dir="ltr"' : ' lang="en"'); function permissionChips(page: Page, permissions: string[] | null | undefined): string { if (permissions == null) return `
  • ${esc(page.t('site.gen.permission.legacy'))}
  • `; if (!permissions.length) return `
  • ${esc(page.t('site.gen.permission.none'))}
  • `; return permissions.map(p => `
  • ${esc(permissionLabel(page, p))}
  • `).join(''); } const swatches = (item: Item) => (item.colors ? `` : ''); /** Everything the "app is not installed" panel needs rides on the install link itself (src/install.ts). */ function installAttrs(page: Page, item: Item): string { const app = appInfo(item.apps[0] ?? APP); return `href="${esc(installLink(item))}" data-install data-install-name="${esc(item.name)}" data-install-file="${esc(href(page.root, item.url))}" data-install-filename="${esc(fileName(item))}"` + ` data-install-app="${esc(app.name)}"${app.page ? ` data-install-app-page="${esc(page.rel + app.page)}"` : ''}${app.oneClickSince ? ` data-install-since="${esc(app.oneClickSince)}"` : ''}`; } /** * An item's own picture or video (store-media.ts), which the build copies out of the Store's source: * `vite-ignore` keeps Vite from looking for the file among the site's own assets. */ const mediaUrl = (page: Page, src: string) => `${page.root}${src.slice(1)}`; const pagePath = (site: Site, item: Item) => itemPath(item, site.slugs.get(item)!); const pageHref = (page: Page, item: Item) => `${page.rel}${pagePath(page.site, item).slice(1)}`; function card(page: Page, item: Item, heading: 'h2' | 'h3', extraClass = ''): string { const { site, t } = page; const link = pageHref(page, item); // Found by its own words, by the English labels and by this language's. const searchable = [...new Set([ item.name, item.description, item.author, KIND_LABEL[item.kind], kindLabel(page, item.kind), item.category, category(page, item.category), ...item.tags, ...item.apps.map(app => appInfo(app).name), ...(item.permissions ?? []).flatMap(p => [p, permissionLabel(page, p)]), ])].join(' ').toLowerCase(); // The item's own cover when it has one, else the preview the Store drew for it. const cover = site.media.get(item)?.cover; const shot = cover ? `` : ``; return ``; } // ---- /themes/ and /plugins/ (one app's items of one kind) ----------------------------------- export function galleryHtml(page: Page, kind: 'theme' | 'plugin', app = APP): string { const { site, t } = page; const items = site.catalog.items.filter(item => item.kind === kind && item.apps.includes(app)); const categories = [...new Set(items.map(item => item.category))].sort(); const filters = `
    ${categories.map(c => ``).join('')}
    `; return `${filters}\n
    ${items.map(item => card(page, item, 'h2')).join('\n')}
    \n`; } // ---- /store/ -------------------------------------------------------------------------------- function storeBar(page: Page): string { const { site, t } = page; const items = site.catalog.items; const count = (test: (item: Item) => boolean) => items.filter(test).length; const apps = [...new Set(items.flatMap(item => item.apps))].sort(); const categories = (kind: Item['kind']) => [...new Set(items.filter(item => item.kind === kind).map(item => item.category))].sort(); const chip = (group: string, value: string, label: string, n: number | null, extra = '') => ``; return ``; } function shelf(page: Page, id: string, title: string, note: string, items: Item[]): string { if (!items.length) return ''; return `

    ${esc(title)}

    ${esc(note)}

    ${items.map(item => card(page, item, 'h3', ' gal-card-feature')).join('\n')}
    `; } /** * "Latest": the items whose files changed most recently (git). Shown only when the dates tell * something, i.e. not when every item carries the same one (a first import, a shallow checkout). */ export function latestItems(items: Item[], dateOf: (item: Item) => string | undefined, limit = 4): Item[] { const dated = items.filter(item => dateOf(item)); if (dated.length < 2 || new Set(dated.map(dateOf)).size < 2) return []; return [...dated].sort((a, b) => dateOf(b)!.localeCompare(dateOf(a)!)).slice(0, limit); } const dateLabel = (date: string, lang: string) => new Date(`${date}T00:00:00Z`).toLocaleDateString(lang === SOURCE_LANGUAGE ? 'en-US' : lang, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }); // ---- /store/// -------------------------------------------------------------------- /** * The top of an item page: its own cover, screenshots and video as a gallery (src/item.ts adds the * thumbnails' switching, the lightbox, swipe and the play button), or, for an item without media, * the preview the Store drew for it. Without JavaScript every picture is shown, one under the other, * each a link to its file, and the video has its own controls. */ function mediaHtml(page: Page, item: Item): string { const { site, t } = page; const media = site.media.get(item); if (!hasMedia(media)) { return `
    ${esc(t('site.gen.media.previewAlt', { name: item.name, kind: item.kind }))}
    `; } const shots = pictures(media); const video = media.video; const slides = shots.map((shot, index) => `
    ${esc(shot.alt)} ${shot.caption ? ` ${esc(shot.caption)}` : ''}
    `); if (video) { const label = video.caption ?? t('site.gen.media.inUse', { name: item.name }); slides.push(`
    ${video.caption ? ` ${esc(video.caption)}` : ''}
    `); } const thumbs = slides.length < 2 ? '' : `
    ${[ ...shots.map((shot, index) => ``), ...(video ? [``] : []), ].join('')}
    `; return `
    ${slides.join('\n ')}
    ${thumbs}
    `; } function itemHtml(page: Page, item: Item): string { const { site, t, rel, root, lang } = page; const th = (key: string, values: Record = {}, slots: Record = {}) => messageHtml(t, key, values, slots); const bold = { b1: '', b1End: '' }; const app = appInfo(item.apps[0] ?? APP); const file = fileName(item); const fileHref = href(root, item.url); const address = absolute(`${lang === SOURCE_LANGUAGE ? '' : `/${lang}`}${pagePath(site, item)}`); const updated = site.dates.get(local(item.url)); const facts: [string, string][] = [ [t('site.gen.item.version'), esc(item.version)], [t('site.gen.item.author'), item.author ? esc(item.author) : `${esc(t('site.gen.item.notStated'))}`], [t('site.gen.item.category'), `${esc(category(page, item.category))}`], [t('site.gen.item.for'), item.apps.map(id => { const info = appInfo(id); return info.page ? `${esc(info.name)}` : esc(info.name); }).join(', ')], [t('site.gen.item.file'), `${esc(file)}`], [t('site.gen.item.size'), `${kb(item.size)} ${esc(t('site.gen.item.bytes', { count: item.size.toLocaleString('en-US') }))}`], ...(updated ? [[t('site.gen.item.updated'), ``] as [string, string]] : []), ...(item.kind === 'icons' ? [[t('site.gen.item.dataOnly'), esc(t('site.gen.item.dataOnlyIcons'))] as [string, string]] : []), ...(item.kind === 'strings' ? [ [t('site.gen.item.language'), `${esc(item.language ?? '')}`], ...(coverageOf(item) ? [[t('site.gen.item.coverage'), esc(t('site.gen.item.coverageValue', coverageOf(item)!))] as [string, string]] : []), [t('site.gen.item.dataOnly'), esc(t('site.gen.item.dataOnlyValue'))], ] as [string, string][] : []), ...(item.license ? [[t('site.gen.item.license'), `${esc(item.license)}`] as [string, string]] : []), ...(item.credits ? [[t('site.gen.item.credits'), `${esc(item.credits)}`] as [string, string]] : []), ]; const permissions = item.kind !== 'plugin' ? '' : `

    ${esc(t('site.gen.item.asks'))}

      ${item.permissions == null ? `
    • ${esc(t('site.gen.permission.legacy'))}${esc(t('site.gen.item.legacyPlain'))}
    • ` : !item.permissions.length ? `
    • ${esc(t('site.gen.permission.none'))}${esc(t('site.gen.item.nonePlain'))}
    • ` : item.permissions.map(p => `
    • ${esc(permissionLabel(page, p))}${esc(PERMISSIONS.has(p) ? t(`${permissionKey(p)}.plain`) : t('site.gen.item.declares', { permission: p }))}
    • `).join('')}

    ${th('site.gen.item.permissionsFine')}

    `; const palette = !item.colors ? '' : `

    ${esc(t('site.gen.item.palette'))}

      ${([['bg', item.colors.bg], ['surface', item.colors.surface ?? item.colors.bg], ['text', item.colors.text], ['accent', item.colors.accent]] as const).map(([name, color]) => `
    • ${esc(t(`site.gen.item.color.${name}`))}${esc(color)}
    • `).join('')}

    ${th('site.gen.item.paletteFine')}

    `; const related = site.catalog.items .filter(other => other !== item && other.kind === item.kind && other.apps.some(id => item.apps.includes(id))) .sort((a, b) => Number(b.category === item.category) - Number(a.category === item.category)) .slice(0, 3); const how = { a1: '', a1End: '' }; return `
    ${mediaHtml(page, item)}

    ${th('site.gen.item.kicker', { kind: kindLabel(page, item.kind), category: category(page, item.category), sites: item.apps.map(id => appInfo(id).site).join(', ') })}

    ${esc(item.name)}

    ${item.author ? `${esc(t('site.gen.card.by', { author: item.author }))} · ` : ''}v${esc(item.version)} · ${kb(item.size)}

    ${esc(tidy(item.description))}

    ${item.kind === 'plugin' ? `
      ${permissionChips(page, item.permissions)}
    ` : swatches(item)}

    ${app.oneClickSince ? th('site.gen.item.note', { app: app.name, version: app.oneClickSince }, how) : th('site.gen.item.noteAny', { app: app.name }, how)}

    ${item.tags.length ? `` : ''}
    ${permissions}${palette}

    ${esc(t(item.kind === 'plugin' ? 'site.gen.item.code' : item.kind === 'strings' || item.kind === 'icons' ? 'site.gen.item.packFile' : 'site.gen.item.themeFile'))}

    ${esc(t('site.gen.item.read', { file }))}${kb(item.size)} · ${esc(t('site.gen.item.exactBytes'))}
    ${esc(file)}${esc(t('site.gen.item.raw'))}
    ${th('site.gen.item.openRaw', {}, { a1: ``, a1End: '' })}

    ${esc(t('site.gen.item.how.title'))}

    1. ${th('site.gen.item.how.link', { kind: item.kind }, { ...bold, code1: 'waybetter://' })}
    2. ${th('site.gen.item.how.check', {}, bold)}
    3. ${th(item.kind === 'plugin' ? 'site.gen.item.how.confirmPlugin' : item.kind === 'strings' ? 'site.gen.item.how.confirmStrings' : item.kind === 'icons' ? 'site.gen.item.how.confirmIcons' : 'site.gen.item.how.confirmTheme', {}, bold)}

    ${th(item.kind === 'plugin' ? 'site.gen.item.how.finePlugin' : item.kind === 'strings' ? 'site.gen.item.how.fineStrings' : item.kind === 'icons' ? 'site.gen.item.how.fineIcons' : 'site.gen.item.how.fineTheme')} ${th('site.gen.item.how.vouched')}

    ${esc(t('site.gen.item.manual.title'))}

    ${th(app.page && app.oneClickSince ? 'site.gen.item.manual.text' : 'site.gen.item.manual.textNoPage', { app: app.name, version: app.oneClickSince ?? '', file }, { a1: ``, a1End: '', a2: ``, a2End: '' })}

    ${related.length ? `

    ${esc(t(item.kind === 'theme' ? 'site.gen.item.moreThemes' : item.kind === 'strings' ? 'site.gen.item.moreStrings' : item.kind === 'icons' ? 'site.gen.item.moreIcons' : 'site.gen.item.morePlugins'))}

    ${esc(t('site.gen.item.seeAll', { count: site.catalog.items.filter(other => other.kind === item.kind).length }))}

    ${related.map(other => card(page, other, 'h3')).join('\n')}
    ` : ''}`; } // ---- Area plugins: built into wayBetter for X (areas.ts) -------------------------------------- const areaHref = (page: Page, area: AreaItem) => `${page.rel}${areaPath(area.id).slice(1)}`; function areaCard(page: Page, area: AreaItem, heading: 'h2' | 'h3'): string { const { site, t } = page; const link = areaHref(page, area); const cover = site.areaMedia.get(area.id)?.cover; const searchable = [...new Set([area.name, area.description, area.category, category(page, area.category), ...area.tags, ...area.features.map(feature => feature.label)])].join(' ').toLowerCase(); return ``; } /** The shelf of area plugins on /store/ and /plugins/: built in, so counted apart from the catalog's items. */ function areaShelf(page: Page, heading: 'h2' | 'h3' = 'h3'): string { const areas = areaItems(page.lang); return `

    ${esc(page.t('site.gen.areas.title', { app: appInfo(APP).name }))}

    ${esc(page.t('site.gen.areas.note', { count: areas.length }))}

    ${areas.map(area => areaCard(page, area, heading)).join('\n')}
    `; } function areaHtml(page: Page, area: AreaItem): string { const { site, t, rel, lang } = page; const th = (key: string, values: Record = {}, slots: Record = {}) => messageHtml(t, key, values, slots); const app = appInfo(APP); const address = absolute(`${lang === SOURCE_LANGUAGE ? '' : `/${lang}`}${areaPath(area.id)}`); const media = site.areaMedia.get(area.id); const facts: [string, string][] = [ [t('site.gen.item.version'), esc(t('site.gen.area.partOf', { app: app.name, version: AREAS_SINCE }))], [t('site.gen.item.category'), `${esc(category(page, area.category))}`], [t('site.gen.item.for'), app.page ? `${esc(app.name)}` : esc(app.name)], [t('site.gen.area.features'), esc(t('site.gen.area.featureCount', { count: area.features.length }))], ...(area.license ? [[t('site.gen.item.license'), `${esc(area.license)}`] as [string, string]] : []), ...(area.credits ? [[t('site.gen.item.credits'), `${esc(area.credits)}`] as [string, string]] : []), ]; // Pictures only when the area has some; no drawn preview for something that is not a file. const pseudo = { name: area.name, preview: '' } as unknown as Item; if (media) site.media.set(pseudo, media); const top = media ? mediaHtml(page, pseudo) : ''; if (media) site.media.delete(pseudo); const others = areaItems(lang).filter(other => other.id !== area.id).slice(0, 3); return `
    ${top}

    ${esc(t('site.gen.area.kicker', { app: app.name }))}

    ${esc(area.name)}

    ${esc(area.description)}

    ${esc(t('site.gen.area.note', { app: app.name, version: AREAS_SINCE }))}

    ${area.tags.length ? `` : ''}

    ${esc(t('site.gen.area.whatItDoes'))}

      ${area.features.map(feature => `
    • ${esc(feature.label)}${feature.description ? `${esc(feature.description)}` : ''}
    • `).join('')}

    ${esc(t('site.gen.area.how.title'))}

    1. ${th('site.gen.area.how.plugins', { name: area.name }, { b1: '', b1End: '' })}
    2. ${th('site.gen.area.how.mode', {}, { b1: '', b1End: '' })}

    ${esc(t('site.gen.area.how.nothing'))}

    ${others.length ? `

    ${esc(t('site.gen.areas.more'))}

    ${esc(t('site.gen.item.seeAll', { count: areaItems(lang).length }))}

    ${others.map(other => areaCard(page, other, 'h3')).join('\n')}
    ` : ''}`; } // ---- Languages -------------------------------------------------------------------------------- const GLOBE = ''; /** * The language menu (header and footer): a link to this same page in every language, each written * in its own name. Plain links, so it works without JavaScript; the head script (langScript) * remembers the choice and keeps the query and the hash. */ function langMenu(page: Page, all: Language[], base: string, where: 'top' | 'foot'): string { if (all.length < 2) return ''; const current = all.find(language => language.code === page.lang)!; // The 404 page answers for any address: its links lead to each language's home (the script makes them this address). const target = base === '/404.html' ? '' : base.slice(1); const links = all.map(language => `${esc(language.name)}`).join(''); const label = page.t('site.lang.label'); return `
    ${GLOBE}
    ${links}
    `; } /** * The language script, in every page's . It never runs for a visitor without JavaScript, and * the English pages are complete without it. * - A click in the language menu is remembered (localStorage "wb.lang"), and keeps ?query and #hash. * - First visit, on an English page only: when nothing is remembered and the browser's first * language the site speaks is not English, go once to this same page in that language and * remember it. After that, and after any choice in the menu, nothing redirects again. Crawlers * are left alone, and so is a browser that cannot remember (it would be redirected every time). */ function langScript(page: Page, all: Language[]): string { if (all.length < 2) return ''; const codes = JSON.stringify(all.map(language => language.code)); const english = page.lang === SOURCE_LANGUAGE; const source = `(function(){var L=${codes},K="wb.lang",d=document,l=location,cur=${JSON.stringify(page.lang)},` + `r=new URL(${JSON.stringify(page.root)},d.baseURI).pathname,p=l.pathname.slice(r.length${english ? '' : '+cur.length+1'}),s;` + 'try{s=localStorage.getItem(K)}catch(e){}' + 'function to(c){return r+(c==="en"?"":c+"/")+p+l.search+l.hash}' + 'd.addEventListener("click",function(e){var a=e.target&&e.target.closest&&e.target.closest("a[data-lang]");if(!a)return;try{localStorage.setItem(K,a.getAttribute("data-lang"))}catch(e){}a.href=to(a.getAttribute("data-lang"))},true);' // The menu closes on a click outside it and on Escape. + 'function shut(t){var o=d.querySelectorAll("details[data-lang-menu][open]"),i;for(i=0;i=0)m=c}' + 'if(!m||m==="en")return;try{localStorage.setItem(K,m)}catch(e){return}l.replace(to(m))' : '') + '})();'; return ``; } // ---- The plugin ----------------------------------------------------------------------------- const SOURCES: Record = { '/': 'index.html', '/x/': 'x/index.html', '/store/': 'store/index.html', '/store/submit/': 'store/submit/index.html', '/themes/': 'themes/index.html', '/plugins/': 'plugins/index.html' }; const NOT_FOUND = '/404.html'; /** Last commit date (YYYY-MM-DD) of every file under the site's folder, keyed by its path in it. Empty without git. */ function gitDates(root: string): Map { const dates = new Map(); try { const log = execFileSync('git', ['log', '--format=@%cs', '--name-only', '--relative', '--', '.'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }); let date = ''; for (const line of log.split(/\r?\n/)) { if (line.startsWith('@')) date = line.slice(1); else if (line && !dates.has(line)) dates.set(line, date); } } catch { /* not a git checkout: the sitemap falls back to the catalog and build dates */ } return dates; } /** Path back to the site root from a page ("/store/themes/nord/" → "../../../"). */ const relOf = (path: string) => '../'.repeat(path.split('/').length - 2) || './'; export function storePages(root: string): Plugin { // catalog.v3.json is the full list (every kind, format 3), catalog.json what every app knows (format 2): the same items, so the pages read the fuller one. const catalogPath = resolve(root, existsSync(resolve(root, 'public/store/catalog.v3.json')) ? 'public/store/catalog.v3.json' : 'public/store/catalog.json'); const featuredPath = resolve(root, '../../store/featured.json'); const itemFrame = resolve(root, 'store/_item.html'); const localesDir = resolve(root, 'locales'); /** The Store's source folders (store//s//), where an item's media lives. */ const sourceRoot = resolve(root, '../../store'); /** mediaFiles: path in the build ("store-media/x/plugins//cover.webp") → the file in the source. */ type Loaded = { site: Site; all: Language[]; pages: Map; sitemap: PageMeta[]; mediaFiles: Map; items: Map; areas: Set }; let cache: Loaded | null = null; const MEDIA_TYPES: Record = { png: 'image/png', webp: 'image/webp', jpg: 'image/jpeg', jpeg: 'image/jpeg', mp4: 'video/mp4', webm: 'video/webm' }; const load = (): Loaded => { if (cache) return cache; const signed = JSON.parse(readFileSync(catalogPath, 'utf8')) as Omit & { items: Array }; if (signed.format !== 2 && signed.format !== 3) throw new Error('store catalog: unsupported format'); // Built-in items (area plugins) are listed by the app's Store with their switch; the site has its own area pages (areas.ts), so they are not pages here. const catalog: Catalog = { ...signed, items: signed.items.filter((item): item is Item => item.kind !== 'builtin') }; const slugs = itemSlugs(catalog.items); const files = gitDates(root); // Store files by their path below the site root ("store/themes/nord.json"). const dates = new Map([...files].filter(([path]) => path.startsWith('public/')).map(([path, date]) => [path.slice('public/'.length), date])); let featured: Item[] = []; if (existsSync(featuredPath)) { const keys = JSON.parse(readFileSync(featuredPath, 'utf8')) as string[]; featured = keys.map(key => catalog.items.find(item => item.apps.some(app => `${app}/${item.kind}/${item.id}` === key))).filter((item): item is Item => Boolean(item)); } // Each item's own cover, screenshots and video, from its folder in the Store's source. They are // not in the signed catalog: the site shows them, the apps never fetch them. const media = new Map(); const mediaByPath = new Map(); const mediaFiles = new Map(); const problems: string[] = []; for (const item of catalog.items) { for (const app of item.apps) { const folder = `store/${app}/${KIND_FOLDER[item.kind]}/${item.id}`; const read = readItemMedia(resolve(sourceRoot, app, KIND_FOLDER[item.kind], item.id), `/store-media/${app}/${KIND_FOLDER[item.kind]}/${item.id}/`, folder); problems.push(...read.problems); if (!hasMedia(read.media)) continue; media.set(item, read.media); mediaByPath.set(itemPath(item, slugs.get(item)!), read.media); for (const file of read.files) mediaFiles.set(file.to, file.from); break; } } if (problems.length) throw new Error(`the Store's media has problems (bun run check:store explains each):\n ${problems.join('\n ')}`); // The area plugins' own pictures (store/x/areas//), the same way. const areaMedia = new Map(); for (const area of areaItems()) { const folder = `store/x/areas/${area.id}`; const read = readItemMedia(areaFolder(area.id), `/store-media/x/areas/${area.id}/`, folder); problems.push(...read.problems); if (!hasMedia(read.media)) continue; areaMedia.set(area.id, read.media); mediaByPath.set(areaPath(area.id), read.media); for (const file of read.files) mediaFiles.set(file.to, file.from); } if (problems.length) throw new Error(`the Store's media has problems (bun run check:store explains each):\n ${problems.join('\n ')}`); const site: Site = { catalog, slugs, dates, featured, media, areaMedia }; const lastmod = new Map(); for (const [path, source] of Object.entries(SOURCES)) { const date = files.get(source); if (date) lastmod.set(path, date); } const catalogDate = catalog.generatedAt.slice(0, 10); for (const [path, date] of [...lastmod]) if (path !== '/' && path !== '/x/' && path !== '/store/submit/' && date < catalogDate) lastmod.set(path, catalogDate); const itemImages = new Set(); const items = new Map(); for (const item of catalog.items) { const path = itemPath(item, slugs.get(item)!); items.set(path, item); const date = dates.get(local(item.url)); if (date) lastmod.set(path, date); if (existsSync(resolve(root, `public/og/items/${item.kind}-${slugs.get(item)}.jpg`))) itemImages.add(path); } // Every page once per language: a translated page is as new as its English page or its catalog. const all = languages(root); const codes = all.map(language => language.code); const buildDate = new Date().toISOString().slice(0, 10); const sitemap: PageMeta[] = []; const pages = new Map(); for (const code of codes) { const translated = code === SOURCE_LANGUAGE ? undefined : files.get(`locales/${code}.json`); const dated = new Map([...lastmod].map(([path, date]) => [path, translated && translated > date ? translated : date])); const list = sitePages(catalog, { lastmod: dated, itemImages, media: mediaByPath, buildDate, lang: code, languages: codes, areas: areaItems(code) }); sitemap.push(...list); for (const page of [...list, notFoundPage(code)]) pages.set(page.path, page); } cache = { site, all, pages, sitemap, mediaFiles, items, areas: new Set(areaItems().map(area => areaPath(area.id))) }; return cache; }; /** "/tr/store/" → the language and the page's own path; English paths have no prefix. */ const split = (path: string): { lang: string; base: string } => { const first = path.split('/')[1] ?? ''; const language = first !== SOURCE_LANGUAGE && load().all.some(entry => entry.code === first); return language ? { lang: first, base: path.slice(first.length + 1) } : { lang: SOURCE_LANGUAGE, base: path }; }; /** The HTML file a page is written from, or null when the path is not a page. */ const sourceOf = (base: string): string | null => (load().items.has(base) || load().areas.has(base) ? itemFrame : SOURCES[base] ? resolve(root, SOURCES[base]!) : base === NOT_FOUND ? resolve(root, '404.html') : null); /** Pages that have no file of their own: item pages, and every page of every other language. */ const isVirtual = (path: string): boolean => { const { lang, base } = split(path); return sourceOf(base) !== null && (lang !== SOURCE_LANGUAGE || load().items.has(base) || load().areas.has(base)); }; /** The build's name for such a page: a file that exists only in memory. */ const virtualId = (path: string) => resolve(root, `.${path}${path.endsWith('/') ? 'index.html' : ''}`).replace(/\\/g, '/'); const virtualPath = (id: string) => { const clean = id.replace(/\\/g, '/'); const base = root.replace(/\\/g, '/'); if (!clean.startsWith(`${base}/`) || !clean.endsWith('.html')) return null; const path = clean.slice(base.length).replace(/index\.html$/, ''); return isVirtual(path) ? path : null; }; const virtualPaths = (): string[] => { const { all, items, areas } = load(); const bases = [...Object.keys(SOURCES), ...items.keys(), ...areas, NOT_FOUND]; return [...items.keys(), ...areas, ...all.filter(language => language.code !== SOURCE_LANGUAGE).flatMap(language => bases.map(base => `/${language.code}${base}`))]; }; /** * A translated page lives one folder deeper than the English page it is written from. Links * between pages are relative and the languages mirror each other, so they already stay inside the * language; everything else a page points at (scripts, styles, pictures, downloads, the API) is * shared and gets its path from the site root. */ const rebase = (html: string, base: string, toRoot: string): string => { const { items, areas } = load(); const from = base === NOT_FOUND ? '/' : base; return html.replace(/(\s(?:href|src|poster|data-src|data-poster-small)=")([^"]*)(")/g, (whole, before: string, raw: string, after: string) => { if (!raw || /^(?:[a-z][a-z0-9+.-]*:|\/|#|%)/i.test(raw)) return whole; const cut = raw.search(/[?#]/); const target = cut < 0 ? raw : raw.slice(0, cut); const suffix = cut < 0 ? '' : raw.slice(cut); if (!target) return whole; const path = posix.normalize(posix.join(from, target)); const page = path.endsWith('/') ? path : `${path}/`; if (SOURCES[page] || items.has(page) || areas.has(page)) return whole; return `${before}${toRoot}${path.slice(1)}${suffix}${after}`; }); }; return { name: 'waybetter-store-pages', enforce: 'pre', config() { const input: Record = {}; for (const path of virtualPaths()) input[`page${path.replace(/\/$/, '').replace(/[^A-Za-z0-9]+/g, '-')}`] = virtualId(path); return { build: { rollupOptions: { input } } }; }, resolveId(id) { return virtualPath(id) ? id.replace(/\\/g, '/') : null; }, load(id) { const path = virtualPath(id); return path ? readFileSync(sourceOf(split(path).base)!, 'utf8') : null; }, configureServer(server) { server.watcher.add([catalogPath, featuredPath, itemFrame, sourceRoot, localesDir]); server.watcher.on('all', (_event, file) => { const changed = resolve(file); if ([catalogPath, featuredPath].includes(changed) || changed.startsWith(sourceRoot) || changed.startsWith(localesDir)) { cache = null; resetCatalogs(); } }); // Items' media is served from the Store's source, at the address the build copies it to. server.middlewares.use((req, res, next) => { const from = load().mediaFiles.get(decodeURIComponent((req.url ?? '').split('?')[0]!.slice(1))); if (!from) return next(); res.statusCode = 200; res.setHeader('Content-Type', MEDIA_TYPES[from.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream'); res.end(readFileSync(from)); }); // Item pages and translated pages have no file on disk: serve their source for their paths. server.middlewares.use(async (req, res, next) => { const path = (req.url ?? '').split('?')[0]!.replace(/index\.html$/, ''); if (!isVirtual(path)) return next(); try { const html = await server.transformIndexHtml(`${path}${path.endsWith('/') ? 'index.html' : ''}`, readFileSync(sourceOf(split(path).base)!, 'utf8')); res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(html); } catch (error) { next(error); } }); }, transformIndexHtml: { order: 'pre', handler(source, ctx) { const { site, all, pages, items } = load(); const path = ctx.path.replace(/index\.html$/, ''); const { lang, base } = split(path); const translated = lang !== SOURCE_LANGUAGE; // The 404 page is served at any depth and starts its links at the language's home (). const rel = relOf(base === NOT_FOUND ? '/' : base); const toRoot = translated ? `../${rel === './' ? '' : rel}` : rel; const language = all.find(entry => entry.code === lang)!; const t = translator(lang, root); const page: Page = { site, lang, dir: language.dir, t, rel, root: toRoot }; const meta = pages.get(path); const item = items.get(base); const count = (kind: string, app?: string) => site.catalog.items.filter(entry => (kind === 'all' || entry.kind === kind) && (!app || entry.apps.includes(app))).length; const latest = latestItems(site.catalog.items, entry => site.dates.get(local(entry.url))); const apps = [...new Set(site.catalog.items.flatMap(entry => entry.apps))].sort().map(id => appInfo(id).name).join(', '); const generated = site.catalog.generatedAt.slice(0, 10); if (source.includes('') && !meta) throw new Error(`no meta data for ${path}: add the page to sitePages() in seo.ts`); // The page's own words in its language (English is what the file says), then its links. let html = source; if (translated) { html = localizeHtml(html, t, { themes: count('theme', APP), plugins: count('plugin', APP), items: count('all') }); html = rebase(html, base, toRoot) .replace(//, ``) .replace('', ``); } return stripMarks(html) .replace('', () => [headHtml(meta!), langScript(page, all), ``].filter(Boolean).join('\n ')) .replace('', () => langMenu(page, all, base, 'top')) .replace('', () => langMenu(page, all, base, 'foot')) .replace('', () => { if (item) return itemHtml(page, item); const area = areaItems(lang).find(entry => areaPath(entry.id) === base); return area ? areaHtml(page, area) : ''; }) .replace('', () => areaShelf(page)) .replace('', () => galleryHtml(page, 'theme')) .replace('', () => galleryHtml(page, 'plugin')) .replace('', () => storeBar(page)) .replace('', () => shelf(page, 'st-featured', t('site.gen.shelf.featured'), t('site.gen.shelf.featuredNote'), site.featured)) .replace('', () => shelf(page, 'st-latest', t('site.gen.shelf.latest'), t('site.gen.shelf.latestNote'), latest)) .replace('', () => site.catalog.items.map(entry => card(page, entry, 'h3')).join('\n')) .replace('', () => (site.featured.length ? site.featured : site.catalog.items).slice(0, 3).map((entry, index) => { const cover = site.media.get(entry)?.cover; return cover ? `` : ``; }).join('')) // Counts for wayBetter for X's galleries (…:theme, …:plugin) and the whole Store (…:all, …:apps). .replace(//g, (_, kind: string) => String(count(kind, APP))) .replace(//g, (_, kind: string) => String(count(kind))) .replace(//g, () => String(new Set(site.catalog.items.flatMap(entry => entry.apps)).size)) .replace(//g, () => esc(apps)) .replace(//g, () => esc(generated)) .replace(//g, () => esc(APPS[APP]?.oneClickSince ?? '')) .replace('', () => (REPO_PUBLIC ? '' : `

    ${messageHtml(t, 'site.gen.repoNote', {}, { a1: ``, a1End: '' })}

    `)) // Repository links come from site.config.ts (one constant: FORGE_URL). .replace(/%REPO_URL%/g, REPO_URL) .replace(/%FRAMEWORK_REPO_URL%/g, FRAMEWORK_REPO_URL) .replace(/%FORGE_HOST%/g, FORGE_HOST) .replace(/%REPO_FILE:([\w./-]+)%/g, (_, file: string) => repoFile(file)) .replace(/%REPO_(PULLS|FORK|ISSUE|SIGNUP|SCHEMA|STORE_DOC)%/g, (_, key: string) => ({ PULLS: repoLinks.pulls, FORK: repoLinks.fork, ISSUE: repoLinks.newIssue, SIGNUP: repoLinks.signUp, SCHEMA: repoLinks.catalogSchema, STORE_DOC: repoLinks.frameworkStoreDoc })[key]!); }, }, generateBundle() { const { sitemap, mediaFiles } = load(); // Items' media, copied to its stable address: /store-media//s//. for (const [to, from] of mediaFiles) this.emitFile({ type: 'asset', fileName: to, source: readFileSync(from) }); this.emitFile({ type: 'asset', fileName: 'sitemap.xml', source: sitemapXml(sitemap) }); this.emitFile({ type: 'asset', fileName: 'robots.txt', source: robotsTxt() }); }, }; }