wayBetter for X
This commit is contained in:
commit
2f1e35ba20
720 changed files with 147465 additions and 0 deletions
804
apps/web/store-pages.ts
Normal file
804
apps/web/store-pages.ts
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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}&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 }))} · ` : ''}v${esc(item.version)} · ${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)} · ${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&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() });
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue