waybetter-for-x/apps/web/areas.ts
2026-10-01 06:01:13 +07:00

74 lines
3.8 KiB
TypeScript

// The area plugins of wayBetter for X on the site (/store/areas/<id>/): Compose+, Reader+, … are
// built into the app, so they are not in the signed catalog and have no Install button. Their
// names, descriptions and feature lists come from the app's own feature table and words
// (apps/desktop/src/areas/table.ts, apps/desktop/locales/<lang>.json); the folder
// store/x/areas/<id>/item.json adds category, tags, licence, credits and media
// (scripts/store/areas.mjs). So the page, the app and the Store check read one source.
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// @ts-expect-error: a plain ES module shared with scripts/check-store.mjs
import { readAreaSource } from '../../scripts/store/areas.mjs';
import { X_TABLE } from '../desktop/src/areas/table.ts';
import { featureWords } from '../../waybetter/packages/runtime/src/featureTable.ts';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '../..');
const LOCALES = join(REPO, 'apps/desktop/locales');
/** The app version these pages speak of: the first public release has every area. */
export const AREAS_SINCE = '1.0.0';
export type AreaFeature = { id: string; label: string; description: string };
export type AreaItem = {
id: string; name: string; description: string; category: string; tags: string[];
license?: string; credits?: string; features: AreaFeature[];
};
type Area = { id: string; category: string; tags: string[]; license?: string; credits?: string; dir: string };
let source: Area[] | null = null;
const words = new Map<string, Record<string, string>>();
/** The area folders (store/x/areas/), read once; a problem stops the build as the Store check would. */
function areaSource(): Area[] {
if (source) return source;
const read = readAreaSource({ root: REPO, app: 'x', ids: X_TABLE.areas.map(area => area.id) }) as { entries: Area[]; problems: string[] };
if (read.problems.length) throw new Error(`the Store's area pages have problems (bun run check:store explains each):\n ${read.problems.join('\n ')}`);
source = read.entries;
return source;
}
/** The app's words in a language, English underneath (a site language the app lacks reads English). */
function appWords(lang: string): Record<string, string> {
const cached = words.get(lang);
if (cached) return cached;
const load = (code: string) => {
const file = join(LOCALES, `${code}.json`);
return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as { messages: Record<string, string> }).messages : {};
};
const out = { ...load('en'), ...(lang === 'en' ? {} : load(lang)) };
words.set(lang, out);
return out;
}
/** Every area in the table's order, in a language (the app's own words for it). */
export function areaItems(lang = 'en'): AreaItem[] {
const text = appWords(lang);
const say = (key: string, fallback: string) => (text[key] ?? fallback).replace(/\s+/g, ' ').trim();
return areaSource().map(entry => {
const area = X_TABLE.areas.find(candidate => candidate.id === entry.id)!;
const features = X_TABLE.features
.filter(feature => feature.area === area.id)
.map(feature => {
const keys = featureWords(feature);
return { id: feature.id, label: say(keys.label, feature.id), description: say(keys.description, '') };
});
return {
id: area.id, name: area.name, description: say(area.description, area.name), category: entry.category, tags: entry.tags,
...(entry.license ? { license: entry.license } : {}), ...(entry.credits ? { credits: entry.credits } : {}), features,
};
});
}
export const areaPath = (id: string): string => `/store/areas/${id}/`;
/** The folder with the area's item.json and media ("store/x/areas/reader"). */
export const areaFolder = (id: string): string => join(REPO, 'store', 'x', 'areas', id);