@gjsify/site 0.0.0-stage → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +165 -2
- package/lib/cli.d.ts +6 -0
- package/lib/cli.js +91 -0
- package/lib/client/site.css +1286 -0
- package/lib/client/site.js +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +9 -0
- package/lib/render/color.d.ts +12 -0
- package/lib/render/color.js +45 -0
- package/lib/render/html.d.ts +8 -0
- package/lib/render/html.js +20 -0
- package/lib/render/i18n.d.ts +38 -0
- package/lib/render/i18n.js +106 -0
- package/lib/render/icons.d.ts +14 -0
- package/lib/render/icons.js +42 -0
- package/lib/render/image-backend.d.ts +26 -0
- package/lib/render/image-backend.js +130 -0
- package/lib/render/images.d.ts +51 -0
- package/lib/render/images.js +97 -0
- package/lib/render/pages.d.ts +32 -0
- package/lib/render/pages.js +628 -0
- package/lib/render/qr.d.ts +8 -0
- package/lib/render/qr.js +25 -0
- package/lib/site-data/config.d.ts +23 -0
- package/lib/site-data/config.js +142 -0
- package/lib/site-data/downloads.d.ts +29 -0
- package/lib/site-data/downloads.js +193 -0
- package/lib/site-data/flathub.d.ts +24 -0
- package/lib/site-data/flathub.js +35 -0
- package/lib/site-data/github.d.ts +9 -0
- package/lib/site-data/github.js +45 -0
- package/lib/site-data/index.d.ts +12 -0
- package/lib/site-data/index.js +12 -0
- package/lib/site-data/load.d.ts +18 -0
- package/lib/site-data/load.js +164 -0
- package/lib/site-data/metainfo.d.ts +9 -0
- package/lib/site-data/metainfo.js +142 -0
- package/lib/site-data/network.d.ts +32 -0
- package/lib/site-data/network.js +67 -0
- package/lib/site-data/oars.d.ts +15 -0
- package/lib/site-data/oars.js +62 -0
- package/lib/site-data/po.d.ts +40 -0
- package/lib/site-data/po.js +148 -0
- package/lib/site-data/screenshots.d.ts +33 -0
- package/lib/site-data/screenshots.js +90 -0
- package/lib/site-data/sepa.d.ts +20 -0
- package/lib/site-data/sepa.js +45 -0
- package/lib/site-data/types.d.ts +224 -0
- package/lib/site-data/types.js +1 -0
- package/lib/site-data/xml.d.ts +11 -0
- package/lib/site-data/xml.js +51 -0
- package/lib/site.d.ts +49 -0
- package/lib/site.js +161 -0
- package/package.json +77 -5
- package/po/de.po +265 -0
- package/po/site.pot +264 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { parseSiteConfig } from './config.js';
|
|
5
|
+
import { buildDownloadTargets } from './downloads.js';
|
|
6
|
+
import { fetchFlathubStats, fetchFlathubSummary } from './flathub.js';
|
|
7
|
+
import { fetchLatestRelease } from './github.js';
|
|
8
|
+
import { parseMetainfo } from './metainfo.js';
|
|
9
|
+
import { minimumAge } from './oars.js';
|
|
10
|
+
import { completeLocalized, languageCandidates, parseCatalog } from './po.js';
|
|
11
|
+
import { resolveScreenshots } from './screenshots.js';
|
|
12
|
+
/** Reads `<dir>/<lang>.po` for every language, trying `pt-BR` and `pt_BR` spellings; missing files are skipped. */
|
|
13
|
+
export async function loadCatalogs(dir, languages) {
|
|
14
|
+
const catalogs = {};
|
|
15
|
+
for (const language of languages) {
|
|
16
|
+
for (const candidate of languageCandidates(language)) {
|
|
17
|
+
const file = join(dir, `${candidate}.po`);
|
|
18
|
+
if (existsSync(file)) {
|
|
19
|
+
catalogs[language] = parseCatalog(await readFile(file, 'utf8'));
|
|
20
|
+
break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return catalogs;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The page's own strings are always translated; the app's text only when the metainfo or a
|
|
28
|
+
* catalogue brings a translation. Say so once per language instead of silently showing English.
|
|
29
|
+
*/
|
|
30
|
+
function warnMissingTranslations(config, metainfo, complete, warn) {
|
|
31
|
+
const texts = [
|
|
32
|
+
metainfo.name,
|
|
33
|
+
metainfo.summary,
|
|
34
|
+
...metainfo.description.flatMap((block) => (block.type === 'paragraph' ? [block.text] : block.items)),
|
|
35
|
+
];
|
|
36
|
+
for (const language of config.languages) {
|
|
37
|
+
if (language === config.sourceLanguage)
|
|
38
|
+
continue;
|
|
39
|
+
const missing = texts.filter((text) => complete(text).byLanguage[language] === undefined).length;
|
|
40
|
+
if (missing > 0) {
|
|
41
|
+
const hint = config.translations
|
|
42
|
+
? ''
|
|
43
|
+
: ' (set "gjsify.site.translations" to a directory of .po catalogues)';
|
|
44
|
+
warn(`${missing} of ${texts.length} app texts have no "${language}" translation and show the source text${hint}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Collects everything the pages need into one object. Network failures degrade to warnings. */
|
|
49
|
+
export async function loadSiteData(options) {
|
|
50
|
+
const warnings = [];
|
|
51
|
+
const warn = (message) => {
|
|
52
|
+
warnings.push(message);
|
|
53
|
+
options.warn?.(message);
|
|
54
|
+
};
|
|
55
|
+
const manifest = JSON.parse(await readFile(join(options.root, 'package.json'), 'utf8'));
|
|
56
|
+
const config = parseSiteConfig(manifest);
|
|
57
|
+
const version = manifest.version ?? '';
|
|
58
|
+
const metainfoPath = resolve(options.root, config.metainfo);
|
|
59
|
+
if (!existsSync(metainfoPath))
|
|
60
|
+
throw new Error(`gjsify.site.metainfo points to "${config.metainfo}", which does not exist`);
|
|
61
|
+
const metainfo = parseMetainfo(await readFile(metainfoPath, 'utf8'));
|
|
62
|
+
const appCatalogs = config.translations
|
|
63
|
+
? await loadCatalogs(resolve(options.root, config.translations), config.languages)
|
|
64
|
+
: {};
|
|
65
|
+
const complete = (text) => completeLocalized(text, config.languages, config.sourceLanguage, appCatalogs);
|
|
66
|
+
const completeBlocks = (blocks) => blocks.map((block) => block.type === 'paragraph'
|
|
67
|
+
? { type: 'paragraph', text: complete(block.text) }
|
|
68
|
+
: { type: 'list', items: block.items.map(complete) });
|
|
69
|
+
warnMissingTranslations(config, metainfo, complete, warn);
|
|
70
|
+
const screenshots = (await resolveScreenshots(metainfo, {
|
|
71
|
+
root: options.root,
|
|
72
|
+
repository: config.repository,
|
|
73
|
+
cacheDir: options.screenshotCacheDir,
|
|
74
|
+
offline: options.offline,
|
|
75
|
+
network: options.network,
|
|
76
|
+
warn,
|
|
77
|
+
})).map((screenshot) => ({
|
|
78
|
+
...screenshot,
|
|
79
|
+
caption: screenshot.caption ? complete(screenshot.caption) : null,
|
|
80
|
+
}));
|
|
81
|
+
const icon = config.icon ?? manifest.gjsify?.flatpak?.icon ?? manifest.gjsify?.ship?.icon;
|
|
82
|
+
if (icon && !existsSync(resolve(options.root, icon)))
|
|
83
|
+
throw new Error(`The app icon "${icon}" does not exist`);
|
|
84
|
+
let latestRelease = null;
|
|
85
|
+
let hasReleases = true;
|
|
86
|
+
let flathubSize;
|
|
87
|
+
const stats = {};
|
|
88
|
+
if (!options.offline) {
|
|
89
|
+
const network = options.network ?? {};
|
|
90
|
+
const [release, flathub, summary] = await Promise.allSettled([
|
|
91
|
+
fetchLatestRelease(config.repository, options.githubToken, network),
|
|
92
|
+
config.flathub ? fetchFlathubStats(config.flathub, network) : Promise.resolve(undefined),
|
|
93
|
+
config.flathub ? fetchFlathubSummary(config.flathub, network) : Promise.resolve(undefined),
|
|
94
|
+
]);
|
|
95
|
+
if (release.status === 'fulfilled') {
|
|
96
|
+
latestRelease = release.value;
|
|
97
|
+
hasReleases = latestRelease !== null;
|
|
98
|
+
if (latestRelease) {
|
|
99
|
+
stats.githubDownloads = latestRelease.assets.reduce((sum, asset) => sum + asset.downloadCount, 0);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
warn(`Could not fetch the latest GitHub release (${String(release.reason)}); linking to the releases page instead`);
|
|
104
|
+
}
|
|
105
|
+
if (flathub.status === 'fulfilled' && flathub.value) {
|
|
106
|
+
stats.flathubInstalls = flathub.value.installsTotal;
|
|
107
|
+
stats.flathubInstallsLastMonth = flathub.value.installsLastMonth;
|
|
108
|
+
}
|
|
109
|
+
else if (flathub.status === 'rejected') {
|
|
110
|
+
warn(`Could not fetch Flathub statistics (${String(flathub.reason)}); omitting install counts`);
|
|
111
|
+
}
|
|
112
|
+
if (summary.status === 'fulfilled') {
|
|
113
|
+
flathubSize = summary.value?.downloadSize;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
warn(`Could not fetch the Flathub summary (${String(summary.reason)}); falling back to the bundle size`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const age = metainfo.contentRating
|
|
120
|
+
? minimumAge(metainfo.contentRating.type, metainfo.contentRating.attributes)
|
|
121
|
+
: undefined;
|
|
122
|
+
const developer = metainfo.developer.name
|
|
123
|
+
? metainfo.developer
|
|
124
|
+
: { id: manifest.gjsify?.flatpak?.developer?.id, name: manifest.gjsify?.flatpak?.developer?.name ?? '' };
|
|
125
|
+
const releases = metainfo.releases.map((release) => ({
|
|
126
|
+
...release,
|
|
127
|
+
description: completeBlocks(release.description),
|
|
128
|
+
}));
|
|
129
|
+
return {
|
|
130
|
+
config,
|
|
131
|
+
version,
|
|
132
|
+
app: {
|
|
133
|
+
id: metainfo.id,
|
|
134
|
+
name: complete(metainfo.name),
|
|
135
|
+
summary: complete(metainfo.summary),
|
|
136
|
+
developer,
|
|
137
|
+
description: completeBlocks(metainfo.description),
|
|
138
|
+
license: metainfo.projectLicense,
|
|
139
|
+
contentLicense: metainfo.metadataLicense,
|
|
140
|
+
keywords: metainfo.keywords.map(complete),
|
|
141
|
+
categories: metainfo.categories,
|
|
142
|
+
urls: metainfo.urls,
|
|
143
|
+
branding: metainfo.branding,
|
|
144
|
+
ageRating: age === undefined ? undefined : { minAge: age },
|
|
145
|
+
icon,
|
|
146
|
+
},
|
|
147
|
+
screenshots,
|
|
148
|
+
releases,
|
|
149
|
+
latestRelease,
|
|
150
|
+
downloads: {
|
|
151
|
+
targets: buildDownloadTargets({
|
|
152
|
+
assets: config.releaseAssets ? (latestRelease?.assets ?? []) : [],
|
|
153
|
+
configured: config.downloads,
|
|
154
|
+
flathub: config.flathub,
|
|
155
|
+
flathubSize,
|
|
156
|
+
webAppUrl: config.webApp ? `${config.base}${config.webApp.replace(/^\//, '')}` : undefined,
|
|
157
|
+
}),
|
|
158
|
+
releasesUrl: `https://github.com/${config.repository}/releases`,
|
|
159
|
+
hasReleases,
|
|
160
|
+
},
|
|
161
|
+
stats,
|
|
162
|
+
warnings,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Localized, Metainfo } from './types.js';
|
|
2
|
+
import { type XmlElement } from './xml.js';
|
|
3
|
+
/**
|
|
4
|
+
* Pairs sibling elements into texts: an element without `xml:lang` opens a new text, the ones with
|
|
5
|
+
* `xml:lang` that follow translate it. This is how `msgfmt --xml` writes translated metainfo.
|
|
6
|
+
*/
|
|
7
|
+
export declare function groupLocalized(elements: readonly XmlElement[]): Localized[];
|
|
8
|
+
/** Reads the parts of an AppStream `<component>` the landing page needs. */
|
|
9
|
+
export declare function parseMetainfo(xml: string): Metainfo;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { child, childrenNamed, parseXml } from './xml.js';
|
|
2
|
+
const XML_LANG = 'xml:lang';
|
|
3
|
+
/**
|
|
4
|
+
* Pairs sibling elements into texts: an element without `xml:lang` opens a new text, the ones with
|
|
5
|
+
* `xml:lang` that follow translate it. This is how `msgfmt --xml` writes translated metainfo.
|
|
6
|
+
*/
|
|
7
|
+
export function groupLocalized(elements) {
|
|
8
|
+
const groups = [];
|
|
9
|
+
for (const element of elements) {
|
|
10
|
+
const language = element.attrs[XML_LANG];
|
|
11
|
+
if (language === undefined) {
|
|
12
|
+
groups.push({ source: element.text, byLanguage: {} });
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
const current = groups.at(-1);
|
|
16
|
+
if (current)
|
|
17
|
+
current.byLanguage[language] = element.text;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return groups;
|
|
21
|
+
}
|
|
22
|
+
function singleLocalized(parent, name) {
|
|
23
|
+
return groupLocalized(childrenNamed(parent, name))[0];
|
|
24
|
+
}
|
|
25
|
+
function parseBlocks(description) {
|
|
26
|
+
const blocks = [];
|
|
27
|
+
if (!description)
|
|
28
|
+
return blocks;
|
|
29
|
+
let paragraphGroup = [];
|
|
30
|
+
const flushParagraphs = () => {
|
|
31
|
+
for (const text of groupLocalized(paragraphGroup))
|
|
32
|
+
blocks.push({ type: 'paragraph', text });
|
|
33
|
+
paragraphGroup = [];
|
|
34
|
+
};
|
|
35
|
+
// Translations of a paragraph follow it directly, so consecutive `<p>` elements are grouped and
|
|
36
|
+
// split again whenever a list interrupts them.
|
|
37
|
+
for (const element of description.children) {
|
|
38
|
+
if (element.name === 'p') {
|
|
39
|
+
paragraphGroup.push(element);
|
|
40
|
+
}
|
|
41
|
+
else if (element.name === 'ul' || element.name === 'ol') {
|
|
42
|
+
flushParagraphs();
|
|
43
|
+
blocks.push({ type: 'list', items: groupLocalized(childrenNamed(element, 'li')) });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
flushParagraphs();
|
|
47
|
+
return blocks;
|
|
48
|
+
}
|
|
49
|
+
function parseReleases(releases) {
|
|
50
|
+
if (!releases)
|
|
51
|
+
return [];
|
|
52
|
+
return childrenNamed(releases, 'release').map((release) => {
|
|
53
|
+
const timestamp = release.attrs.timestamp;
|
|
54
|
+
const date = release.attrs.date ?? (timestamp ? new Date(Number(timestamp) * 1000).toISOString().slice(0, 10) : '');
|
|
55
|
+
return {
|
|
56
|
+
version: release.attrs.version ?? '',
|
|
57
|
+
date,
|
|
58
|
+
type: release.attrs.type,
|
|
59
|
+
description: parseBlocks(child(release, 'description')),
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** The colours land in an inline `<style>`, so only plain hex values pass. */
|
|
64
|
+
const HEX_COLOR = /^#[0-9a-f]{3,8}$/i;
|
|
65
|
+
function parseBranding(branding) {
|
|
66
|
+
const result = {};
|
|
67
|
+
if (!branding)
|
|
68
|
+
return result;
|
|
69
|
+
for (const color of childrenNamed(branding, 'color')) {
|
|
70
|
+
if (color.attrs.type !== 'primary' || !HEX_COLOR.test(color.text))
|
|
71
|
+
continue;
|
|
72
|
+
if (color.attrs.scheme_preference === 'light')
|
|
73
|
+
result.light = color.text;
|
|
74
|
+
else if (color.attrs.scheme_preference === 'dark')
|
|
75
|
+
result.dark = color.text;
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
const URL_KEYS = {
|
|
80
|
+
homepage: 'homepage',
|
|
81
|
+
bugtracker: 'bugtracker',
|
|
82
|
+
'vcs-browser': 'vcsBrowser',
|
|
83
|
+
translate: 'translate',
|
|
84
|
+
donation: 'donation',
|
|
85
|
+
help: 'help',
|
|
86
|
+
faq: 'faq',
|
|
87
|
+
contact: 'contact',
|
|
88
|
+
contribute: 'contribute',
|
|
89
|
+
};
|
|
90
|
+
/** Links are rendered as `href`, so `javascript:` and friends are dropped. */
|
|
91
|
+
const SAFE_URL = /^(https?:|mailto:)/i;
|
|
92
|
+
function parseUrls(component) {
|
|
93
|
+
const urls = {};
|
|
94
|
+
for (const url of childrenNamed(component, 'url')) {
|
|
95
|
+
const key = URL_KEYS[url.attrs.type ?? ''];
|
|
96
|
+
if (key && SAFE_URL.test(url.text))
|
|
97
|
+
urls[key] = url.text;
|
|
98
|
+
}
|
|
99
|
+
return urls;
|
|
100
|
+
}
|
|
101
|
+
/** Reads the parts of an AppStream `<component>` the landing page needs. */
|
|
102
|
+
export function parseMetainfo(xml) {
|
|
103
|
+
const component = parseXml(xml);
|
|
104
|
+
if (component.name !== 'component')
|
|
105
|
+
throw new Error(`Metainfo root must be <component>, got <${component.name}>`);
|
|
106
|
+
const id = child(component, 'id')?.text;
|
|
107
|
+
if (!id)
|
|
108
|
+
throw new Error('Metainfo has no <id>');
|
|
109
|
+
const name = singleLocalized(component, 'name');
|
|
110
|
+
if (!name)
|
|
111
|
+
throw new Error('Metainfo has no <name>');
|
|
112
|
+
const summary = singleLocalized(component, 'summary') ?? { source: '', byLanguage: {} };
|
|
113
|
+
const developer = child(component, 'developer');
|
|
114
|
+
const developerName = developer ? child(developer, 'name')?.text : undefined;
|
|
115
|
+
const screenshots = childrenNamed(child(component, 'screenshots') ?? component, 'screenshot').map((screenshot) => {
|
|
116
|
+
const images = childrenNamed(screenshot, 'image');
|
|
117
|
+
const image = images.find((candidate) => candidate.attrs.type === 'source') ?? images[0];
|
|
118
|
+
return { url: image?.text ?? '', caption: singleLocalized(screenshot, 'caption') ?? null };
|
|
119
|
+
});
|
|
120
|
+
const contentRating = child(component, 'content_rating');
|
|
121
|
+
const attributes = {};
|
|
122
|
+
for (const attribute of contentRating ? childrenNamed(contentRating, 'content_attribute') : []) {
|
|
123
|
+
if (attribute.attrs.id)
|
|
124
|
+
attributes[attribute.attrs.id] = attribute.text;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
name,
|
|
129
|
+
summary,
|
|
130
|
+
developer: { id: developer?.attrs.id, name: developerName ?? '' },
|
|
131
|
+
description: parseBlocks(child(component, 'description')),
|
|
132
|
+
screenshots,
|
|
133
|
+
releases: parseReleases(child(component, 'releases')),
|
|
134
|
+
branding: parseBranding(child(component, 'branding')),
|
|
135
|
+
urls: parseUrls(component),
|
|
136
|
+
keywords: groupLocalized(childrenNamed(child(component, 'keywords') ?? component, 'keyword')),
|
|
137
|
+
categories: childrenNamed(child(component, 'categories') ?? component, 'category').map((category) => category.text),
|
|
138
|
+
projectLicense: child(component, 'project_license')?.text,
|
|
139
|
+
metadataLicense: child(component, 'metadata_license')?.text,
|
|
140
|
+
contentRating: contentRating ? { type: contentRating.attrs.type ?? '', attributes } : undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: {
|
|
2
|
+
headers?: Record<string, string>;
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
}) => Promise<{
|
|
5
|
+
ok: boolean;
|
|
6
|
+
status: number;
|
|
7
|
+
json(): Promise<unknown>;
|
|
8
|
+
/** Only the callers that want bytes need this; a JSON-only fake may leave it out. */
|
|
9
|
+
arrayBuffer?(): Promise<ArrayBuffer>;
|
|
10
|
+
}>;
|
|
11
|
+
export interface NetworkOptions {
|
|
12
|
+
fetch?: FetchLike;
|
|
13
|
+
/** When set, responses are stored here and reused for `cacheTtlMs`; a stale copy also serves as the offline fallback. */
|
|
14
|
+
cacheDir?: string;
|
|
15
|
+
cacheTtlMs?: number;
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
/** A response outside 2xx; callers that treat a status as an answer (a 404 for "no release yet") read `status`. */
|
|
19
|
+
export declare class HttpError extends Error {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
constructor(status: number, url: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* GETs JSON, optionally through an on-disk cache. Throws when the network fails and no cached copy
|
|
25
|
+
* exists, so callers decide how to degrade.
|
|
26
|
+
*/
|
|
27
|
+
export declare function fetchJson(url: string, headers: Record<string, string>, options?: NetworkOptions): Promise<unknown>;
|
|
28
|
+
/**
|
|
29
|
+
* GETs bytes. Not cached here: the caller writes the file it asked for and finds it again by name,
|
|
30
|
+
* which is also what makes a later `--offline` build work.
|
|
31
|
+
*/
|
|
32
|
+
export declare function fetchBinary(url: string, options?: NetworkOptions): Promise<Uint8Array>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/** A response outside 2xx; callers that treat a status as an answer (a 404 for "no release yet") read `status`. */
|
|
5
|
+
export class HttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
constructor(status, url) {
|
|
8
|
+
super(`HTTP ${status} for ${url}`);
|
|
9
|
+
this.status = status;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000;
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
14
|
+
async function readCache(file) {
|
|
15
|
+
try {
|
|
16
|
+
const [text, info] = await Promise.all([readFile(file, 'utf8'), stat(file)]);
|
|
17
|
+
return { data: JSON.parse(text), ageMs: Date.now() - info.mtimeMs };
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* GETs JSON, optionally through an on-disk cache. Throws when the network fails and no cached copy
|
|
25
|
+
* exists, so callers decide how to degrade.
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchJson(url, headers, options = {}) {
|
|
28
|
+
const file = options.cacheDir
|
|
29
|
+
? join(options.cacheDir, `${createHash('sha256').update(url).digest('hex').slice(0, 16)}.json`)
|
|
30
|
+
: undefined;
|
|
31
|
+
const cached = file ? await readCache(file) : null;
|
|
32
|
+
if (cached && cached.ageMs < (options.cacheTtlMs ?? DEFAULT_TTL_MS))
|
|
33
|
+
return cached.data;
|
|
34
|
+
try {
|
|
35
|
+
const doFetch = options.fetch ?? ((target, init) => fetch(target, init));
|
|
36
|
+
const response = await doFetch(url, {
|
|
37
|
+
headers,
|
|
38
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
39
|
+
});
|
|
40
|
+
if (!response.ok)
|
|
41
|
+
throw new HttpError(response.status, url);
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
if (file && options.cacheDir) {
|
|
44
|
+
await mkdir(options.cacheDir, { recursive: true });
|
|
45
|
+
await writeFile(file, JSON.stringify(data));
|
|
46
|
+
}
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (cached)
|
|
51
|
+
return cached.data;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* GETs bytes. Not cached here: the caller writes the file it asked for and finds it again by name,
|
|
57
|
+
* which is also what makes a later `--offline` build work.
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchBinary(url, options = {}) {
|
|
60
|
+
const doFetch = options.fetch ?? ((target, init) => fetch(target, init));
|
|
61
|
+
const response = await doFetch(url, { signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS) });
|
|
62
|
+
if (!response.ok)
|
|
63
|
+
throw new HttpError(response.status, url);
|
|
64
|
+
if (!response.arrayBuffer)
|
|
65
|
+
throw new Error(`This fetch implementation cannot read bytes (${url})`);
|
|
66
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
67
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OARS content rating → minimum age.
|
|
3
|
+
*
|
|
4
|
+
* Ported from libappstream's `as_content_rating_attribute_to_csm_age()` (the Common Sense Media
|
|
5
|
+
* table) and `as_content_rating_get_minimum_age()`, which is what GNOME Software and Flathub show:
|
|
6
|
+
* https://github.com/ximion/appstream/blob/master/src/as-content-rating.c
|
|
7
|
+
* Unknown attributes count as 0, like upstream.
|
|
8
|
+
*/
|
|
9
|
+
export type OarsValue = 'none' | 'mild' | 'moderate' | 'intense';
|
|
10
|
+
export declare function attributeToCsmAge(attribute: string, value: string): number;
|
|
11
|
+
/**
|
|
12
|
+
* The highest per-attribute age (`as_content_rating_get_minimum_age()`); 0 means "all ages".
|
|
13
|
+
* Returns `undefined` for rating systems other than OARS 1.0/1.1, where upstream gives no answer.
|
|
14
|
+
*/
|
|
15
|
+
export declare function minimumAge(kind: string, attributes: Readonly<Record<string, string>>): number | undefined;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OARS content rating → minimum age.
|
|
3
|
+
*
|
|
4
|
+
* Ported from libappstream's `as_content_rating_attribute_to_csm_age()` (the Common Sense Media
|
|
5
|
+
* table) and `as_content_rating_get_minimum_age()`, which is what GNOME Software and Flathub show:
|
|
6
|
+
* https://github.com/ximion/appstream/blob/master/src/as-content-rating.c
|
|
7
|
+
* Unknown attributes count as 0, like upstream.
|
|
8
|
+
*/
|
|
9
|
+
const VALUE_INDEX = { none: 0, mild: 1, moderate: 2, intense: 3 };
|
|
10
|
+
/** Minimum age per attribute for `[none, mild, moderate, intense]`. */
|
|
11
|
+
const CSM_AGE = {
|
|
12
|
+
'violence-cartoon': [0, 3, 4, 6],
|
|
13
|
+
'violence-fantasy': [0, 3, 7, 8],
|
|
14
|
+
'violence-realistic': [0, 4, 9, 14],
|
|
15
|
+
'violence-bloodshed': [0, 9, 11, 18],
|
|
16
|
+
'violence-sexual': [0, 18, 18, 18],
|
|
17
|
+
'drugs-alcohol': [0, 11, 13, 16],
|
|
18
|
+
'drugs-narcotics': [0, 12, 14, 17],
|
|
19
|
+
'drugs-tobacco': [0, 10, 13, 13],
|
|
20
|
+
'sex-nudity': [0, 12, 14, 14],
|
|
21
|
+
'sex-themes': [0, 13, 14, 15],
|
|
22
|
+
'language-profanity': [0, 8, 11, 14],
|
|
23
|
+
'language-humor': [0, 3, 8, 14],
|
|
24
|
+
'language-discrimination': [0, 9, 10, 11],
|
|
25
|
+
'money-advertising': [0, 7, 8, 10],
|
|
26
|
+
'money-gambling': [0, 7, 10, 18],
|
|
27
|
+
'money-purchasing': [0, 12, 14, 15],
|
|
28
|
+
'social-chat': [0, 4, 10, 13],
|
|
29
|
+
'social-audio': [0, 15, 15, 15],
|
|
30
|
+
'social-contacts': [0, 12, 12, 12],
|
|
31
|
+
'social-info': [0, 0, 13, 13],
|
|
32
|
+
'social-location': [0, 13, 13, 13],
|
|
33
|
+
'sex-homosexuality': [0, 13, 14, 15],
|
|
34
|
+
'sex-prostitution': [0, 12, 14, 18],
|
|
35
|
+
'sex-adultery': [0, 8, 10, 18],
|
|
36
|
+
'sex-appearance': [0, 10, 10, 15],
|
|
37
|
+
'violence-worship': [0, 13, 15, 18],
|
|
38
|
+
'violence-desecration': [0, 13, 15, 18],
|
|
39
|
+
'violence-slavery': [0, 13, 15, 18],
|
|
40
|
+
};
|
|
41
|
+
function isOarsValue(value) {
|
|
42
|
+
return value in VALUE_INDEX;
|
|
43
|
+
}
|
|
44
|
+
export function attributeToCsmAge(attribute, value) {
|
|
45
|
+
const row = CSM_AGE[attribute];
|
|
46
|
+
if (!row || !isOarsValue(value))
|
|
47
|
+
return 0;
|
|
48
|
+
return row[VALUE_INDEX[value]];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The highest per-attribute age (`as_content_rating_get_minimum_age()`); 0 means "all ages".
|
|
52
|
+
* Returns `undefined` for rating systems other than OARS 1.0/1.1, where upstream gives no answer.
|
|
53
|
+
*/
|
|
54
|
+
export function minimumAge(kind, attributes) {
|
|
55
|
+
if (kind !== 'oars-1.0' && kind !== 'oars-1.1')
|
|
56
|
+
return undefined;
|
|
57
|
+
let age = 0;
|
|
58
|
+
for (const [attribute, value] of Object.entries(attributes)) {
|
|
59
|
+
age = Math.max(age, attributeToCsmAge(attribute, value));
|
|
60
|
+
}
|
|
61
|
+
return age;
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Localized } from './types.js';
|
|
2
|
+
/** A parsed gettext catalogue: msgid → msgstr, context-free, untranslated and fuzzy entries dropped. */
|
|
3
|
+
export type Catalog = ReadonlyMap<string, string>;
|
|
4
|
+
export interface PoEntry {
|
|
5
|
+
msgctxt?: string;
|
|
6
|
+
msgid: string;
|
|
7
|
+
msgstr: string;
|
|
8
|
+
fuzzy: boolean;
|
|
9
|
+
references: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Parses a `.po`/`.pot` file; plural forms are not needed for web copy and are skipped. */
|
|
12
|
+
export declare function parsePo(text: string): PoEntry[];
|
|
13
|
+
/** Builds a lookup from parsed entries, skipping the header, empty, fuzzy and context-bound entries. */
|
|
14
|
+
export declare function catalogFromEntries(entries: readonly PoEntry[]): Catalog;
|
|
15
|
+
export declare function parseCatalog(text: string): Catalog;
|
|
16
|
+
/** Layers catalogues by language; a later one wins per msgid, so an overlay can correct a base. */
|
|
17
|
+
export declare function mergeCatalogs(...layers: ReadonlyArray<Readonly<Record<string, Catalog>>>): Record<string, Catalog>;
|
|
18
|
+
/** Escapes a string for a `.po` file value. */
|
|
19
|
+
export declare function quotePo(value: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Language tags the metainfo (`pt-BR`, BCP 47) and gettext (`pt_BR`) spell differently, from most to
|
|
22
|
+
* least specific: `pt-BR`, `pt_BR`, `pt`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function languageCandidates(language: string): string[];
|
|
25
|
+
/** Finds the entry for a language in a map keyed by either spelling. */
|
|
26
|
+
export declare function pickByLanguage<T>(byLanguage: Readonly<Record<string, T>>, language: string): T | undefined;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves a text for a language. Precedence: the metainfo's own `xml:lang` translation, then the
|
|
29
|
+
* app catalogue (msgid = the source text), then the source text itself.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveLocalized(text: Localized, language: string, catalog?: Catalog): string;
|
|
32
|
+
/** The page strings are English msgids, so English needs no catalogue. */
|
|
33
|
+
export declare const UI_SOURCE_LANGUAGE = "en";
|
|
34
|
+
/** A translator bound to one catalogue; unknown msgids come back unchanged, which is gettext's contract. */
|
|
35
|
+
export declare function translator(catalog: Catalog | undefined): (msgid: string) => string;
|
|
36
|
+
/**
|
|
37
|
+
* Fills in the translations a text lacks for the site's languages, from the app catalogues
|
|
38
|
+
* (keyed by language). Translations the metainfo already carries win; other metainfo languages are dropped.
|
|
39
|
+
*/
|
|
40
|
+
export declare function completeLocalized(text: Localized, languages: readonly string[], sourceLanguage: string, catalogs: Readonly<Record<string, Catalog>>): Localized;
|