@gjsify/site 0.0.0-stage → 0.63.1-next.236
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 +78 -5
- package/po/de.po +265 -0
- package/po/site.pot +264 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const ESCAPES = { n: '\n', t: '\t', r: '\r', '"': '"', '\\': '\\' };
|
|
2
|
+
function unquote(raw) {
|
|
3
|
+
const body = raw.trim().slice(1, -1);
|
|
4
|
+
return body.replace(/\\(.)/g, (_match, char) => ESCAPES[char] ?? char);
|
|
5
|
+
}
|
|
6
|
+
/** Parses a `.po`/`.pot` file; plural forms are not needed for web copy and are skipped. */
|
|
7
|
+
export function parsePo(text) {
|
|
8
|
+
const entries = [];
|
|
9
|
+
let current = {};
|
|
10
|
+
let flags = '';
|
|
11
|
+
let references = [];
|
|
12
|
+
const flush = () => {
|
|
13
|
+
if (current.msgid !== undefined && current.msgstr !== undefined) {
|
|
14
|
+
entries.push({
|
|
15
|
+
msgctxt: current.msgctxt,
|
|
16
|
+
msgid: current.msgid,
|
|
17
|
+
msgstr: current.msgstr,
|
|
18
|
+
fuzzy: /\bfuzzy\b/.test(flags),
|
|
19
|
+
references,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
current = {};
|
|
23
|
+
flags = '';
|
|
24
|
+
references = [];
|
|
25
|
+
};
|
|
26
|
+
for (const line of text.split(/\r?\n/)) {
|
|
27
|
+
if (line.startsWith('#,')) {
|
|
28
|
+
flags += ` ${line.slice(2)}`;
|
|
29
|
+
}
|
|
30
|
+
else if (line.startsWith('#:')) {
|
|
31
|
+
references.push(...line.slice(2).trim().split(/\s+/));
|
|
32
|
+
}
|
|
33
|
+
else if (line.startsWith('#') || line.trim() === '') {
|
|
34
|
+
if (line.trim() === '' && current.msgid !== undefined)
|
|
35
|
+
flush();
|
|
36
|
+
}
|
|
37
|
+
else if (line.startsWith('msgctxt ')) {
|
|
38
|
+
if (current.msgid !== undefined)
|
|
39
|
+
flush();
|
|
40
|
+
current.msgctxt = unquote(line.slice(8));
|
|
41
|
+
current.field = 'msgctxt';
|
|
42
|
+
}
|
|
43
|
+
else if (line.startsWith('msgid_plural ')) {
|
|
44
|
+
current.field = 'skip';
|
|
45
|
+
}
|
|
46
|
+
else if (line.startsWith('msgid ')) {
|
|
47
|
+
if (current.msgid !== undefined)
|
|
48
|
+
flush();
|
|
49
|
+
current.msgid = unquote(line.slice(6));
|
|
50
|
+
current.field = 'msgid';
|
|
51
|
+
}
|
|
52
|
+
else if (/^msgstr(\[0\])? /.test(line)) {
|
|
53
|
+
current.msgstr = unquote(line.slice(line.indexOf(' ') + 1));
|
|
54
|
+
current.field = 'msgstr';
|
|
55
|
+
}
|
|
56
|
+
else if (/^msgstr\[\d+\] /.test(line)) {
|
|
57
|
+
current.field = 'skip';
|
|
58
|
+
}
|
|
59
|
+
else if (line.startsWith('"') && current.field && current.field !== 'skip') {
|
|
60
|
+
current[current.field] = (current[current.field] ?? '') + unquote(line);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
flush();
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
/** Builds a lookup from parsed entries, skipping the header, empty, fuzzy and context-bound entries. */
|
|
67
|
+
export function catalogFromEntries(entries) {
|
|
68
|
+
const catalog = new Map();
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
if (entry.msgid === '' || entry.msgstr === '' || entry.fuzzy || entry.msgctxt !== undefined)
|
|
71
|
+
continue;
|
|
72
|
+
catalog.set(entry.msgid, entry.msgstr);
|
|
73
|
+
}
|
|
74
|
+
return catalog;
|
|
75
|
+
}
|
|
76
|
+
export function parseCatalog(text) {
|
|
77
|
+
return catalogFromEntries(parsePo(text));
|
|
78
|
+
}
|
|
79
|
+
/** Layers catalogues by language; a later one wins per msgid, so an overlay can correct a base. */
|
|
80
|
+
export function mergeCatalogs(...layers) {
|
|
81
|
+
const merged = {};
|
|
82
|
+
for (const layer of layers) {
|
|
83
|
+
for (const [language, catalog] of Object.entries(layer)) {
|
|
84
|
+
const target = (merged[language] ??= new Map());
|
|
85
|
+
for (const [msgid, msgstr] of catalog)
|
|
86
|
+
target.set(msgid, msgstr);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return merged;
|
|
90
|
+
}
|
|
91
|
+
/** Escapes a string for a `.po` file value. */
|
|
92
|
+
export function quotePo(value) {
|
|
93
|
+
return `"${value.replace(/[\\"\n\t\r]/g, (char) => (char === '\n' ? '\\n' : char === '\t' ? '\\t' : char === '\r' ? '\\r' : `\\${char}`))}"`;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Language tags the metainfo (`pt-BR`, BCP 47) and gettext (`pt_BR`) spell differently, from most to
|
|
97
|
+
* least specific: `pt-BR`, `pt_BR`, `pt`.
|
|
98
|
+
*/
|
|
99
|
+
export function languageCandidates(language) {
|
|
100
|
+
const dashed = language.replace(/_/g, '-');
|
|
101
|
+
const candidates = [dashed, dashed.replace(/-/g, '_')];
|
|
102
|
+
const base = dashed.split('-')[0];
|
|
103
|
+
if (base && base !== dashed)
|
|
104
|
+
candidates.push(base);
|
|
105
|
+
return [...new Set(candidates)];
|
|
106
|
+
}
|
|
107
|
+
/** Finds the entry for a language in a map keyed by either spelling. */
|
|
108
|
+
export function pickByLanguage(byLanguage, language) {
|
|
109
|
+
for (const candidate of languageCandidates(language)) {
|
|
110
|
+
if (candidate in byLanguage)
|
|
111
|
+
return byLanguage[candidate];
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Resolves a text for a language. Precedence: the metainfo's own `xml:lang` translation, then the
|
|
117
|
+
* app catalogue (msgid = the source text), then the source text itself.
|
|
118
|
+
*/
|
|
119
|
+
export function resolveLocalized(text, language, catalog) {
|
|
120
|
+
const fromMetainfo = pickByLanguage(text.byLanguage, language);
|
|
121
|
+
if (fromMetainfo !== undefined)
|
|
122
|
+
return fromMetainfo;
|
|
123
|
+
const fromCatalog = catalog?.get(text.source);
|
|
124
|
+
return fromCatalog ?? text.source;
|
|
125
|
+
}
|
|
126
|
+
/** The page strings are English msgids, so English needs no catalogue. */
|
|
127
|
+
export const UI_SOURCE_LANGUAGE = 'en';
|
|
128
|
+
/** A translator bound to one catalogue; unknown msgids come back unchanged, which is gettext's contract. */
|
|
129
|
+
export function translator(catalog) {
|
|
130
|
+
return (msgid) => catalog?.get(msgid) ?? msgid;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Fills in the translations a text lacks for the site's languages, from the app catalogues
|
|
134
|
+
* (keyed by language). Translations the metainfo already carries win; other metainfo languages are dropped.
|
|
135
|
+
*/
|
|
136
|
+
export function completeLocalized(text, languages, sourceLanguage, catalogs) {
|
|
137
|
+
const byLanguage = {};
|
|
138
|
+
for (const language of languages) {
|
|
139
|
+
if (language === sourceLanguage)
|
|
140
|
+
continue;
|
|
141
|
+
const fromMetainfo = pickByLanguage(text.byLanguage, language);
|
|
142
|
+
const fromCatalog = catalogs[language]?.get(text.source);
|
|
143
|
+
const resolved = fromMetainfo ?? fromCatalog;
|
|
144
|
+
if (resolved !== undefined)
|
|
145
|
+
byLanguage[language] = resolved;
|
|
146
|
+
}
|
|
147
|
+
return { source: text.source, byLanguage };
|
|
148
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type NetworkOptions } from './network.js';
|
|
2
|
+
import type { Metainfo, Screenshot } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The repo-relative path a `raw.githubusercontent.com` URL points at, or `undefined` when the URL
|
|
5
|
+
* belongs to some other repository or host.
|
|
6
|
+
*
|
|
7
|
+
* A metainfo pins its screenshots to a tag, so the URL serves the artwork of the release it was
|
|
8
|
+
* written for. The checkout being built carries that same file — and, between releases, a newer
|
|
9
|
+
* one — so reading it locally is both faster and more truthful than downloading the tagged copy.
|
|
10
|
+
*/
|
|
11
|
+
export declare function repositoryPath(url: string, repository: string): string | undefined;
|
|
12
|
+
/** Flattens a path into one filename stem, so `misc/1.png` and `doc/1.png` stay apart. */
|
|
13
|
+
export declare function imageSlug(path: string): string;
|
|
14
|
+
export interface ScreenshotOptions {
|
|
15
|
+
/** Repository root, which a `raw.githubusercontent.com` URL into this repository is resolved against. */
|
|
16
|
+
root: string;
|
|
17
|
+
/** `owner/name` of this repository, from `gjsify.site.repository`. */
|
|
18
|
+
repository: string;
|
|
19
|
+
/** Where artwork from other hosts is kept, so a later offline build finds it again. */
|
|
20
|
+
cacheDir?: string;
|
|
21
|
+
/** No downloads; a screenshot with no local or cached file is skipped with a warning. */
|
|
22
|
+
offline?: boolean;
|
|
23
|
+
network?: NetworkOptions;
|
|
24
|
+
warn?: (message: string) => void;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The metainfo `<screenshots>` list with every image located on disk — the same artwork Flathub
|
|
28
|
+
* shows, in the same order, so the page doubles as a preview of the store listing.
|
|
29
|
+
*
|
|
30
|
+
* A screenshot whose file cannot be found or fetched is skipped with a warning: a page missing one
|
|
31
|
+
* image is better than no page at all, and the warning names what to fix.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveScreenshots(metainfo: Metainfo, options: ScreenshotOptions): Promise<Screenshot[]>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { extname, join, resolve } from 'node:path';
|
|
5
|
+
import { fetchBinary } from './network.js';
|
|
6
|
+
const RAW_GITHUB = 'https://raw.githubusercontent.com/';
|
|
7
|
+
/**
|
|
8
|
+
* The repo-relative path a `raw.githubusercontent.com` URL points at, or `undefined` when the URL
|
|
9
|
+
* belongs to some other repository or host.
|
|
10
|
+
*
|
|
11
|
+
* A metainfo pins its screenshots to a tag, so the URL serves the artwork of the release it was
|
|
12
|
+
* written for. The checkout being built carries that same file — and, between releases, a newer
|
|
13
|
+
* one — so reading it locally is both faster and more truthful than downloading the tagged copy.
|
|
14
|
+
*/
|
|
15
|
+
export function repositoryPath(url, repository) {
|
|
16
|
+
if (!url.startsWith(RAW_GITHUB))
|
|
17
|
+
return undefined;
|
|
18
|
+
const [owner, name, ...rest] = urlPath(url).split('/').slice(1);
|
|
19
|
+
if (!owner || !name || rest.length === 0)
|
|
20
|
+
return undefined;
|
|
21
|
+
if (`${owner}/${name}`.toLowerCase() !== repository.toLowerCase())
|
|
22
|
+
return undefined;
|
|
23
|
+
// The ref is one segment (a branch, a commit) or three (`refs/tags/v1.2.0`, which is what the
|
|
24
|
+
// GitHub web UI copies and what metainfo files therefore tend to carry).
|
|
25
|
+
const drop = rest[0] === 'refs' && rest.length > 3 ? 3 : 1;
|
|
26
|
+
const path = rest.slice(drop).join('/');
|
|
27
|
+
return path === '' ? undefined : decodeURIComponent(path);
|
|
28
|
+
}
|
|
29
|
+
/** The path of a URL, leading slash included, without query or fragment. */
|
|
30
|
+
function urlPath(url) {
|
|
31
|
+
const host = url.replace(/^[a-z]+:\/\//i, '');
|
|
32
|
+
const slash = host.indexOf('/');
|
|
33
|
+
return slash === -1 ? '/' : host.slice(slash).split(/[?#]/)[0];
|
|
34
|
+
}
|
|
35
|
+
/** Flattens a path into one filename stem, so `misc/1.png` and `doc/1.png` stay apart. */
|
|
36
|
+
export function imageSlug(path) {
|
|
37
|
+
return path
|
|
38
|
+
.replace(/\.[^./]+$/, '')
|
|
39
|
+
.replace(/[^a-zA-Z0-9.]+/g, '-')
|
|
40
|
+
.replace(/^-+|-+$/g, '')
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The metainfo `<screenshots>` list with every image located on disk — the same artwork Flathub
|
|
45
|
+
* shows, in the same order, so the page doubles as a preview of the store listing.
|
|
46
|
+
*
|
|
47
|
+
* A screenshot whose file cannot be found or fetched is skipped with a warning: a page missing one
|
|
48
|
+
* image is better than no page at all, and the warning names what to fix.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveScreenshots(metainfo, options) {
|
|
51
|
+
const warn = options.warn ?? (() => { });
|
|
52
|
+
const store = options.cacheDir ?? join(tmpdir(), 'gjsify-site-screenshots');
|
|
53
|
+
const screenshots = [];
|
|
54
|
+
for (const [index, entry] of metainfo.screenshots.entries()) {
|
|
55
|
+
const label = `Screenshot ${index + 1}`;
|
|
56
|
+
if (!entry.url) {
|
|
57
|
+
warn(`${label} of the metainfo has no <image>; skipping it`);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const repoPath = repositoryPath(entry.url, options.repository);
|
|
61
|
+
const slug = imageSlug(repoPath ?? urlPath(entry.url));
|
|
62
|
+
const screenshot = { caption: entry.caption, url: entry.url, slug };
|
|
63
|
+
if (repoPath !== undefined) {
|
|
64
|
+
const file = resolve(options.root, repoPath);
|
|
65
|
+
if (!existsSync(file)) {
|
|
66
|
+
warn(`${label} points at "${repoPath}" of this repository, which does not exist; skipping it`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
screenshots.push({ ...screenshot, file });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const file = join(store, `${slug}${extname(urlPath(entry.url)) || '.png'}`);
|
|
73
|
+
if (!existsSync(file)) {
|
|
74
|
+
if (options.offline) {
|
|
75
|
+
warn(`${label} is hosted at ${entry.url}; skipping it because this build is offline`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
await mkdir(store, { recursive: true });
|
|
80
|
+
await writeFile(file, await fetchBinary(entry.url, options.network ?? {}));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
warn(`Could not download ${label} from ${entry.url} (${String(error)}); skipping it`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
screenshots.push({ ...screenshot, file });
|
|
88
|
+
}
|
|
89
|
+
return screenshots;
|
|
90
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Upper case, no spaces: the form an IBAN is stored and compared in. */
|
|
2
|
+
export declare function normalizeIban(iban: string): string;
|
|
3
|
+
/** Format check plus the ISO 7064 mod-97 checksum; expects a normalised IBAN. */
|
|
4
|
+
export declare function isValidIban(iban: string): boolean;
|
|
5
|
+
export declare function isValidBic(bic: string): boolean;
|
|
6
|
+
/** Groups of four, as banks print it. */
|
|
7
|
+
export declare function formatIban(iban: string): string;
|
|
8
|
+
export interface EpcRecipient {
|
|
9
|
+
name: string;
|
|
10
|
+
iban: string;
|
|
11
|
+
bic?: string;
|
|
12
|
+
reference?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The text of an EPC QR code (EPC069-12 v002): service tag BCD, version 002, charset 1 (UTF-8),
|
|
16
|
+
* identification SCT, then BIC, name, IBAN, amount, purpose, structured reference and the free
|
|
17
|
+
* text. The amount stays empty so the donor chooses it in their banking app. Lines are joined by
|
|
18
|
+
* LF with no trailing newline, which the spec allows for omitted trailing elements.
|
|
19
|
+
*/
|
|
20
|
+
export declare function epcPayload(recipient: EpcRecipient): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Upper case, no spaces: the form an IBAN is stored and compared in. */
|
|
2
|
+
export function normalizeIban(iban) {
|
|
3
|
+
return iban.replace(/\s+/g, '').toUpperCase();
|
|
4
|
+
}
|
|
5
|
+
/** Format check plus the ISO 7064 mod-97 checksum; expects a normalised IBAN. */
|
|
6
|
+
export function isValidIban(iban) {
|
|
7
|
+
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(iban))
|
|
8
|
+
return false;
|
|
9
|
+
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
|
10
|
+
let remainder = 0;
|
|
11
|
+
for (const char of rearranged) {
|
|
12
|
+
const digits = /\d/.test(char) ? char : String(char.charCodeAt(0) - 55);
|
|
13
|
+
for (const digit of digits)
|
|
14
|
+
remainder = (remainder * 10 + Number(digit)) % 97;
|
|
15
|
+
}
|
|
16
|
+
return remainder === 1;
|
|
17
|
+
}
|
|
18
|
+
export function isValidBic(bic) {
|
|
19
|
+
return /^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic);
|
|
20
|
+
}
|
|
21
|
+
/** Groups of four, as banks print it. */
|
|
22
|
+
export function formatIban(iban) {
|
|
23
|
+
return normalizeIban(iban).replace(/(.{4})(?=.)/g, '$1 ');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The text of an EPC QR code (EPC069-12 v002): service tag BCD, version 002, charset 1 (UTF-8),
|
|
27
|
+
* identification SCT, then BIC, name, IBAN, amount, purpose, structured reference and the free
|
|
28
|
+
* text. The amount stays empty so the donor chooses it in their banking app. Lines are joined by
|
|
29
|
+
* LF with no trailing newline, which the spec allows for omitted trailing elements.
|
|
30
|
+
*/
|
|
31
|
+
export function epcPayload(recipient) {
|
|
32
|
+
return [
|
|
33
|
+
'BCD',
|
|
34
|
+
'002',
|
|
35
|
+
'1',
|
|
36
|
+
'SCT',
|
|
37
|
+
recipient.bic ?? '',
|
|
38
|
+
recipient.name,
|
|
39
|
+
normalizeIban(recipient.iban),
|
|
40
|
+
'',
|
|
41
|
+
'',
|
|
42
|
+
'',
|
|
43
|
+
recipient.reference ?? '',
|
|
44
|
+
].join('\n');
|
|
45
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/** Operating systems a download can target. `web` covers the browser build and a self-hosting archive. */
|
|
2
|
+
export type TargetOs = 'linux' | 'darwin' | 'win32' | 'web' | 'android';
|
|
3
|
+
/** Browsers a WebExtension can target; the Chromium family counts as one. */
|
|
4
|
+
export type TargetBrowser = 'firefox' | 'chromium';
|
|
5
|
+
/** What a download group is keyed by: the OS a file runs on, or the browser an extension runs in. */
|
|
6
|
+
export type TargetPlatform = TargetOs | TargetBrowser;
|
|
7
|
+
/** `noarch` means one file serves every architecture (Flatpak, `.deb` "all", the web build). */
|
|
8
|
+
export type TargetArch = 'x86_64' | 'arm64' | 'noarch';
|
|
9
|
+
export type TargetFormat = 'flathub' | 'flatpak' | 'appimage' | 'deb' | 'rpm' | 'msi' | 'zip' | 'dmg' | 'apk' | 'web' | 'store' | 'xpi' | 'source';
|
|
10
|
+
/**
|
|
11
|
+
* Text with the untranslated source and any translations found in the metainfo. `source` is
|
|
12
|
+
* written in the site's default language; `byLanguage` is keyed by BCP 47 tag as the metainfo
|
|
13
|
+
* spells it (`de`, `pt-BR`).
|
|
14
|
+
*/
|
|
15
|
+
export interface Localized {
|
|
16
|
+
source: string;
|
|
17
|
+
byLanguage: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
export type DescriptionBlock = {
|
|
20
|
+
type: 'paragraph';
|
|
21
|
+
text: Localized;
|
|
22
|
+
} | {
|
|
23
|
+
type: 'list';
|
|
24
|
+
items: Localized[];
|
|
25
|
+
};
|
|
26
|
+
/** A plain link to a place that takes donations or sponsorship; `kind` is a msgid of the website catalogue. */
|
|
27
|
+
export interface SiteSupportLink {
|
|
28
|
+
kind: string;
|
|
29
|
+
url: string;
|
|
30
|
+
}
|
|
31
|
+
/** A SEPA bank transfer, shown as account details and an EPC QR code. The IBAN is stored normalised. */
|
|
32
|
+
export interface SiteSupportSepa {
|
|
33
|
+
kind: 'sepa';
|
|
34
|
+
name: string;
|
|
35
|
+
iban: string;
|
|
36
|
+
bic?: string;
|
|
37
|
+
reference?: string;
|
|
38
|
+
}
|
|
39
|
+
export type SiteSupportEntry = SiteSupportLink | SiteSupportSepa;
|
|
40
|
+
export interface SiteLegalLink {
|
|
41
|
+
label: string;
|
|
42
|
+
url: string;
|
|
43
|
+
}
|
|
44
|
+
/** One entry of `gjsify.site.downloads`: a target the release assets cannot supply (a store page, the install docs). */
|
|
45
|
+
export interface SiteDownloadConfig {
|
|
46
|
+
platform: TargetPlatform;
|
|
47
|
+
format: TargetFormat;
|
|
48
|
+
/** Required unless `available` is false. */
|
|
49
|
+
url?: string;
|
|
50
|
+
/** English label, translated through the website catalogue; a default exists per platform and format. */
|
|
51
|
+
label?: string;
|
|
52
|
+
/** False lists the target as "not yet available" without a link. Default true. */
|
|
53
|
+
available: boolean;
|
|
54
|
+
}
|
|
55
|
+
/** The `gjsify.site` block of the root package.json, with defaults applied. */
|
|
56
|
+
export interface SiteConfig {
|
|
57
|
+
metainfo: string;
|
|
58
|
+
base: string;
|
|
59
|
+
url: string;
|
|
60
|
+
languages: string[];
|
|
61
|
+
/** Language `/` falls back to when the visitor's languages match none of `languages`. */
|
|
62
|
+
defaultLanguage: string;
|
|
63
|
+
/** Language of the metainfo's untranslated text; the other languages are looked up in the catalogues. */
|
|
64
|
+
sourceLanguage: string;
|
|
65
|
+
repository: string;
|
|
66
|
+
flathub?: string;
|
|
67
|
+
webApp?: string;
|
|
68
|
+
/** Directory holding the app's `<lang>.po` catalogues, used when the metainfo lacks a translation. */
|
|
69
|
+
translations?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Directory holding `<lang>.po` catalogues for the strings this configuration itself introduces —
|
|
72
|
+
* the download labels. They overlay the page catalogues shipped with the renderer, which cannot
|
|
73
|
+
* know a consumer's own copy.
|
|
74
|
+
*/
|
|
75
|
+
pageTranslations?: string;
|
|
76
|
+
/** Repo-relative app icon; defaults to the Flatpak or `ship` icon of the root package.json. */
|
|
77
|
+
icon?: string;
|
|
78
|
+
/** Extra download targets, added to the ones derived from the latest release. */
|
|
79
|
+
downloads: SiteDownloadConfig[];
|
|
80
|
+
/** False ignores the latest release's assets, so `downloads` is the whole list. Default true. */
|
|
81
|
+
releaseAssets: boolean;
|
|
82
|
+
support: SiteSupportEntry[];
|
|
83
|
+
legal: SiteLegalLink[];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* One entry of the metainfo `<screenshots>` list, with its artwork located on disk. The same images
|
|
87
|
+
* Flathub shows, so the page is a preview of the store listing.
|
|
88
|
+
*/
|
|
89
|
+
export interface Screenshot {
|
|
90
|
+
/** The metainfo caption, translated where the metainfo or a catalogue has a translation. */
|
|
91
|
+
caption: Localized | null;
|
|
92
|
+
/** The `<image>` URL, kept for reference. */
|
|
93
|
+
url: string;
|
|
94
|
+
/** Absolute path of the artwork: the repository's own file, or the downloaded copy. */
|
|
95
|
+
file: string;
|
|
96
|
+
/** Stem of the published renditions, derived from the URL path; also the key of the image map. */
|
|
97
|
+
slug: string;
|
|
98
|
+
}
|
|
99
|
+
export interface Release {
|
|
100
|
+
version: string;
|
|
101
|
+
date: string;
|
|
102
|
+
type?: string;
|
|
103
|
+
description: DescriptionBlock[];
|
|
104
|
+
}
|
|
105
|
+
export interface DownloadTarget {
|
|
106
|
+
/** The group the target belongs to: an operating system, or a browser for extensions. */
|
|
107
|
+
os: TargetPlatform;
|
|
108
|
+
arch: TargetArch;
|
|
109
|
+
format: TargetFormat;
|
|
110
|
+
/** English label; the page translates it through the website catalogue. */
|
|
111
|
+
label: string;
|
|
112
|
+
/** Empty when the target is not available yet. */
|
|
113
|
+
url: string;
|
|
114
|
+
/** False for a target announced but not downloadable; it is listed, never recommended. */
|
|
115
|
+
available: boolean;
|
|
116
|
+
size?: number;
|
|
117
|
+
downloadCount?: number;
|
|
118
|
+
/** The one target to offer first for its OS. */
|
|
119
|
+
recommended: boolean;
|
|
120
|
+
}
|
|
121
|
+
export interface ReleaseAsset {
|
|
122
|
+
name: string;
|
|
123
|
+
size: number;
|
|
124
|
+
downloadUrl: string;
|
|
125
|
+
downloadCount: number;
|
|
126
|
+
}
|
|
127
|
+
export interface LatestRelease {
|
|
128
|
+
tag: string;
|
|
129
|
+
name: string;
|
|
130
|
+
publishedAt: string;
|
|
131
|
+
url: string;
|
|
132
|
+
assets: ReleaseAsset[];
|
|
133
|
+
}
|
|
134
|
+
export interface AgeRating {
|
|
135
|
+
/** Minimum age in years, derived from the OARS attributes the way libappstream does. */
|
|
136
|
+
minAge: number;
|
|
137
|
+
}
|
|
138
|
+
export interface Branding {
|
|
139
|
+
light?: string;
|
|
140
|
+
dark?: string;
|
|
141
|
+
}
|
|
142
|
+
export interface AppUrls {
|
|
143
|
+
homepage?: string;
|
|
144
|
+
bugtracker?: string;
|
|
145
|
+
vcsBrowser?: string;
|
|
146
|
+
translate?: string;
|
|
147
|
+
donation?: string;
|
|
148
|
+
help?: string;
|
|
149
|
+
faq?: string;
|
|
150
|
+
contact?: string;
|
|
151
|
+
contribute?: string;
|
|
152
|
+
}
|
|
153
|
+
/** What the AppStream metainfo says, before any site configuration is mixed in. */
|
|
154
|
+
export interface Metainfo {
|
|
155
|
+
id: string;
|
|
156
|
+
name: Localized;
|
|
157
|
+
summary: Localized;
|
|
158
|
+
developer: {
|
|
159
|
+
id?: string;
|
|
160
|
+
name: string;
|
|
161
|
+
};
|
|
162
|
+
description: DescriptionBlock[];
|
|
163
|
+
/** Metainfo screenshots in document order; `url` is the remote image, kept for reference. */
|
|
164
|
+
screenshots: Array<{
|
|
165
|
+
url: string;
|
|
166
|
+
caption: Localized | null;
|
|
167
|
+
}>;
|
|
168
|
+
releases: Release[];
|
|
169
|
+
branding: Branding;
|
|
170
|
+
urls: AppUrls;
|
|
171
|
+
keywords: Localized[];
|
|
172
|
+
categories: string[];
|
|
173
|
+
projectLicense?: string;
|
|
174
|
+
/** Licence of the metainfo text itself, which is what the page reproduces. */
|
|
175
|
+
metadataLicense?: string;
|
|
176
|
+
contentRating?: {
|
|
177
|
+
type: string;
|
|
178
|
+
attributes: Record<string, string>;
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export interface Stats {
|
|
182
|
+
/** Total installs reported by Flathub. */
|
|
183
|
+
flathubInstalls?: number;
|
|
184
|
+
flathubInstallsLastMonth?: number;
|
|
185
|
+
/** Sum of GitHub release asset downloads for the latest release. */
|
|
186
|
+
githubDownloads?: number;
|
|
187
|
+
}
|
|
188
|
+
export interface SiteData {
|
|
189
|
+
config: SiteConfig;
|
|
190
|
+
version: string;
|
|
191
|
+
app: {
|
|
192
|
+
id: string;
|
|
193
|
+
name: Localized;
|
|
194
|
+
summary: Localized;
|
|
195
|
+
developer: {
|
|
196
|
+
id?: string;
|
|
197
|
+
name: string;
|
|
198
|
+
};
|
|
199
|
+
description: DescriptionBlock[];
|
|
200
|
+
license?: string;
|
|
201
|
+
/** Licence of the text this page reproduces, as opposed to the app's own. */
|
|
202
|
+
contentLicense?: string;
|
|
203
|
+
keywords: Localized[];
|
|
204
|
+
categories: string[];
|
|
205
|
+
urls: AppUrls;
|
|
206
|
+
branding: Branding;
|
|
207
|
+
ageRating?: AgeRating;
|
|
208
|
+
/** Repo-relative path to the app icon. */
|
|
209
|
+
icon?: string;
|
|
210
|
+
};
|
|
211
|
+
screenshots: Screenshot[];
|
|
212
|
+
releases: Release[];
|
|
213
|
+
latestRelease: LatestRelease | null;
|
|
214
|
+
downloads: {
|
|
215
|
+
targets: DownloadTarget[];
|
|
216
|
+
/** Always present: the fallback when no asset list could be fetched. */
|
|
217
|
+
releasesUrl: string;
|
|
218
|
+
/** False when GitHub confirmed there is no release yet; an unreachable API still counts as true. */
|
|
219
|
+
hasReleases: boolean;
|
|
220
|
+
};
|
|
221
|
+
stats: Stats;
|
|
222
|
+
/** Non-fatal problems met while collecting data, already logged. */
|
|
223
|
+
warnings: string[];
|
|
224
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** A small element tree: `preserveOrder` output is awkward to walk, and order matters for translations. */
|
|
2
|
+
export interface XmlElement {
|
|
3
|
+
name: string;
|
|
4
|
+
attrs: Record<string, string>;
|
|
5
|
+
children: XmlElement[];
|
|
6
|
+
/** Concatenated text of this element including nested elements (inline markup is flattened). */
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseXml(xml: string): XmlElement;
|
|
10
|
+
export declare function child(element: XmlElement, name: string): XmlElement | undefined;
|
|
11
|
+
export declare function childrenNamed(element: XmlElement, name: string): XmlElement[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
const parser = new XMLParser({
|
|
3
|
+
preserveOrder: true,
|
|
4
|
+
ignoreAttributes: false,
|
|
5
|
+
attributeNamePrefix: '@_',
|
|
6
|
+
trimValues: false,
|
|
7
|
+
processEntities: true,
|
|
8
|
+
htmlEntities: false,
|
|
9
|
+
});
|
|
10
|
+
function convert(nodes) {
|
|
11
|
+
const elements = [];
|
|
12
|
+
for (const node of nodes) {
|
|
13
|
+
const name = Object.keys(node).find((key) => key !== ':@');
|
|
14
|
+
if (!name || name === '#text' || name === '#comment' || name.startsWith('?'))
|
|
15
|
+
continue;
|
|
16
|
+
const rawAttrs = (node[':@'] ?? {});
|
|
17
|
+
const attrs = {};
|
|
18
|
+
for (const [key, value] of Object.entries(rawAttrs))
|
|
19
|
+
attrs[key.replace(/^@_/, '')] = String(value);
|
|
20
|
+
const rawChildren = (node[name] ?? []);
|
|
21
|
+
const children = convert(rawChildren);
|
|
22
|
+
const element = { name, attrs, children, text: '' };
|
|
23
|
+
element.text = collectText(rawChildren).replace(/\s+/g, ' ').trim();
|
|
24
|
+
elements.push(element);
|
|
25
|
+
}
|
|
26
|
+
return elements;
|
|
27
|
+
}
|
|
28
|
+
function collectText(nodes) {
|
|
29
|
+
let text = '';
|
|
30
|
+
for (const node of nodes) {
|
|
31
|
+
const name = Object.keys(node).find((key) => key !== ':@');
|
|
32
|
+
if (name === '#text')
|
|
33
|
+
text += String(node['#text']);
|
|
34
|
+
else if (name)
|
|
35
|
+
text += collectText((node[name] ?? []));
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
export function parseXml(xml) {
|
|
40
|
+
const root = convert(parser.parse(xml));
|
|
41
|
+
const element = root[0];
|
|
42
|
+
if (!element)
|
|
43
|
+
throw new Error('XML document has no root element');
|
|
44
|
+
return element;
|
|
45
|
+
}
|
|
46
|
+
export function child(element, name) {
|
|
47
|
+
return element.children.find((candidate) => candidate.name === name);
|
|
48
|
+
}
|
|
49
|
+
export function childrenNamed(element, name) {
|
|
50
|
+
return element.children.filter((candidate) => candidate.name === name);
|
|
51
|
+
}
|
package/lib/site.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type I18nResult } from './render/i18n.js';
|
|
2
|
+
/** Where a build reads from and writes to. */
|
|
3
|
+
export interface SiteTarget {
|
|
4
|
+
/** Repository root: the root package.json, the metainfo, the screenshots and the icon are read from here. */
|
|
5
|
+
root: string;
|
|
6
|
+
/** Output directory, emptied first. */
|
|
7
|
+
out: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SiteBuildOptions extends SiteTarget {
|
|
10
|
+
/** Skip every network call; the page is rendered from what is in the repository. */
|
|
11
|
+
offline?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Where the one-hour HTTP response cache and the image renditions live; `false` disables the
|
|
14
|
+
* response cache. A published package must not write inside its own install, so the default is
|
|
15
|
+
* the consumer project's own build cache.
|
|
16
|
+
*/
|
|
17
|
+
cacheDir?: string | false;
|
|
18
|
+
/** Raises the GitHub API rate limit. Optional — the public limit is enough for a single build. */
|
|
19
|
+
githubToken?: string;
|
|
20
|
+
log?: (message: string) => void;
|
|
21
|
+
warn?: (message: string) => void;
|
|
22
|
+
/** This package's install, for a caller whose module URL does not reveal it — a bundle. */
|
|
23
|
+
packageDir?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface SiteBuildResult {
|
|
26
|
+
/** The languages that were rendered, in configuration order. */
|
|
27
|
+
languages: string[];
|
|
28
|
+
/** Screenshot source paths that were turned into renditions. */
|
|
29
|
+
screenshots: number;
|
|
30
|
+
}
|
|
31
|
+
export declare function defaultCacheDir(root: string): string;
|
|
32
|
+
/** Renders one static page per configured language, plus the language-picking root page. */
|
|
33
|
+
export declare function buildSite(options: SiteBuildOptions): Promise<SiteBuildResult>;
|
|
34
|
+
export interface SiteI18nOptions {
|
|
35
|
+
/**
|
|
36
|
+
* The repository whose `gjsify.site` strings are synced into its `pageTranslations`. Omitted,
|
|
37
|
+
* this package's own `po/` is synced from the renderer sources instead.
|
|
38
|
+
*/
|
|
39
|
+
root?: string;
|
|
40
|
+
/** Report what a regeneration would change instead of writing it. */
|
|
41
|
+
check?: boolean;
|
|
42
|
+
/** This package's install, for a caller whose module URL does not reveal it — a bundle. */
|
|
43
|
+
packageDir?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Keeps gettext catalogues in sync with the strings that can actually be emitted — this package's
|
|
47
|
+
* own `po/` when called without a root, otherwise the consumer's `pageTranslations`.
|
|
48
|
+
*/
|
|
49
|
+
export declare function syncSiteI18n(options?: SiteI18nOptions): Promise<I18nResult>;
|