wayBetter for X

This commit is contained in:
CRTkafa 2026-10-01 06:01:13 +07:00
commit 434f193217
720 changed files with 147465 additions and 0 deletions

804
apps/web/store-pages.ts Normal file
View file

@ -0,0 +1,804 @@
// 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/<id>/ one page per item (store/_item.html is the frame; pages exist only
// /store/plugins/<id>/ in the build, and in the dev server through a middleware)
// /store/areas/<id>/ 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
// /<lang>/… 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 <head> 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 <img>: 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<Item, 'kind' | 'url'>) => `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.<id>.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']);
/**
* 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<Item, string>; dates: Map<string, string>; featured: Item[]; media: Map<Item, ItemMedia>; areaMedia: Map<string, ItemMedia> };
/**
* 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<string, { translated: string; translatable: string } | null>();
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 `<li class="is-warn">${esc(page.t('site.gen.permission.legacy'))}</li>`;
if (!permissions.length) return `<li class="is-ok">${esc(page.t('site.gen.permission.none'))}</li>`;
return permissions.map(p => `<li class="${permissionTone(p)}">${esc(permissionLabel(page, p))}</li>`).join('');
}
const swatches = (item: Item) => (item.colors
? `<span class="gal-sw" aria-hidden="true">${[item.colors.bg, item.colors.surface ?? item.colors.bg, item.colors.text, item.colors.accent].map(c => `<i style="background:${esc(c)}"></i>`).join('')}</span>`
: '');
/** 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
? `<img vite-ignore class="gal-img" src="${esc(mediaUrl(page, cover.src))}" alt="" width="${cover.width}" height="${cover.height}" loading="lazy" decoding="async" />`
: `<img class="gal-img" src="${esc(picture(item.preview))}" alt="" width="640" height="400" loading="lazy" decoding="async" />`;
return `<article class="gal-card${extraClass}" id="${esc(item.kind)}-${esc(site.slugs.get(item))}" data-kind="${esc(item.kind)}" data-category="${esc(item.category)}" data-apps="${esc(item.apps.join(' '))}" data-search="${esc(searchable)}">
<a class="gal-shot" href="${esc(link)}" tabindex="-1" aria-hidden="true">
${shot}
<span class="gal-kind gal-kind-${esc(item.kind)}">${esc(kindLabel(page, item.kind))}</span>
</a>
<div class="gal-body">
<header><${heading}><a href="${esc(link)}"${own(page)}>${esc(item.name)}</a></${heading}>${swatches(item)}</header>
<p class="gal-meta">${item.author ? `${esc(t('site.gen.card.by', { author: item.author }))} · ` : ''}v${esc(item.version)} · ${esc(category(page, item.category))} · ${kb(item.size)}</p>
<p class="gal-desc"${own(page)}>${esc(tidy(item.description))}</p>
${item.kind === 'plugin' ? `<ul class="gal-perms" aria-label="${esc(t('site.gen.permissions'))}">${permissionChips(page, item.permissions)}</ul>` : ''}
<div class="gal-actions">
<a class="btn btn-solid btn-sm" ${installAttrs(page, item)} aria-label="${esc(t('site.gen.card.installLabel', { name: item.name }))}">${esc(t('site.gen.install'))}</a>
<a class="btn btn-ghost btn-sm" href="${esc(link)}" aria-label="${esc(t('site.gen.card.detailsLabel', { name: item.name }))}">${esc(t('site.gen.card.details'))}</a>
</div>
</div>
</article>`;
}
// ---- /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 = `<div class="gal-filters" role="group" aria-label="${esc(t('site.gen.gallery.categories'))}"><button type="button" class="is-on" data-filter="all" aria-pressed="true">${esc(t('site.gen.everything'))} <span>${items.length}</span></button>${categories.map(c => `<button type="button" data-filter="${esc(c)}" aria-pressed="false">${esc(category(page, c))} <span>${items.filter(i => i.category === c).length}</span></button>`).join('')}<label class="gal-search"><span class="sr-only">${esc(t('site.gen.search'))}</span><input type="search" placeholder="${esc(t(kind === 'theme' ? 'site.gen.gallery.searchThemes' : 'site.gen.gallery.searchPlugins'))}" data-gallery-search /></label></div>`;
return `${filters}\n<div class="gal-grid" data-gallery>${items.map(item => card(page, item, 'h2')).join('\n')}</div>\n<p class="gal-empty" data-gallery-empty hidden>${messageHtml(t, 'site.gen.gallery.empty', {}, { a1: `<a href="${page.rel}store/">`, a1End: '</a>' })}</p>`;
}
// ---- /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 = '') => `<button type="button" data-${group}="${esc(value)}" aria-pressed="${value === 'all'}"${extra}>${esc(label)}${n == null ? '' : ` <span>${n}</span>`}</button>`;
return `<form class="st-bar" role="search" aria-label="${esc(t('site.gen.bar.label'))}" data-store-bar action="./" method="get">
<label class="st-search">
<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="9" cy="9" r="5.5" /><path d="m13.5 13.5 3.5 3.5" /></svg>
<span class="sr-only">${esc(t('site.gen.bar.search'))}</span>
<input type="search" name="q" placeholder="${esc(t('site.gen.bar.placeholder', { count: items.length }))}" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search" data-store-search />
<kbd aria-hidden="true">/</kbd>
</label>
<div class="st-filters">
<div class="st-seg" role="group" aria-label="${esc(t('site.gen.bar.kind'))}">
${chip('kind', 'all', t('site.gen.bar.all'), items.length)}${chip('kind', 'theme', t('site.gen.themes'), count(i => i.kind === 'theme'))}${chip('kind', 'plugin', t('site.gen.plugins'), count(i => i.kind === 'plugin'))}
</div>
<div class="st-chips" role="group" aria-label="${esc(t('site.gen.bar.app'))}">
<span class="st-chips-label" aria-hidden="true">${esc(t('site.gen.bar.app'))}</span>
${chip('app', 'all', t('site.gen.bar.everyApp'), null)}${apps.map(app => chip('app', app, appInfo(app).site, count(i => i.apps.includes(app)))).join('')}
</div>
<div class="st-chips" role="group" aria-label="${esc(t('site.gen.bar.category'))}">
<span class="st-chips-label" aria-hidden="true">${esc(t('site.gen.bar.category'))}</span>
${chip('category', 'all', t('site.gen.bar.any'), null)}${(['theme', 'plugin'] as const).flatMap(kind => categories(kind).map(c => chip('category', c, category(page, c), count(i => i.kind === kind && i.category === c), ` data-for="${kind}"`))).join('')}
</div>
</div>
</form>`;
}
function shelf(page: Page, id: string, title: string, note: string, items: Item[]): string {
if (!items.length) return '';
return `<section class="st-shelf" aria-labelledby="${id}">
<header class="st-shelf-head"><h2 class="st-h" id="${id}">${esc(title)}</h2><p>${esc(note)}</p></header>
<div class="st-row">${items.map(item => card(page, item, 'h3', ' gal-card-feature')).join('\n')}</div>
</section>`;
}
/**
* "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/<kind>/<id>/ --------------------------------------------------------------------
/**
* 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 `<figure class="it-shot">
<img src="${esc(picture(item.preview))}" alt="${esc(t('site.gen.media.previewAlt', { name: item.name, kind: item.kind }))}" width="640" height="400" fetchpriority="high" />
</figure>`;
}
const shots = pictures(media);
const video = media.video;
const slides = shots.map((shot, index) => `<figure class="it-shot it-slide${index === 0 ? ' is-on' : ''}" data-slide>
<a class="it-zoom" href="${esc(mediaUrl(page, shot.src))}" data-lightbox aria-label="${esc(t('site.gen.media.open', { alt: shot.alt }))}">
<img vite-ignore src="${esc(mediaUrl(page, shot.src))}" alt="${esc(shot.alt)}"${own(page)} width="${shot.width}" height="${shot.height}" ${index === 0 ? 'fetchpriority="high"' : 'loading="lazy" decoding="async"'} />
</a>${shot.caption ? `
<figcaption${own(page)}>${esc(shot.caption)}</figcaption>` : ''}
</figure>`);
if (video) {
const label = video.caption ?? t('site.gen.media.inUse', { name: item.name });
slides.push(`<figure class="it-shot it-slide it-video${shots.length ? '' : ' is-on'}" data-slide>
<video vite-ignore poster="${esc(mediaUrl(page, video.poster.src))}" width="${video.width}" height="${video.height}" preload="none" playsinline muted loop controls aria-label="${esc(t('site.gen.media.video', { label }))}" data-video>
<source vite-ignore src="${esc(mediaUrl(page, video.src))}" type="${esc(video.type)}" />
<a href="${esc(mediaUrl(page, video.src))}">${esc(t('site.gen.media.download'))}</a>
</video>
<button class="it-play" type="button" data-video-play aria-label="${esc(t('site.gen.media.play', { label }))}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l11-6.5z" /></svg></button>${video.caption ? `
<figcaption${own(page)}>${esc(video.caption)}</figcaption>` : ''}
</figure>`);
}
const thumbs = slides.length < 2 ? '' : `<div class="it-thumbs" role="group" aria-label="${esc(t(video ? 'site.gen.media.picturesAndVideo' : 'site.gen.media.pictures', { name: item.name }))}">${[
...shots.map((shot, index) => `<button type="button" data-thumb aria-pressed="${index === 0}" aria-label="${esc(t('site.gen.media.pictureOf', { index: index + 1, count: shots.length, alt: shot.alt }))}"><img vite-ignore src="${esc(mediaUrl(page, shot.src))}" alt="" width="${shot.width}" height="${shot.height}" loading="lazy" decoding="async" /></button>`),
...(video ? [`<button type="button" class="is-video" data-thumb aria-pressed="${shots.length === 0}" aria-label="${esc(video.caption ? t('site.gen.media.video', { label: video.caption }) : t('site.gen.media.videoThumb'))}"><img vite-ignore src="${esc(mediaUrl(page, video.poster.src))}" alt="" width="${video.poster.width}" height="${video.poster.height}" loading="lazy" decoding="async" /><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l11-6.5z" /></svg></button>`] : []),
].join('')}</div>`;
return `<div class="it-media" data-media>
<div class="it-stage">
${slides.join('\n ')}
</div>
${thumbs}
</div>`;
}
function itemHtml(page: Page, item: Item): string {
const { site, t, rel, root, lang } = page;
const th = (key: string, values: Record<string, unknown> = {}, slots: Record<string, string> = {}) => messageHtml(t, key, values, slots);
const bold = { b1: '<b>', b1End: '</b>' };
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) : `<span class="it-dim">${esc(t('site.gen.item.notStated'))}</span>`],
[t('site.gen.item.category'), `<a href="${rel}store/?kind=${item.kind}&amp;category=${encodeURIComponent(item.category)}">${esc(category(page, item.category))}</a>`],
[t('site.gen.item.for'), item.apps.map(id => { const info = appInfo(id); return info.page ? `<a href="${rel}${info.page}">${esc(info.name)}</a>` : esc(info.name); }).join(', ')],
[t('site.gen.item.file'), `<code>${esc(file)}</code>`],
[t('site.gen.item.size'), `${kb(item.size)} <span class="it-dim">${esc(t('site.gen.item.bytes', { count: item.size.toLocaleString('en-US') }))}</span>`],
...(updated ? [[t('site.gen.item.updated'), `<time datetime="${updated}">${esc(dateLabel(updated, lang))}</time>`] as [string, string]] : []),
...(item.kind === 'strings' ? [
[t('site.gen.item.language'), `<span lang="${esc(item.language ?? '')}">${esc(item.language ?? '')}</span>`],
...(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'), `<span${own(page)}>${esc(item.license)}</span>`] as [string, string]] : []),
...(item.credits ? [[t('site.gen.item.credits'), `<span${own(page)}>${esc(item.credits)}</span>`] as [string, string]] : []),
];
const permissions = item.kind !== 'plugin' ? '' : `<section class="it-block" aria-labelledby="it-perms">
<h2 class="it-h" id="it-perms">${esc(t('site.gen.item.asks'))}</h2>
<ul class="it-perms">${item.permissions == null
? `<li class="is-warn"><b>${esc(t('site.gen.permission.legacy'))}</b><span>${esc(t('site.gen.item.legacyPlain'))}</span></li>`
: !item.permissions.length
? `<li class="is-ok"><b>${esc(t('site.gen.permission.none'))}</b><span>${esc(t('site.gen.item.nonePlain'))}</span></li>`
: item.permissions.map(p => `<li class="${permissionTone(p)}"><b>${esc(permissionLabel(page, p))}</b><span>${esc(PERMISSIONS.has(p) ? t(`${permissionKey(p)}.plain`) : t('site.gen.item.declares', { permission: p }))}</span></li>`).join('')}</ul>
<p class="it-fine">${th('site.gen.item.permissionsFine')}</p>
</section>`;
const palette = !item.colors ? '' : `<section class="it-block" aria-labelledby="it-colors">
<h2 class="it-h" id="it-colors">${esc(t('site.gen.item.palette'))}</h2>
<ul class="it-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]) => `<li><i style="background:${esc(color)}"></i><span>${esc(t(`site.gen.item.color.${name}`))}</span><code>${esc(color)}</code></li>`).join('')}</ul>
<p class="it-fine">${th('site.gen.item.paletteFine')}</p>
</section>`;
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: '<a href="#installing">', a1End: '</a>' };
return `<section class="it-top">
<div class="it-glow" aria-hidden="true"${item.colors ? ` style="--it-accent:${esc(item.colors.accent)}"` : ''}></div>
<nav class="it-crumbs wrap" aria-label="${esc(t('site.gen.breadcrumb'))}">
<ol><li><a href="${rel}store/">${esc(t('site.gen.store'))}</a></li><li><a href="${rel}store/?kind=${item.kind}">${esc(t(item.kind === 'theme' ? 'site.gen.themes' : item.kind === 'strings' ? 'site.gen.strings' : 'site.gen.plugins'))}</a></li><li aria-current="page"${own(page)}>${esc(item.name)}</li></ol>
</nav>
<div class="wrap it-grid">
${mediaHtml(page, item)}
<div class="it-head">
<p class="kicker"><span class="kicker-dot"></span>${th('site.gen.item.kicker', { kind: kindLabel(page, item.kind), category: category(page, item.category), sites: item.apps.map(id => appInfo(id).site).join(', ') })}</p>
<h1${own(page)}>${esc(item.name)}</h1>
<p class="it-by">${item.author ? `${esc(t('site.gen.card.by', { author: item.author }))} &middot; ` : ''}v${esc(item.version)} &middot; ${kb(item.size)}</p>
<p class="it-desc"${own(page)}>${esc(tidy(item.description))}</p>
${item.kind === 'plugin' ? `<ul class="gal-perms" aria-label="${esc(t('site.gen.permissions'))}">${permissionChips(page, item.permissions)}</ul>` : swatches(item)}
<div class="it-actions" data-install-host>
<a class="btn btn-brush" ${installAttrs(page, item)}>${esc(t('site.gen.item.install'))}</a>
<a class="btn btn-ghost" href="${esc(fileHref)}" download="${esc(file)}" data-download>${esc(t('site.gen.item.download', { extension: item.kind === 'theme' ? '.json' : item.kind === 'strings' ? '.strings.json' : '.plugin.js' }))}</a>
<a class="btn btn-ghost it-share" href="${esc(address)}" data-share data-share-title="${esc(`${item.name} · wayBetter Store`)}">${esc(t('site.gen.item.share'))}</a>
</div>
<p class="it-note">${app.oneClickSince ? th('site.gen.item.note', { app: app.name, version: app.oneClickSince }, how) : th('site.gen.item.noteAny', { app: app.name }, how)}</p>
${item.tags.length ? `<ul class="it-tags" aria-label="${esc(t('site.gen.item.tags'))}"${own(page)}>${item.tags.map(tag => `<li><a href="${rel}store/?q=${encodeURIComponent(tag)}">${esc(tag)}</a></li>`).join('')}</ul>` : ''}
</div>
</div>
</section>
<section class="it-body">
<div class="wrap it-cols">
<div class="it-main">
${permissions}${palette}
<section class="it-block" aria-labelledby="it-source">
<h2 class="it-h" id="it-source">${esc(t(item.kind === 'plugin' ? 'site.gen.item.code' : item.kind === 'strings' ? 'site.gen.item.packFile' : 'site.gen.item.themeFile'))}</h2>
<details class="it-src" data-source data-source-url="${esc(fileHref)}" data-source-lang="${item.kind === 'plugin' ? 'js' : 'json'}">
<summary><span class="it-src-t">${esc(t('site.gen.item.read', { file }))}</span><span class="it-src-m">${kb(item.size)} &middot; ${esc(t('site.gen.item.exactBytes'))}</span></summary>
<div class="code">
<div class="code-bar"><span class="dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="file">${esc(file)}</span><a class="copy-btn it-raw" href="${esc(fileHref)}" data-source-raw>${esc(t('site.gen.item.raw'))}</a><button class="copy-btn" type="button" data-source-copy hidden>${esc(t('site.gen.copy'))}</button></div>
<pre tabindex="0" aria-label="${esc(file)}"><code data-source-code>${th('site.gen.item.openRaw', {}, { a1: `<a href="${esc(fileHref)}">`, a1End: '</a>' })}</code></pre>
</div>
</details>
</section>
<section class="it-block" id="installing" aria-labelledby="it-install">
<h2 class="it-h" id="it-install">${esc(t('site.gen.item.how.title'))}</h2>
<ol class="it-steps">
<li>${th('site.gen.item.how.link', { kind: item.kind }, { ...bold, code1: '<code>waybetter://</code>' })}</li>
<li>${th('site.gen.item.how.check', {}, bold)}</li>
<li>${th(item.kind === 'plugin' ? 'site.gen.item.how.confirmPlugin' : item.kind === 'strings' ? 'site.gen.item.how.confirmStrings' : 'site.gen.item.how.confirmTheme', {}, bold)}</li>
</ol>
<p class="it-fine">${th(item.kind === 'plugin' ? 'site.gen.item.how.finePlugin' : item.kind === 'strings' ? 'site.gen.item.how.fineStrings' : 'site.gen.item.how.fineTheme')} ${th('site.gen.item.how.vouched')}</p>
<div class="it-manual">
<h3>${esc(t('site.gen.item.manual.title'))}</h3>
<p>${th(app.page && app.oneClickSince ? 'site.gen.item.manual.text' : 'site.gen.item.manual.textNoPage', { app: app.name, version: app.oneClickSince ?? '', file }, { a1: `<a href="${rel}${app.page ?? ''}">`, a1End: '</a>', a2: `<a href="${esc(fileHref)}" download="${esc(file)}">`, a2End: '</a>' })}</p>
</div>
</section>
</div>
<aside class="it-side" aria-label="${esc(t('site.gen.item.details'))}">
<dl class="it-facts">${facts.map(([name, value]) => `<div><dt>${esc(name)}</dt><dd>${value}</dd></div>`).join('')}</dl>
<div class="it-hash">
<div class="it-hash-head"><span>SHA-256</span><button class="copy-btn" type="button" data-copy="${esc(item.sha256)}" aria-label="${esc(t('site.gen.item.copyHash', { file }))}">${esc(t('site.gen.copy'))}</button></div>
<code>${esc(item.sha256)}</code>
<p class="it-fine">${th('site.gen.item.hashNote', { date: site.catalog.generatedAt.slice(0, 10) }, { code1: `<code>sha256sum ${esc(file)}</code>` })}</p>
</div>
</aside>
</div>
</section>
${related.length ? `<section class="it-more">
<div class="wrap">
<header class="st-shelf-head"><h2 class="st-h">${esc(t(item.kind === 'theme' ? 'site.gen.item.moreThemes' : item.kind === 'strings' ? 'site.gen.item.moreStrings' : 'site.gen.item.morePlugins'))}</h2><p><a href="${rel}store/?kind=${item.kind}">${esc(t('site.gen.item.seeAll', { count: site.catalog.items.filter(other => other.kind === item.kind).length }))}</a></p></header>
<div class="gal-grid">${related.map(other => card(page, other, 'h3')).join('\n')}</div>
</div>
</section>` : ''}`;
}
// ---- 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 `<article class="gal-card gal-card-area" id="area-${esc(area.id)}" data-kind="area" data-category="${esc(area.category)}" data-apps="${APP}" data-search="${esc(searchable)}">
<a class="gal-shot gal-shot-area" href="${esc(link)}" tabindex="-1" aria-hidden="true">
${cover ? `<img vite-ignore class="gal-img" src="${esc(mediaUrl(page, cover.src))}" alt="" width="${cover.width}" height="${cover.height}" loading="lazy" decoding="async" />` : `<span class="gal-area-name">${esc(area.name)}</span>`}
<span class="gal-kind gal-kind-area">${esc(t('site.gen.kind.area'))}</span>
</a>
<div class="gal-body">
<header><${heading}><a href="${esc(link)}">${esc(area.name)}</a></${heading}></header>
<p class="gal-meta">${esc(t('site.gen.area.builtIn'))} · ${esc(category(page, area.category))} · ${esc(t('site.gen.area.featureCount', { count: area.features.length }))}</p>
<p class="gal-desc">${esc(area.description)}</p>
<div class="gal-actions">
<a class="btn btn-ghost btn-sm" href="${esc(link)}" aria-label="${esc(t('site.gen.card.detailsLabel', { name: area.name }))}">${esc(t('site.gen.card.details'))}</a>
</div>
</div>
</article>`;
}
/** 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 `<section class="st-shelf st-areas" aria-labelledby="st-areas">
<header class="st-shelf-head"><h2 class="st-h" id="st-areas">${esc(page.t('site.gen.areas.title', { app: appInfo(APP).name }))}</h2><p>${esc(page.t('site.gen.areas.note', { count: areas.length }))}</p></header>
<div class="gal-grid">${areas.map(area => areaCard(page, area, heading)).join('\n')}</div>
</section>`;
}
function areaHtml(page: Page, area: AreaItem): string {
const { site, t, rel, lang } = page;
const th = (key: string, values: Record<string, unknown> = {}, slots: Record<string, string> = {}) => 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'), `<a href="${rel}store/?kind=plugin&amp;category=${encodeURIComponent(area.category)}">${esc(category(page, area.category))}</a>`],
[t('site.gen.item.for'), app.page ? `<a href="${rel}${app.page}">${esc(app.name)}</a>` : 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'), `<span lang="en">${esc(area.license)}</span>`] as [string, string]] : []),
...(area.credits ? [[t('site.gen.item.credits'), `<span lang="en">${esc(area.credits)}</span>`] 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 `<section class="it-top">
<div class="it-glow" aria-hidden="true"></div>
<nav class="it-crumbs wrap" aria-label="${esc(t('site.gen.breadcrumb'))}">
<ol><li><a href="${rel}store/">${esc(t('site.gen.store'))}</a></li><li><a href="${rel}plugins/#st-areas">${esc(t('site.gen.area.builtIn'))}</a></li><li aria-current="page">${esc(area.name)}</li></ol>
</nav>
<div class="wrap it-grid${top ? '' : ' it-grid-solo'}">
${top}
<div class="it-head">
<p class="kicker"><span class="kicker-dot"></span>${esc(t('site.gen.area.kicker', { app: app.name }))}</p>
<h1>${esc(area.name)}</h1>
<p class="it-desc">${esc(area.description)}</p>
<p class="it-note">${esc(t('site.gen.area.note', { app: app.name, version: AREAS_SINCE }))}</p>
<div class="it-actions">
<a class="btn btn-brush" href="${rel}x/#download">${esc(t('site.gen.area.get', { app: app.name }))}</a>
<a class="btn btn-ghost it-share" href="${esc(address)}" data-share data-share-title="${esc(`${area.name} · wayBetter for X`)}">${esc(t('site.gen.item.share'))}</a>
</div>
${area.tags.length ? `<ul class="it-tags" aria-label="${esc(t('site.gen.item.tags'))}" lang="en">${area.tags.map(tag => `<li><a href="${rel}store/?q=${encodeURIComponent(tag)}">${esc(tag)}</a></li>`).join('')}</ul>` : ''}
</div>
</div>
</section>
<section class="it-body">
<div class="wrap it-cols">
<div class="it-main">
<section class="it-block" aria-labelledby="it-features">
<h2 class="it-h" id="it-features">${esc(t('site.gen.area.whatItDoes'))}</h2>
<ul class="it-features">${area.features.map(feature => `<li><b>${esc(feature.label)}</b>${feature.description ? `<span>${esc(feature.description)}</span>` : ''}</li>`).join('')}</ul>
</section>
<section class="it-block" id="installing" aria-labelledby="it-install">
<h2 class="it-h" id="it-install">${esc(t('site.gen.area.how.title'))}</h2>
<ol class="it-steps">
<li>${th('site.gen.area.how.plugins', { name: area.name }, { b1: '<b>', b1End: '</b>' })}</li>
<li>${th('site.gen.area.how.mode', {}, { b1: '<b>', b1End: '</b>' })}</li>
</ol>
<p class="it-fine">${esc(t('site.gen.area.how.nothing'))}</p>
</section>
</div>
<aside class="it-side" aria-label="${esc(t('site.gen.item.details'))}">
<dl class="it-facts">${facts.map(([name, value]) => `<div><dt>${esc(name)}</dt><dd>${value}</dd></div>`).join('')}</dl>
</aside>
</div>
</section>
${others.length ? `<section class="it-more">
<div class="wrap">
<header class="st-shelf-head"><h2 class="st-h">${esc(t('site.gen.areas.more'))}</h2><p><a href="${rel}plugins/#st-areas">${esc(t('site.gen.item.seeAll', { count: areaItems(lang).length }))}</a></p></header>
<div class="gal-grid">${others.map(other => areaCard(page, other, 'h3')).join('\n')}</div>
</div>
</section>` : ''}`;
}
// ---- Languages --------------------------------------------------------------------------------
const GLOBE = '<svg class="lang-ico" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7.25" /><path d="M2.75 10h14.5M10 2.75c2.1 2 3.2 4.5 3.2 7.25s-1.1 5.25-3.2 7.25c-2.1-2-3.2-4.5-3.2-7.25s1.1-5.25 3.2-7.25Z" /></svg>';
/**
* 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 => `<a href="${page.root}${language.code === SOURCE_LANGUAGE ? '' : `${language.code}/`}${target}" hreflang="${language.code}" lang="${language.code}" dir="${language.dir}" data-lang="${language.code}"${language.code === page.lang ? ' aria-current="true"' : ''}>${esc(language.name)}</a>`).join('');
const label = page.t('site.lang.label');
return `<details class="lang lang-${where}" data-lang-menu>
<summary aria-label="${esc(`${label}: ${current.name}`)}" title="${esc(label)}">${GLOBE}<span class="lang-now"${where === 'top' ? ' aria-hidden="true"' : ''}>${esc(where === 'top' ? current.code.split('-')[0]!.toUpperCase() : current.name)}</span></summary>
<div class="lang-pop" role="group" aria-label="${esc(page.t('site.lang.choose'))}">${links}</div>
</details>`;
}
/**
* The language script, in every page's <head>. 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<o.length;i++)if(!t||!o[i].contains(t))o[i].removeAttribute("open")}'
+ 'd.addEventListener("click",function(e){shut(e.target)});d.addEventListener("keydown",function(e){if(e.key==="Escape")shut()});'
+ (english
? 'if(s||navigator.webdriver||/bot|crawl|spider|slurp|preview|lighthouse|headless|fetch|monitor/i.test(navigator.userAgent))return;'
+ 'var n=navigator.languages||[navigator.language],i,t,c,m;'
+ 'for(i=0;i<n.length&&!m;i++){t=String(n[i]||"").toLowerCase().split("-");c=t[0]==="zh"?(/hant|tw|hk|mo/.test(t.slice(1).join("-"))?"zh-Hant":""):t[0]==="in"?"id":t[0];if(L.indexOf(c)>=0)m=c}'
+ 'if(!m||m==="en")return;try{localStorage.setItem(K,m)}catch(e){return}l.replace(to(m))'
: '')
+ '})();';
return `<script>${source}</script>`;
}
// ---- The plugin -----------------------------------------------------------------------------
const SOURCES: Record<string, string> = { '/': '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<string, string> {
const dates = new Map<string, string>();
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 {
const catalogPath = resolve(root, '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/<app>/<kind>s/<id>/), where an item's media lives. */
const sourceRoot = resolve(root, '../../store');
/** mediaFiles: path in the build ("store-media/x/plugins/<id>/cover.webp") → the file in the source. */
type Loaded = { site: Site; all: Language[]; pages: Map<string, PageMeta>; sitemap: PageMeta[]; mediaFiles: Map<string, string>; items: Map<string, Item>; areas: Set<string> };
let cache: Loaded | null = null;
const MEDIA_TYPES: Record<string, string> = { 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 catalog = JSON.parse(readFileSync(catalogPath, 'utf8')) as Catalog;
if (catalog.format !== 2) throw new Error('store catalog: unsupported format');
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<Item, ItemMedia>();
const mediaByPath = new Map<string, ItemMedia>();
const mediaFiles = new Map<string, string>();
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/<id>/), the same way.
const areaMedia = new Map<string, ItemMedia>();
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<string, string>();
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<string>();
const items = new Map<string, Item>();
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<string, PageMeta>();
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<string, string> = {};
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 (<base href>).
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('<!-- seo -->') && !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(/<html lang="en">/, `<html lang="${lang}" dir="${language.dir}">`)
.replace('<base href="/" />', `<base href="/${lang}/" />`);
}
return stripMarks(html)
.replace('<!-- seo -->', () => [headHtml(meta!), langScript(page, all), `<script type="application/json" id="wb-i18n">${JSON.stringify(clientMessages(lang, root)).replace(/</g, '\\u003c')}</script>`].filter(Boolean).join('\n '))
.replace('<!-- lang:top -->', () => langMenu(page, all, base, 'top'))
.replace('<!-- lang:foot -->', () => langMenu(page, all, base, 'foot'))
.replace('<!-- store:item -->', () => {
if (item) return itemHtml(page, item);
const area = areaItems(lang).find(entry => areaPath(entry.id) === base);
return area ? areaHtml(page, area) : '';
})
.replace('<!-- store:areas -->', () => areaShelf(page))
.replace('<!-- store:themes -->', () => galleryHtml(page, 'theme'))
.replace('<!-- store:plugins -->', () => galleryHtml(page, 'plugin'))
.replace('<!-- store:bar -->', () => storeBar(page))
.replace('<!-- store:featured -->', () => shelf(page, 'st-featured', t('site.gen.shelf.featured'), t('site.gen.shelf.featuredNote'), site.featured))
.replace('<!-- store:latest -->', () => shelf(page, 'st-latest', t('site.gen.shelf.latest'), t('site.gen.shelf.latestNote'), latest))
.replace('<!-- store:grid -->', () => site.catalog.items.map(entry => card(page, entry, 'h3')).join('\n'))
.replace('<!-- store:art -->', () => (site.featured.length ? site.featured : site.catalog.items).slice(0, 3).map((entry, index) => {
const cover = site.media.get(entry)?.cover;
return cover
? `<img vite-ignore class="st-art-${index + 1}" src="${esc(mediaUrl(page, cover.src))}" alt="" width="${cover.width}" height="${cover.height}" />`
: `<img class="st-art-${index + 1}" src="${esc(picture(entry.preview))}" alt="" width="640" height="400" />`;
}).join(''))
// Counts for wayBetter for X's galleries (…:theme, …:plugin) and the whole Store (…:all, …:apps).
.replace(/<!-- store:count:(theme|plugin) -->/g, (_, kind: string) => String(count(kind, APP)))
.replace(/<!-- store:total:(all|theme|plugin) -->/g, (_, kind: string) => String(count(kind)))
.replace(/<!-- store:total:apps -->/g, () => String(new Set(site.catalog.items.flatMap(entry => entry.apps)).size))
.replace(/<!-- store:apps -->/g, () => esc(apps))
.replace(/<!-- store:generated -->/g, () => esc(generated))
.replace(/<!-- store:since -->/g, () => esc(APPS[APP]?.oneClickSince ?? ''))
.replace('<!-- repo:note -->', () => (REPO_PUBLIC ? '' : `<p class="repo-note">${messageHtml(t, 'site.gen.repoNote', {}, { a1: `<a href="${FRAMEWORK_REPO_URL}">`, a1End: '</a>' })}</p>`))
// 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/<app>/<kind>s/<id>/<file>.
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() });
},
};
}