@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.
Files changed (56) hide show
  1. package/README.md +165 -2
  2. package/lib/cli.d.ts +6 -0
  3. package/lib/cli.js +91 -0
  4. package/lib/client/site.css +1286 -0
  5. package/lib/client/site.js +1 -0
  6. package/lib/index.d.ts +9 -0
  7. package/lib/index.js +9 -0
  8. package/lib/render/color.d.ts +12 -0
  9. package/lib/render/color.js +45 -0
  10. package/lib/render/html.d.ts +8 -0
  11. package/lib/render/html.js +20 -0
  12. package/lib/render/i18n.d.ts +38 -0
  13. package/lib/render/i18n.js +106 -0
  14. package/lib/render/icons.d.ts +14 -0
  15. package/lib/render/icons.js +42 -0
  16. package/lib/render/image-backend.d.ts +26 -0
  17. package/lib/render/image-backend.js +130 -0
  18. package/lib/render/images.d.ts +51 -0
  19. package/lib/render/images.js +97 -0
  20. package/lib/render/pages.d.ts +32 -0
  21. package/lib/render/pages.js +628 -0
  22. package/lib/render/qr.d.ts +8 -0
  23. package/lib/render/qr.js +25 -0
  24. package/lib/site-data/config.d.ts +23 -0
  25. package/lib/site-data/config.js +142 -0
  26. package/lib/site-data/downloads.d.ts +29 -0
  27. package/lib/site-data/downloads.js +193 -0
  28. package/lib/site-data/flathub.d.ts +24 -0
  29. package/lib/site-data/flathub.js +35 -0
  30. package/lib/site-data/github.d.ts +9 -0
  31. package/lib/site-data/github.js +45 -0
  32. package/lib/site-data/index.d.ts +12 -0
  33. package/lib/site-data/index.js +12 -0
  34. package/lib/site-data/load.d.ts +18 -0
  35. package/lib/site-data/load.js +164 -0
  36. package/lib/site-data/metainfo.d.ts +9 -0
  37. package/lib/site-data/metainfo.js +142 -0
  38. package/lib/site-data/network.d.ts +32 -0
  39. package/lib/site-data/network.js +67 -0
  40. package/lib/site-data/oars.d.ts +15 -0
  41. package/lib/site-data/oars.js +62 -0
  42. package/lib/site-data/po.d.ts +40 -0
  43. package/lib/site-data/po.js +148 -0
  44. package/lib/site-data/screenshots.d.ts +33 -0
  45. package/lib/site-data/screenshots.js +90 -0
  46. package/lib/site-data/sepa.d.ts +20 -0
  47. package/lib/site-data/sepa.js +45 -0
  48. package/lib/site-data/types.d.ts +224 -0
  49. package/lib/site-data/types.js +1 -0
  50. package/lib/site-data/xml.d.ts +11 -0
  51. package/lib/site-data/xml.js +51 -0
  52. package/lib/site.d.ts +49 -0
  53. package/lib/site.js +161 -0
  54. package/package.json +78 -5
  55. package/po/de.po +265 -0
  56. package/po/site.pot +264 -0
@@ -0,0 +1,25 @@
1
+ import qrcode from 'qrcode-generator';
2
+ // The encoder defaults to Latin-1 and would mangle any other character; EPC declares UTF-8.
3
+ qrcode.stringToBytes = (text) => [...new TextEncoder().encode(text)];
4
+ /**
5
+ * Renders `text` as an inline SVG at build time. Error correction M, as the EPC spec prefers
6
+ * (L is the minimum it allows). The quiet zone is part of the SVG and always light, so the code
7
+ * scans on a dark theme too: scanners expect dark modules on a light ground.
8
+ */
9
+ export function qrSvg(text, label, options = {}) {
10
+ const margin = options.margin ?? 4;
11
+ const code = qrcode(0, 'M');
12
+ code.addData(text, 'Byte');
13
+ code.make();
14
+ const count = code.getModuleCount();
15
+ const size = count + margin * 2;
16
+ let path = '';
17
+ for (let row = 0; row < count; row++) {
18
+ for (let col = 0; col < count; col++) {
19
+ if (code.isDark(row, col))
20
+ path += `M${col + margin} ${row + margin}h1v1h-1z`;
21
+ }
22
+ }
23
+ const title = label.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
24
+ return `<svg class="qr" viewBox="0 0 ${size} ${size}" role="img" aria-label="${title}" shape-rendering="crispEdges"><rect width="${size}" height="${size}" fill="#fff"/><path d="${path}" fill="#000"/></svg>`;
25
+ }
@@ -0,0 +1,23 @@
1
+ import type { SiteConfig } from './types.js';
2
+ /** The slice of the root package.json the site reads. Everything is optional until validated. */
3
+ export interface RootManifest {
4
+ version?: string;
5
+ gjsify?: {
6
+ site?: unknown;
7
+ ship?: {
8
+ icon?: string;
9
+ };
10
+ flatpak?: {
11
+ appId?: string;
12
+ icon?: string;
13
+ developer?: {
14
+ id?: string;
15
+ name?: string;
16
+ };
17
+ };
18
+ };
19
+ }
20
+ /** Root-relative directory URL: `/x/` with a leading and trailing slash. */
21
+ export declare function normalizeBase(base: string): string;
22
+ /** Validates `gjsify.site` and applies defaults; the error names the offending key. */
23
+ export declare function parseSiteConfig(manifest: RootManifest): SiteConfig;
@@ -0,0 +1,142 @@
1
+ import { isValidBic, isValidIban, normalizeIban } from './sepa.js';
2
+ function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ function requireString(raw, key) {
6
+ const value = raw[key];
7
+ if (typeof value !== 'string' || value === '')
8
+ throw new Error(`gjsify.site.${key} is required and must be a non-empty string`);
9
+ return value;
10
+ }
11
+ function optionalString(raw, key) {
12
+ const value = raw[key];
13
+ if (value === undefined)
14
+ return undefined;
15
+ if (typeof value !== 'string' || value === '')
16
+ throw new Error(`gjsify.site.${key} must be a non-empty string`);
17
+ return value;
18
+ }
19
+ function objectList(raw, key) {
20
+ const value = raw[key];
21
+ if (value === undefined)
22
+ return [];
23
+ if (!Array.isArray(value) || !value.every(isRecord))
24
+ throw new Error(`gjsify.site.${key} must be a list of objects`);
25
+ return value;
26
+ }
27
+ function stringField(entry, key, where) {
28
+ const value = entry[key];
29
+ if (typeof value !== 'string' || value === '')
30
+ throw new Error(`${where}.${key} must be a non-empty string`);
31
+ return value;
32
+ }
33
+ function parseSupport(entry, where) {
34
+ const kind = stringField(entry, 'kind', where);
35
+ if (kind !== 'sepa')
36
+ return { kind, url: stringField(entry, 'url', where) };
37
+ const optional = (key) => {
38
+ if (entry[key] === undefined)
39
+ return undefined;
40
+ return stringField(entry, key, where);
41
+ };
42
+ const iban = normalizeIban(stringField(entry, 'iban', where));
43
+ if (!isValidIban(iban))
44
+ throw new Error(`${where}.iban is not a valid IBAN (format or checksum)`);
45
+ const bic = optional('bic')?.replace(/\s+/g, '').toUpperCase();
46
+ if (bic !== undefined && !isValidBic(bic))
47
+ throw new Error(`${where}.bic is not a valid BIC (8 or 11 characters)`);
48
+ const name = stringField(entry, 'name', where);
49
+ if ([...name].length > 70)
50
+ throw new Error(`${where}.name is longer than the 70 characters an EPC QR code allows`);
51
+ const reference = optional('reference');
52
+ if (reference !== undefined && [...reference].length > 140) {
53
+ throw new Error(`${where}.reference is longer than the 140 characters an EPC QR code allows`);
54
+ }
55
+ return { kind: 'sepa', name, iban, bic, reference };
56
+ }
57
+ const PLATFORMS = ['linux', 'darwin', 'win32', 'android', 'web', 'firefox', 'chromium'];
58
+ const FORMATS = [
59
+ 'flathub',
60
+ 'flatpak',
61
+ 'appimage',
62
+ 'deb',
63
+ 'rpm',
64
+ 'msi',
65
+ 'zip',
66
+ 'dmg',
67
+ 'apk',
68
+ 'web',
69
+ 'store',
70
+ 'xpi',
71
+ 'source',
72
+ ];
73
+ function parseDownloads(raw) {
74
+ return objectList(raw, 'downloads').map((entry, index) => {
75
+ const where = `gjsify.site.downloads[${index}]`;
76
+ const platform = stringField(entry, 'platform', where);
77
+ if (!PLATFORMS.includes(platform))
78
+ throw new Error(`${where}.platform must be one of ${PLATFORMS.join(', ')}`);
79
+ const format = stringField(entry, 'format', where);
80
+ if (!FORMATS.includes(format))
81
+ throw new Error(`${where}.format must be one of ${FORMATS.join(', ')}`);
82
+ const available = entry.available ?? true;
83
+ if (typeof available !== 'boolean')
84
+ throw new Error(`${where}.available must be true or false`);
85
+ const url = entry.url;
86
+ if (available && (typeof url !== 'string' || !/^https?:\/\//.test(url))) {
87
+ throw new Error(`${where}.url must be an http(s) address unless "available" is false`);
88
+ }
89
+ if (url !== undefined && (typeof url !== 'string' || !/^https?:\/\//.test(url))) {
90
+ throw new Error(`${where}.url must be an http(s) address`);
91
+ }
92
+ const label = entry.label;
93
+ if (label !== undefined && (typeof label !== 'string' || label === ''))
94
+ throw new Error(`${where}.label must be a non-empty string`);
95
+ return { platform, format, url, label, available };
96
+ });
97
+ }
98
+ function parseReleaseAssets(raw) {
99
+ const value = raw.releaseAssets ?? true;
100
+ if (typeof value !== 'boolean')
101
+ throw new Error('gjsify.site.releaseAssets must be true or false');
102
+ return value;
103
+ }
104
+ /** Root-relative directory URL: `/x/` with a leading and trailing slash. */
105
+ export function normalizeBase(base) {
106
+ return `/${base.replace(/^\/+|\/+$/g, '')}/`.replace(/^\/\/$/, '/');
107
+ }
108
+ /** Validates `gjsify.site` and applies defaults; the error names the offending key. */
109
+ export function parseSiteConfig(manifest) {
110
+ const raw = manifest.gjsify?.site;
111
+ if (!isRecord(raw))
112
+ throw new Error('The root package.json has no "gjsify.site" object');
113
+ const languages = raw.languages ?? ['en'];
114
+ if (!Array.isArray(languages) || languages.length === 0 || !languages.every((entry) => typeof entry === 'string')) {
115
+ throw new Error('gjsify.site.languages must be a non-empty list of language codes');
116
+ }
117
+ const defaultLanguage = optionalString(raw, 'defaultLanguage') ?? languages[0];
118
+ if (!languages.includes(defaultLanguage))
119
+ throw new Error(`gjsify.site.defaultLanguage "${defaultLanguage}" is not in languages`);
120
+ const url = requireString(raw, 'url');
121
+ return {
122
+ metainfo: requireString(raw, 'metainfo'),
123
+ base: normalizeBase(requireString(raw, 'base')),
124
+ url: url.endsWith('/') ? url : `${url}/`,
125
+ languages: languages,
126
+ defaultLanguage,
127
+ sourceLanguage: optionalString(raw, 'sourceLanguage') ?? 'en',
128
+ repository: requireString(raw, 'repository'),
129
+ flathub: optionalString(raw, 'flathub'),
130
+ webApp: optionalString(raw, 'webApp'),
131
+ translations: optionalString(raw, 'translations'),
132
+ pageTranslations: optionalString(raw, 'pageTranslations'),
133
+ icon: optionalString(raw, 'icon'),
134
+ downloads: parseDownloads(raw),
135
+ releaseAssets: parseReleaseAssets(raw),
136
+ support: objectList(raw, 'support').map((entry, index) => parseSupport(entry, `gjsify.site.support[${index}]`)),
137
+ legal: objectList(raw, 'legal').map((entry, index) => ({
138
+ label: stringField(entry, 'label', `gjsify.site.legal[${index}]`),
139
+ url: stringField(entry, 'url', `gjsify.site.legal[${index}]`),
140
+ })),
141
+ };
142
+ }
@@ -0,0 +1,29 @@
1
+ import type { DownloadTarget, ReleaseAsset, SiteDownloadConfig, TargetArch, TargetFormat, TargetPlatform } from './types.js';
2
+ /** Browser extensions are listed after the OS builds; a page usually has only one kind. */
3
+ export declare const PLATFORM_ORDER: readonly TargetPlatform[];
4
+ /** Platforms that are browsers: their targets are picked by the visitor's browser, not OS. */
5
+ export declare function isBrowserPlatform(platform: TargetPlatform): platform is 'firefox' | 'chromium';
6
+ export declare const DOWNLOAD_LABELS: Record<string, string>;
7
+ /** Fallback for a platform and format pair `DOWNLOAD_LABELS` does not name, e.g. `linux:source`. */
8
+ export declare const FORMAT_LABELS: Record<TargetFormat, string>;
9
+ /** English label of a download target: the platform's own wording, else the format's, never the raw id. */
10
+ export declare function defaultLabel(os: TargetPlatform, format: TargetFormat): string;
11
+ /** Maps a release asset to the OS and format it serves, or `null` for files that are not downloads (checksums, sources). */
12
+ export declare function classifyAsset(asset: ReleaseAsset): {
13
+ os: TargetPlatform;
14
+ format: TargetFormat;
15
+ arch: TargetArch;
16
+ } | null;
17
+ export interface DownloadSources {
18
+ assets: readonly ReleaseAsset[];
19
+ /** Flathub application id, when the app is published there. */
20
+ flathub?: string;
21
+ /** Bytes the Flathub install transfers, from its summary endpoint. */
22
+ flathubSize?: number;
23
+ /** Root-relative URL of the live web build. */
24
+ webAppUrl?: string;
25
+ /** `gjsify.site.downloads`: targets no release asset supplies, e.g. a store page. */
26
+ configured?: readonly SiteDownloadConfig[];
27
+ }
28
+ /** Builds the sorted target list and marks the first available target of every platform as recommended. */
29
+ export declare function buildDownloadTargets(sources: DownloadSources): DownloadTarget[];
@@ -0,0 +1,193 @@
1
+ /** Browser extensions are listed after the OS builds; a page usually has only one kind. */
2
+ export const PLATFORM_ORDER = [
3
+ 'linux',
4
+ 'darwin',
5
+ 'win32',
6
+ 'android',
7
+ 'web',
8
+ 'firefox',
9
+ 'chromium',
10
+ ];
11
+ /** Platforms that are browsers: their targets are picked by the visitor's browser, not OS. */
12
+ export function isBrowserPlatform(platform) {
13
+ return platform === 'firefox' || platform === 'chromium';
14
+ }
15
+ /** Best format first, per OS. The first target in this order is the recommended one. */
16
+ const FORMAT_RANK = {
17
+ linux: ['flathub', 'appimage', 'flatpak', 'deb', 'rpm'],
18
+ darwin: ['dmg', 'zip'],
19
+ win32: ['msi', 'zip'],
20
+ android: ['apk'],
21
+ web: ['web', 'zip'],
22
+ firefox: ['store', 'xpi', 'zip', 'source'],
23
+ chromium: ['store', 'zip', 'source'],
24
+ };
25
+ /** Apple silicon first on macOS, the common PC architecture first everywhere else. */
26
+ const ARCH_RANK = {
27
+ linux: ['noarch', 'x86_64', 'arm64'],
28
+ darwin: ['arm64', 'x86_64', 'noarch'],
29
+ win32: ['x86_64', 'arm64', 'noarch'],
30
+ android: ['noarch', 'arm64', 'x86_64'],
31
+ web: ['noarch', 'x86_64', 'arm64'],
32
+ firefox: ['noarch'],
33
+ chromium: ['noarch'],
34
+ };
35
+ export const DOWNLOAD_LABELS = {
36
+ 'linux:flathub': 'Flathub',
37
+ 'linux:flatpak': 'Flatpak bundle',
38
+ 'linux:appimage': 'AppImage',
39
+ 'linux:deb': 'Debian package',
40
+ 'linux:rpm': 'RPM package',
41
+ 'win32:msi': 'Windows installer',
42
+ 'win32:zip': 'Windows portable',
43
+ 'darwin:zip': 'macOS app',
44
+ 'darwin:dmg': 'macOS disk image',
45
+ 'android:apk': 'Android package',
46
+ 'web:web': 'Try in browser',
47
+ 'web:zip': 'Web build',
48
+ 'firefox:store': 'Firefox Add-ons',
49
+ 'firefox:xpi': 'Signed Firefox add-on',
50
+ 'firefox:zip': 'Firefox extension',
51
+ 'firefox:source': 'Build from source',
52
+ 'chromium:store': 'Chrome Web Store',
53
+ 'chromium:zip': 'Chromium extension',
54
+ 'chromium:source': 'Build from source',
55
+ };
56
+ /** Fallback for a platform and format pair `DOWNLOAD_LABELS` does not name, e.g. `linux:source`. */
57
+ export const FORMAT_LABELS = {
58
+ flathub: 'Flathub',
59
+ flatpak: 'Flatpak bundle',
60
+ appimage: 'AppImage',
61
+ deb: 'Debian package',
62
+ rpm: 'RPM package',
63
+ msi: 'Windows installer',
64
+ zip: 'ZIP archive',
65
+ dmg: 'macOS disk image',
66
+ apk: 'Android package',
67
+ web: 'Try in browser',
68
+ store: 'Store',
69
+ xpi: 'Firefox add-on',
70
+ source: 'Source code',
71
+ };
72
+ /** English label of a download target: the platform's own wording, else the format's, never the raw id. */
73
+ export function defaultLabel(os, format) {
74
+ return DOWNLOAD_LABELS[`${os}:${format}`] ?? FORMAT_LABELS[format];
75
+ }
76
+ function detectArch(name) {
77
+ if (/arm64|aarch64/i.test(name))
78
+ return 'arm64';
79
+ if (/x86_64|x64|amd64/i.test(name))
80
+ return 'x86_64';
81
+ return 'noarch';
82
+ }
83
+ /** Maps a release asset to the OS and format it serves, or `null` for files that are not downloads (checksums, sources). */
84
+ export function classifyAsset(asset) {
85
+ const name = asset.name.toLowerCase();
86
+ const arch = detectArch(name);
87
+ if (name.endsWith('.flatpak'))
88
+ return { os: 'linux', format: 'flatpak', arch };
89
+ if (name.endsWith('.appimage'))
90
+ return { os: 'linux', format: 'appimage', arch };
91
+ if (name.endsWith('.deb'))
92
+ return { os: 'linux', format: 'deb', arch };
93
+ if (name.endsWith('.rpm'))
94
+ return { os: 'linux', format: 'rpm', arch };
95
+ if (name.endsWith('.msi'))
96
+ return { os: 'win32', format: 'msi', arch };
97
+ if (name.endsWith('.dmg'))
98
+ return { os: 'darwin', format: 'dmg', arch };
99
+ if (name.endsWith('.apk'))
100
+ return { os: 'android', format: 'apk', arch };
101
+ if (name.endsWith('.xpi'))
102
+ return { os: 'firefox', format: 'xpi', arch: 'noarch' };
103
+ if (name.endsWith('.zip')) {
104
+ if (/windows|win32|win64/.test(name))
105
+ return { os: 'win32', format: 'zip', arch };
106
+ if (/macos|darwin/.test(name))
107
+ return { os: 'darwin', format: 'zip', arch };
108
+ if (/web/.test(name))
109
+ return { os: 'web', format: 'zip', arch };
110
+ if (/firefox/.test(name))
111
+ return { os: 'firefox', format: 'zip', arch: 'noarch' };
112
+ if (/chrome|chromium|edge/.test(name))
113
+ return { os: 'chromium', format: 'zip', arch: 'noarch' };
114
+ }
115
+ return null;
116
+ }
117
+ function rank(order, value) {
118
+ const index = order.indexOf(value);
119
+ return index === -1 ? order.length : index;
120
+ }
121
+ /**
122
+ * What the store install costs to download. Flathub's own number is the honest one; the Flatpak
123
+ * bundle of the same release is close enough to stand in when the API did not answer.
124
+ */
125
+ function storeSize(sources) {
126
+ if (sources.flathubSize !== undefined)
127
+ return sources.flathubSize;
128
+ const bundle = sources.assets.find((asset) => classifyAsset(asset)?.format === 'flatpak');
129
+ return bundle?.size;
130
+ }
131
+ /** Builds the sorted target list and marks the first available target of every platform as recommended. */
132
+ export function buildDownloadTargets(sources) {
133
+ const targets = [];
134
+ if (sources.flathub) {
135
+ targets.push({
136
+ os: 'linux',
137
+ arch: 'noarch',
138
+ format: 'flathub',
139
+ label: defaultLabel('linux', 'flathub'),
140
+ url: `https://flathub.org/apps/${sources.flathub}`,
141
+ available: true,
142
+ size: storeSize(sources),
143
+ recommended: false,
144
+ });
145
+ }
146
+ for (const asset of sources.assets) {
147
+ const kind = classifyAsset(asset);
148
+ if (!kind)
149
+ continue;
150
+ targets.push({
151
+ ...kind,
152
+ label: defaultLabel(kind.os, kind.format),
153
+ url: asset.downloadUrl,
154
+ available: true,
155
+ size: asset.size,
156
+ downloadCount: asset.downloadCount,
157
+ recommended: false,
158
+ });
159
+ }
160
+ if (sources.webAppUrl) {
161
+ targets.push({
162
+ os: 'web',
163
+ arch: 'noarch',
164
+ format: 'web',
165
+ label: defaultLabel('web', 'web'),
166
+ url: sources.webAppUrl,
167
+ available: true,
168
+ recommended: false,
169
+ });
170
+ }
171
+ for (const entry of sources.configured ?? []) {
172
+ targets.push({
173
+ os: entry.platform,
174
+ arch: 'noarch',
175
+ format: entry.format,
176
+ label: entry.label ?? defaultLabel(entry.platform, entry.format),
177
+ url: entry.available ? (entry.url ?? '') : '',
178
+ available: entry.available,
179
+ recommended: false,
180
+ });
181
+ }
182
+ targets.sort((a, b) => rank(PLATFORM_ORDER, a.os) - rank(PLATFORM_ORDER, b.os) ||
183
+ rank(FORMAT_RANK[a.os], a.format) - rank(FORMAT_RANK[b.os], b.format) ||
184
+ rank(ARCH_RANK[a.os], a.arch) - rank(ARCH_RANK[b.os], b.arch));
185
+ const seen = new Set();
186
+ for (const target of targets) {
187
+ if (!target.available || seen.has(target.os))
188
+ continue;
189
+ seen.add(target.os);
190
+ target.recommended = true;
191
+ }
192
+ return targets;
193
+ }
@@ -0,0 +1,24 @@
1
+ import { type NetworkOptions } from './network.js';
2
+ export interface FlathubStats {
3
+ installsTotal: number;
4
+ installsLastMonth?: number;
5
+ }
6
+ /**
7
+ * Parses `https://flathub.org/api/v2/stats/<appid>`. Verified shape (2026-10): an object with
8
+ * `installs_total`, `installs_last_month`, `installs_last_7_days`, `installs_per_day` and
9
+ * `installs_per_country`. Only the totals are used.
10
+ */
11
+ export declare function parseFlathubStats(payload: unknown): FlathubStats;
12
+ export declare function fetchFlathubStats(appId: string, options: NetworkOptions): Promise<FlathubStats>;
13
+ export interface FlathubSummary {
14
+ /** Bytes the install transfers, which is what a download size means for a store install. */
15
+ downloadSize: number;
16
+ installedSize?: number;
17
+ }
18
+ /**
19
+ * Parses `https://flathub.org/api/v2/summary/<appid>`. Verified shape (2026-10): `download_size`,
20
+ * `installed_size`, `branches`, `arches`, `metadata` and `timestamp`, with the sizes in bytes and
21
+ * repeated per branch. The top-level pair describes the branch Flathub installs by default.
22
+ */
23
+ export declare function parseFlathubSummary(payload: unknown): FlathubSummary;
24
+ export declare function fetchFlathubSummary(appId: string, options: NetworkOptions): Promise<FlathubSummary>;
@@ -0,0 +1,35 @@
1
+ import { fetchJson } from './network.js';
2
+ /**
3
+ * Parses `https://flathub.org/api/v2/stats/<appid>`. Verified shape (2026-10): an object with
4
+ * `installs_total`, `installs_last_month`, `installs_last_7_days`, `installs_per_day` and
5
+ * `installs_per_country`. Only the totals are used.
6
+ */
7
+ export function parseFlathubStats(payload) {
8
+ const stats = payload;
9
+ if (typeof stats?.installs_total !== 'number')
10
+ throw new Error('Unexpected Flathub stats payload');
11
+ return {
12
+ installsTotal: stats.installs_total,
13
+ installsLastMonth: typeof stats.installs_last_month === 'number' ? stats.installs_last_month : undefined,
14
+ };
15
+ }
16
+ export async function fetchFlathubStats(appId, options) {
17
+ return parseFlathubStats(await fetchJson(`https://flathub.org/api/v2/stats/${encodeURIComponent(appId)}`, { Accept: 'application/json' }, options));
18
+ }
19
+ /**
20
+ * Parses `https://flathub.org/api/v2/summary/<appid>`. Verified shape (2026-10): `download_size`,
21
+ * `installed_size`, `branches`, `arches`, `metadata` and `timestamp`, with the sizes in bytes and
22
+ * repeated per branch. The top-level pair describes the branch Flathub installs by default.
23
+ */
24
+ export function parseFlathubSummary(payload) {
25
+ const summary = payload;
26
+ if (typeof summary?.download_size !== 'number')
27
+ throw new Error('Unexpected Flathub summary payload');
28
+ return {
29
+ downloadSize: summary.download_size,
30
+ installedSize: typeof summary.installed_size === 'number' ? summary.installed_size : undefined,
31
+ };
32
+ }
33
+ export async function fetchFlathubSummary(appId, options) {
34
+ return parseFlathubSummary(await fetchJson(`https://flathub.org/api/v2/summary/${encodeURIComponent(appId)}`, { Accept: 'application/json' }, options));
35
+ }
@@ -0,0 +1,9 @@
1
+ import { type NetworkOptions } from './network.js';
2
+ import type { LatestRelease } from './types.js';
3
+ /** Parses the GitHub REST `releases/latest` payload; anything malformed throws so the caller can warn and degrade. */
4
+ export declare function parseLatestRelease(payload: unknown): LatestRelease;
5
+ /**
6
+ * Fetches the latest published (non-draft, non-prerelease) release; `null` when the repository has
7
+ * none yet, which is a state and not an error. `token` raises the rate limit.
8
+ */
9
+ export declare function fetchLatestRelease(repository: string, token: string | undefined, options: NetworkOptions): Promise<LatestRelease | null>;
@@ -0,0 +1,45 @@
1
+ import { fetchJson, HttpError } from './network.js';
2
+ /** Parses the GitHub REST `releases/latest` payload; anything malformed throws so the caller can warn and degrade. */
3
+ export function parseLatestRelease(payload) {
4
+ const release = payload;
5
+ if (typeof release?.tag_name !== 'string' || !Array.isArray(release.assets)) {
6
+ throw new Error('Unexpected GitHub release payload');
7
+ }
8
+ const assets = release.assets.flatMap((asset) => typeof asset.name === 'string' && typeof asset.browser_download_url === 'string'
9
+ ? [
10
+ {
11
+ name: asset.name,
12
+ size: typeof asset.size === 'number' ? asset.size : 0,
13
+ downloadUrl: asset.browser_download_url,
14
+ downloadCount: typeof asset.download_count === 'number' ? asset.download_count : 0,
15
+ },
16
+ ]
17
+ : []);
18
+ return {
19
+ tag: release.tag_name,
20
+ name: typeof release.name === 'string' && release.name ? release.name : release.tag_name,
21
+ publishedAt: typeof release.published_at === 'string' ? release.published_at : '',
22
+ url: typeof release.html_url === 'string' ? release.html_url : '',
23
+ assets,
24
+ };
25
+ }
26
+ /**
27
+ * Fetches the latest published (non-draft, non-prerelease) release; `null` when the repository has
28
+ * none yet, which is a state and not an error. `token` raises the rate limit.
29
+ */
30
+ export async function fetchLatestRelease(repository, token, options) {
31
+ const headers = {
32
+ Accept: 'application/vnd.github+json',
33
+ 'User-Agent': `${repository.replace('/', '-')}-website`,
34
+ };
35
+ if (token)
36
+ headers.Authorization = `Bearer ${token}`;
37
+ try {
38
+ return parseLatestRelease(await fetchJson(`https://api.github.com/repos/${repository}/releases/latest`, headers, options));
39
+ }
40
+ catch (error) {
41
+ if (error instanceof HttpError && error.status === 404)
42
+ return null;
43
+ throw error;
44
+ }
45
+ }
@@ -0,0 +1,12 @@
1
+ export * from './config.js';
2
+ export * from './downloads.js';
3
+ export * from './flathub.js';
4
+ export * from './github.js';
5
+ export * from './load.js';
6
+ export * from './metainfo.js';
7
+ export * from './network.js';
8
+ export * from './oars.js';
9
+ export * from './po.js';
10
+ export * from './screenshots.js';
11
+ export * from './sepa.js';
12
+ export * from './types.js';
@@ -0,0 +1,12 @@
1
+ export * from './config.js';
2
+ export * from './downloads.js';
3
+ export * from './flathub.js';
4
+ export * from './github.js';
5
+ export * from './load.js';
6
+ export * from './metainfo.js';
7
+ export * from './network.js';
8
+ export * from './oars.js';
9
+ export * from './po.js';
10
+ export * from './screenshots.js';
11
+ export * from './sepa.js';
12
+ export * from './types.js';
@@ -0,0 +1,18 @@
1
+ import type { NetworkOptions } from './network.js';
2
+ import { type Catalog } from './po.js';
3
+ import type { SiteData } from './types.js';
4
+ export interface LoadOptions {
5
+ /** Repository root: where the root package.json lives and the config's paths start. */
6
+ root: string;
7
+ /** Where screenshots hosted outside this repository are kept, so a later offline build finds them again. */
8
+ screenshotCacheDir?: string;
9
+ /** Skip every network call (release assets, stats, screenshots hosted elsewhere). */
10
+ offline?: boolean;
11
+ githubToken?: string;
12
+ network?: NetworkOptions;
13
+ warn?: (message: string) => void;
14
+ }
15
+ /** Reads `<dir>/<lang>.po` for every language, trying `pt-BR` and `pt_BR` spellings; missing files are skipped. */
16
+ export declare function loadCatalogs(dir: string, languages: readonly string[]): Promise<Record<string, Catalog>>;
17
+ /** Collects everything the pages need into one object. Network failures degrade to warnings. */
18
+ export declare function loadSiteData(options: LoadOptions): Promise<SiteData>;