@gjsify/site 0.64.17 → 0.64.19

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 CHANGED
@@ -49,6 +49,49 @@ gjsify site build --runtime node # build and run on a specific runtime
49
49
  | `--check` | `i18n`: report what is out of date instead of writing it. |
50
50
  | `--runtime <r>` | `gjs` \| `node` \| `bun` \| `deno`. Default: the runtime the CLI itself is on. |
51
51
 
52
+ ## Store listings
53
+
54
+ `gjsify webext listing` (also `gjsify site listing`) derives the store listing of a browser extension
55
+ from the same sources as the page: the metainfo, the gettext catalogues and the screenshots. It works
56
+ for any extension, not only one built by `gjsify webext build`. See ADR 0135.
57
+
58
+ ```sh
59
+ gjsify webext listing --check # validate against the store limits, write nothing
60
+ gjsify webext listing --store amo --out dist/listing
61
+ gjsify webext listing --pot po/listing.pot # the config strings as a gettext template
62
+ ```
63
+
64
+ It writes `<out>/<store>/<lang>/{name,summary,description}.txt` (plus `description.html` for AMO and
65
+ `permissions.md` for the stores that ask), `<store>/screenshots/*.png`, `<store>/icon*.png` and a
66
+ `listing.json`. Screenshots are scaled down and letterboxed on the `<branding>` light colour to each
67
+ store's exact size; one that would need an enlargement beyond 2× is refused. The metainfo's donation
68
+ URL is never emitted (ADR 0113). Every length is checked and a violation names the field, store,
69
+ language and limit.
70
+
71
+ Only what the metainfo cannot carry is configured, in `gjsify.webext.listing` of the root
72
+ `package.json` (each key falls back to `gjsify.site`):
73
+
74
+ ```json
75
+ {
76
+ "gjsify": {
77
+ "webext": {
78
+ "listing": {
79
+ "metainfo": "data/app.metainfo.xml",
80
+ "singlePurpose": "One sentence: what the extension is for",
81
+ "permissions": { "tabs": "Why it needs the tab list", "https://*/*": "Why it needs these hosts" },
82
+ "category": { "cws": "productivity", "edge": "Productivity" },
83
+ "privacyUrl": "https://example.org/privacy/",
84
+ "screenshots": "docs/store/screenshots"
85
+ }
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ `singlePurpose` and the permission justifications are English msgids, translated through the app's
92
+ catalogues like the page strings. The privacy URL comes from `<url type="privacy">`, else
93
+ `privacyUrl`, else `<siteUrl><lang>/privacy/` when `gjsify.site.privacy` is set.
94
+
52
95
  ## Support pages and SEO
53
96
 
54
97
  With `gjsify.site.support` set, a build also writes `/<base><lang>/support/index.html` (title
package/lib/cli.js CHANGED
@@ -4,12 +4,15 @@
4
4
  // generates a tiny module that calls `runSiteCli`, bundles it for the runtime
5
5
  // the CLI itself is on, and runs it — so there is no `gjsify-site` bin bound to
6
6
  // one runtime. See `packages/infra/cli/src/commands/site.ts`.
7
+ import { readFile } from 'node:fs/promises';
7
8
  import { join, resolve } from 'node:path';
9
+ import { runListing, STORES } from './listing/index.js';
8
10
  import { buildSite, syncSiteI18n } from './site.js';
9
11
  const USAGE = `Usage: gjsify site <command> [options]
10
12
 
11
13
  build [options] Render the static pages of a repository
12
14
  i18n [options] Update the page catalogues from the strings the build can emit
15
+ listing [options] Derive browser-extension store listings (cws, edge, amo) from the metainfo
13
16
 
14
17
  Options:
15
18
  --root <path> Repository to work on (default: the working directory)
@@ -20,6 +23,10 @@ Options:
20
23
  --cache-dir <dir> Where to keep the response cache and image renditions
21
24
  (default: <root>/node_modules/.cache/gjsify-site)
22
25
  --check i18n: report what is out of date instead of writing it
26
+ --store <s> listing: cws | edge | amo | all (default: all)
27
+ --metainfo <path> listing: AppStream metainfo, overriding the configured one
28
+ --config <file> listing: JSON that overrides \`gjsify.webext.listing\`
29
+ --pot <file> listing: write the config strings as a gettext template
23
30
  --package i18n: sync this package's own catalogues instead of a consumer's
24
31
  --package-dir <d> Where @gjsify/site is installed; the client bundle and the
25
32
  shipped catalogues are read from there (default: derived
@@ -71,6 +78,32 @@ async function i18n(flags) {
71
78
  console.error(message);
72
79
  return untranslated.length > 0 || (check && stale.length > 0) ? 1 : 0;
73
80
  }
81
+ async function listing(flags) {
82
+ const root = resolve(flagValue(flags, '--root') ?? process.cwd());
83
+ const check = flags.includes('--check');
84
+ const requested = flagValue(flags, '--store') ?? 'all';
85
+ if (requested !== 'all' && !STORES.includes(requested))
86
+ throw new Error(`--store must be one of ${STORES.join(', ')}, all`);
87
+ const configFile = flagValue(flags, '--config');
88
+ const pot = flagValue(flags, '--pot');
89
+ const result = await runListing({
90
+ root,
91
+ out: resolve(flagValue(flags, '--out') ?? join(root, 'dist', 'listing')),
92
+ stores: requested === 'all' ? STORES : [requested],
93
+ check,
94
+ config: configFile ? JSON.parse(await readFile(resolve(configFile), 'utf8')) : undefined,
95
+ metainfo: flagValue(flags, '--metainfo'),
96
+ pot,
97
+ offline: flags.includes('--offline'),
98
+ cacheDir: flagValue(flags, '--cache-dir'),
99
+ warn: (message) => console.warn(`warning: ${message}`),
100
+ });
101
+ for (const issue of result.issues)
102
+ console.error(`error: ${issue.message}`);
103
+ if (!check)
104
+ console.log(`Wrote ${result.files.length} files`);
105
+ return result.issues.length > 0 ? 1 : 0;
106
+ }
74
107
  /**
75
108
  * Runs one `gjsify site` invocation and answers the process exit code. Never
76
109
  * exits the process itself: under GJS `process.exit` does not halt
@@ -83,6 +116,8 @@ export async function runSiteCli(argv) {
83
116
  return await build(flags);
84
117
  if (command === 'i18n')
85
118
  return await i18n(flags);
119
+ if (command === 'listing')
120
+ return await listing(flags);
86
121
  }
87
122
  catch (error) {
88
123
  console.error(error instanceof Error ? error.message : error);
package/lib/index.d.ts CHANGED
@@ -12,3 +12,4 @@ export * from './render/pages.js';
12
12
  export * from './render/qr.js';
13
13
  export * from './site-data/index.js';
14
14
  export * from './site.js';
15
+ export * from './listing/index.js';
package/lib/index.js CHANGED
@@ -12,3 +12,4 @@ export * from './render/pages.js';
12
12
  export * from './render/qr.js';
13
13
  export * from './site-data/index.js';
14
14
  export * from './site.js';
15
+ export * from './listing/index.js';
@@ -0,0 +1,42 @@
1
+ import type { Catalog } from '../site-data/po.js';
2
+ import type { Metainfo } from '../site-data/types.js';
3
+ import type { ListingConfig } from './config.js';
4
+ import { type Store } from './limits.js';
5
+ export interface StoreEntry {
6
+ name: string;
7
+ summary: string;
8
+ description: string;
9
+ descriptionHtml?: string;
10
+ singlePurpose: string;
11
+ permissions: Array<{
12
+ permission: string;
13
+ justification: string;
14
+ }>;
15
+ category?: string;
16
+ privacyUrl?: string;
17
+ /** `<url type="homepage">`; the donation URL is never carried (ADR 0113). */
18
+ homepage?: string;
19
+ supportUrl?: string;
20
+ }
21
+ export interface Issue {
22
+ store: Store;
23
+ language: string;
24
+ field: string;
25
+ message: string;
26
+ }
27
+ export interface ListingModel {
28
+ stores: Partial<Record<Store, Record<string, StoreEntry>>>;
29
+ issues: Issue[];
30
+ warnings: string[];
31
+ /** Every msgid the config blocks asked a catalogue for, for `--pot`. */
32
+ msgids: string[];
33
+ }
34
+ export interface ListingInput {
35
+ metainfo: Metainfo;
36
+ config: ListingConfig;
37
+ catalogs: Readonly<Record<string, Catalog>>;
38
+ stores?: readonly Store[];
39
+ }
40
+ /** Error text naming field, store, language and limit. */
41
+ export declare function checkEntry(store: Store, language: string, entry: StoreEntry): Issue[];
42
+ export declare function buildListingModel({ metainfo, config, catalogs, stores }: ListingInput): ListingModel;
@@ -0,0 +1,91 @@
1
+ import { pickByLanguage, resolveLocalized, translator } from '../site-data/po.js';
2
+ import { STORE_SPECS, STORES } from './limits.js';
3
+ import { characters, descriptionHtml, descriptionText } from './text.js';
4
+ /** Error text naming field, store, language and limit. */
5
+ export function checkEntry(store, language, entry) {
6
+ const spec = STORE_SPECS[store];
7
+ const issues = [];
8
+ const add = (field, message) => issues.push({ store, language, field, message: `${spec.label} [${language}] ${field}: ${message}` });
9
+ for (const field of ['name', 'summary', 'description']) {
10
+ const limit = spec.text[field];
11
+ const value = entry[field];
12
+ if (value === '') {
13
+ add(field, 'is empty');
14
+ continue;
15
+ }
16
+ if (!limit)
17
+ continue;
18
+ const count = characters(value);
19
+ const note = `${limit.verified ? '' : ' (limit not confirmed by first-party docs) '}${limit.url}`;
20
+ if (limit.max !== undefined && count > limit.max)
21
+ add(field, `${count} characters, limit ${limit.max} — ${note}`);
22
+ if (limit.min !== undefined && count < limit.min)
23
+ add(field, `${count} characters, minimum ${limit.min} — ${note}`);
24
+ }
25
+ if (spec.summaryHasNoUrls && /https?:\/\/|www\./i.test(entry.summary))
26
+ add('summary', 'must not contain a URL');
27
+ if (spec.justifiesPermissions) {
28
+ if (entry.singlePurpose === '')
29
+ add('singlePurpose', 'is empty (set gjsify.webext.listing.singlePurpose)');
30
+ for (const { permission, justification } of entry.permissions) {
31
+ if (justification === '')
32
+ add(`permissions.${permission}`, 'has no justification');
33
+ }
34
+ }
35
+ if (spec.categoryRequired && !entry.category)
36
+ add('category', `is not set (gjsify.webext.listing.category.${store})`);
37
+ return issues;
38
+ }
39
+ export function buildListingModel({ metainfo, config, catalogs, stores = STORES }) {
40
+ const model = { stores: {}, issues: [], warnings: [], msgids: [] };
41
+ const used = new Set();
42
+ for (const language of config.languages) {
43
+ const catalog = catalogs[language];
44
+ const isSource = language === config.sourceLanguage;
45
+ const lookup = translator(catalog);
46
+ // A config string is a msgid; English needs no catalogue, another language warns once per msgid.
47
+ const configText = (msgid) => {
48
+ if (msgid === undefined)
49
+ return '';
50
+ used.add(msgid);
51
+ if (!isSource && !catalog?.has(msgid))
52
+ model.warnings.push(`"${language}" has no translation of "${msgid}"`);
53
+ return lookup(msgid);
54
+ };
55
+ const app = (text) => {
56
+ if (!isSource && pickByLanguage(text.byLanguage, language) === undefined && !catalog?.has(text.source)) {
57
+ model.warnings.push(`"${language}" has no translation of the app text "${text.source.slice(0, 40)}…"`);
58
+ }
59
+ return isSource ? text.source : resolveLocalized(text, language, catalog);
60
+ };
61
+ const description = metainfo.description;
62
+ const base = {
63
+ name: app(metainfo.name),
64
+ summary: app(metainfo.summary),
65
+ description: descriptionText(description, app),
66
+ singlePurpose: configText(config.singlePurpose),
67
+ permissions: Object.entries(config.permissions).map(([permission, msgid]) => ({
68
+ permission,
69
+ justification: configText(msgid),
70
+ })),
71
+ privacyUrl: metainfo.urls.privacy ??
72
+ config.privacyUrl ??
73
+ (config.siteUrl ? `${config.siteUrl}${language}/privacy/` : undefined),
74
+ homepage: metainfo.urls.homepage,
75
+ supportUrl: metainfo.urls.bugtracker,
76
+ };
77
+ for (const store of stores) {
78
+ const spec = STORE_SPECS[store];
79
+ const entry = {
80
+ ...base,
81
+ ...(spec.descriptionHtml ? { descriptionHtml: descriptionHtml(description, app) } : {}),
82
+ category: config.category[store],
83
+ };
84
+ (model.stores[store] ??= {})[language] = entry;
85
+ model.issues.push(...checkEntry(store, language, entry));
86
+ }
87
+ }
88
+ model.warnings = [...new Set(model.warnings)];
89
+ model.msgids = [...used];
90
+ return model;
91
+ }
@@ -0,0 +1,42 @@
1
+ import type { Store } from './limits.js';
2
+ /** The slice of the root package.json the listing reads; `gjsify.site` only supplies fallbacks. */
3
+ export interface ListingManifest {
4
+ gjsify?: {
5
+ webext?: {
6
+ listing?: unknown;
7
+ };
8
+ site?: unknown;
9
+ ship?: {
10
+ icon?: string;
11
+ };
12
+ flatpak?: {
13
+ icon?: string;
14
+ };
15
+ };
16
+ }
17
+ export interface ListingConfig {
18
+ /** Repo-relative path of the AppStream metainfo. */
19
+ metainfo: string;
20
+ languages: string[];
21
+ sourceLanguage: string;
22
+ /** Directory of `<lang>.po` catalogues holding the translations of the metainfo and of this block's strings. */
23
+ translations?: string;
24
+ repository?: string;
25
+ /** Repo-relative icon (SVG or PNG). */
26
+ icon?: string;
27
+ /** Directory of PNGs used instead of the metainfo screenshots. */
28
+ screenshots?: string;
29
+ /** msgid: the single purpose, in one sentence. */
30
+ singlePurpose?: string;
31
+ /** permission or host pattern → msgid of its justification. */
32
+ permissions: Record<string, string>;
33
+ category: Partial<Record<Store, string>>;
34
+ privacyUrl?: string;
35
+ /** Used when the metainfo has no privacy URL and no `privacyUrl` is set: `<siteUrl><lang>/privacy/`. */
36
+ siteUrl?: string;
37
+ }
38
+ /**
39
+ * Merges `gjsify.webext.listing` (and `override`, e.g. a `--config` file, which wins) over the
40
+ * `gjsify.site` values that mean the same thing. Errors name the offending key.
41
+ */
42
+ export declare function parseListingConfig(manifest: ListingManifest, override?: unknown): ListingConfig;
@@ -0,0 +1,79 @@
1
+ import { isAbsolute } from 'node:path';
2
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
3
+ function str(raw, key, where) {
4
+ const value = raw[key];
5
+ if (value === undefined)
6
+ return undefined;
7
+ if (typeof value !== 'string' || value === '')
8
+ throw new Error(`${where}.${key} must be a non-empty string`);
9
+ return value;
10
+ }
11
+ /**
12
+ * Merges `gjsify.webext.listing` (and `override`, e.g. a `--config` file, which wins) over the
13
+ * `gjsify.site` values that mean the same thing. Errors name the offending key.
14
+ */
15
+ export function parseListingConfig(manifest, override = undefined) {
16
+ const own = manifest.gjsify?.webext?.listing;
17
+ if (own !== undefined && !isRecord(own))
18
+ throw new Error('gjsify.webext.listing must be an object');
19
+ if (override !== undefined && !isRecord(override))
20
+ throw new Error('The listing config must be a JSON object');
21
+ const raw = { ...own, ...override };
22
+ const where = 'gjsify.webext.listing';
23
+ const site = isRecord(manifest.gjsify?.site) ? manifest.gjsify?.site : {};
24
+ const metainfo = str(raw, 'metainfo', where) ?? (typeof site.metainfo === 'string' ? site.metainfo : undefined);
25
+ if (!metainfo)
26
+ throw new Error(`${where}.metainfo is required (or set gjsify.site.metainfo, or pass --metainfo)`);
27
+ const languages = raw.languages ?? site.languages ?? ['en'];
28
+ if (!Array.isArray(languages) || languages.length === 0 || !languages.every((entry) => typeof entry === 'string')) {
29
+ throw new Error(`${where}.languages must be a non-empty list of language codes`);
30
+ }
31
+ const permissions = {};
32
+ if (raw.permissions !== undefined) {
33
+ if (!isRecord(raw.permissions))
34
+ throw new Error(`${where}.permissions must map a permission to its justification`);
35
+ for (const [permission, text] of Object.entries(raw.permissions)) {
36
+ if (typeof text !== 'string' || text === '')
37
+ throw new Error(`${where}.permissions["${permission}"] must be a non-empty string`);
38
+ permissions[permission] = text;
39
+ }
40
+ }
41
+ const category = {};
42
+ if (raw.category !== undefined) {
43
+ if (!isRecord(raw.category))
44
+ throw new Error(`${where}.category must map a store (cws, edge, amo) to a category`);
45
+ for (const [store, value] of Object.entries(raw.category)) {
46
+ if (store !== 'cws' && store !== 'edge' && store !== 'amo')
47
+ throw new Error(`${where}.category.${store}: unknown store (use cws, edge or amo)`);
48
+ if (typeof value !== 'string' || value === '')
49
+ throw new Error(`${where}.category.${store} must be a string`);
50
+ category[store] = value;
51
+ }
52
+ }
53
+ const icon = str(raw, 'icon', where) ??
54
+ (typeof site.icon === 'string' ? site.icon : (manifest.gjsify?.ship?.icon ?? manifest.gjsify?.flatpak?.icon));
55
+ const translations = str(raw, 'translations', where) ?? (typeof site.translations === 'string' ? site.translations : undefined);
56
+ const screenshots = str(raw, 'screenshots', where);
57
+ for (const [key, value] of [
58
+ ['screenshots', screenshots],
59
+ ['translations', translations],
60
+ ['icon', icon],
61
+ ]) {
62
+ if (value !== undefined && isAbsolute(value))
63
+ throw new Error(`${where}.${key} must be repo-relative`);
64
+ }
65
+ return {
66
+ metainfo,
67
+ languages: languages,
68
+ sourceLanguage: str(raw, 'sourceLanguage', where) ?? (typeof site.sourceLanguage === 'string' ? site.sourceLanguage : 'en'),
69
+ translations,
70
+ repository: str(raw, 'repository', where) ?? (typeof site.repository === 'string' ? site.repository : undefined),
71
+ icon,
72
+ screenshots,
73
+ singlePurpose: str(raw, 'singlePurpose', where),
74
+ permissions,
75
+ category,
76
+ privacyUrl: str(raw, 'privacyUrl', where),
77
+ siteUrl: site.privacy !== undefined && typeof site.url === 'string' ? site.url.replace(/\/?$/, '/') : undefined,
78
+ };
79
+ }
@@ -0,0 +1,23 @@
1
+ import type { Size } from './limits.js';
2
+ /** Beyond this the pixels are guessed rather than shown; the listing refuses instead of shipping a blur. */
3
+ export declare const MAX_UPSCALE = 2;
4
+ export interface Fit {
5
+ scale: number;
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ height: number;
10
+ }
11
+ /** The largest placement of `source` inside `box` that keeps the aspect ratio, centred. */
12
+ export declare function fitInto(source: Size, box: Size): Fit;
13
+ /** `#rgb` or `#rrggbb` as `0xRRGGBB`; anything else (alpha forms included) is `undefined`. */
14
+ export declare function parseHexColor(value: string | undefined): number | undefined;
15
+ export type Placement = {
16
+ ok: true;
17
+ fit: Fit;
18
+ } | {
19
+ ok: false;
20
+ reason: string;
21
+ };
22
+ /** Letterbox placement of `source` on a `box` canvas, or the reason a clean result is impossible. */
23
+ export declare function planPlacement(source: Size, box: Size): Placement;
@@ -0,0 +1,29 @@
1
+ /** Beyond this the pixels are guessed rather than shown; the listing refuses instead of shipping a blur. */
2
+ export const MAX_UPSCALE = 2;
3
+ /** The largest placement of `source` inside `box` that keeps the aspect ratio, centred. */
4
+ export function fitInto(source, box) {
5
+ const scale = Math.min(box.width / source.width, box.height / source.height);
6
+ const width = Math.min(box.width, Math.max(1, Math.round(source.width * scale)));
7
+ const height = Math.min(box.height, Math.max(1, Math.round(source.height * scale)));
8
+ return { scale, x: Math.floor((box.width - width) / 2), y: Math.floor((box.height - height) / 2), width, height };
9
+ }
10
+ const HEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
11
+ /** `#rgb` or `#rrggbb` as `0xRRGGBB`; anything else (alpha forms included) is `undefined`. */
12
+ export function parseHexColor(value) {
13
+ if (!value || !HEX.test(value))
14
+ return undefined;
15
+ const digits = value.slice(1);
16
+ const full = digits.length === 3 ? [...digits].map((digit) => digit + digit).join('') : digits;
17
+ return Number.parseInt(full, 16);
18
+ }
19
+ /** Letterbox placement of `source` on a `box` canvas, or the reason a clean result is impossible. */
20
+ export function planPlacement(source, box) {
21
+ const fit = fitInto(source, box);
22
+ if (fit.scale > MAX_UPSCALE) {
23
+ return {
24
+ ok: false,
25
+ reason: `${source.width}x${source.height} would be enlarged ${fit.scale.toFixed(1)}x to fit ${box.width}x${box.height} (limit ${MAX_UPSCALE}x)`,
26
+ };
27
+ }
28
+ return { ok: true, fit };
29
+ }
@@ -0,0 +1,27 @@
1
+ import { type ScreenshotOptions } from '../site-data/screenshots.js';
2
+ import type { Metainfo } from '../site-data/types.js';
3
+ import type { Issue } from './build.js';
4
+ import type { ListingConfig } from './config.js';
5
+ import { type Store } from './limits.js';
6
+ export interface SourceShot {
7
+ file: string;
8
+ slug: string;
9
+ }
10
+ /** The files to publish: a configured directory of PNGs, else the metainfo's own screenshots. */
11
+ export declare function listingScreenshots(metainfo: Metainfo, config: ListingConfig, root: string, options: Omit<ScreenshotOptions, 'root' | 'repository'>): Promise<SourceShot[]>;
12
+ export interface RenderedImage {
13
+ name: string;
14
+ bytes?: Uint8Array;
15
+ }
16
+ /** The background a letterboxed shot sits on: the light brand colour, else white. */
17
+ export declare function letterboxColor(metainfo: Metainfo): number;
18
+ /**
19
+ * Fits every screenshot onto the store's canvas. A shot that would need a blurry enlargement is an
20
+ * issue rather than an image. `encode: false` validates geometry without producing bytes.
21
+ */
22
+ export declare function renderScreenshots(store: Store, shots: readonly SourceShot[], background: number, encode: boolean): {
23
+ images: RenderedImage[];
24
+ issues: Issue[];
25
+ };
26
+ /** The icon at the store's edge length, rendered from the SVG (or scaled from a PNG). */
27
+ export declare function renderIcon(store: Store, file: string): RenderedImage;
@@ -0,0 +1,68 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { decodeImageFile, decodeImageFileAtSize } from '../render/image-backend.js';
5
+ import { imageSlug, resolveScreenshots } from '../site-data/screenshots.js';
6
+ import { fitInto, parseHexColor, planPlacement } from './fit.js';
7
+ import { STORE_SPECS } from './limits.js';
8
+ /** The files to publish: a configured directory of PNGs, else the metainfo's own screenshots. */
9
+ export async function listingScreenshots(metainfo, config, root, options) {
10
+ if (config.screenshots) {
11
+ const dir = resolve(root, config.screenshots);
12
+ if (!existsSync(dir))
13
+ throw new Error(`gjsify.webext.listing.screenshots: "${config.screenshots}" does not exist`);
14
+ const names = (await readdir(dir)).filter((name) => /\.(png|jpe?g|webp)$/i.test(name)).sort();
15
+ return names.map((name) => ({ file: join(dir, name), slug: imageSlug(name) }));
16
+ }
17
+ if (!config.repository) {
18
+ throw new Error('Screenshots from the metainfo need gjsify.webext.listing.repository (or gjsify.site.repository)');
19
+ }
20
+ const shots = await resolveScreenshots(metainfo, { ...options, root, repository: config.repository });
21
+ return shots.map(({ file, slug }) => ({ file, slug }));
22
+ }
23
+ /** The background a letterboxed shot sits on: the light brand colour, else white. */
24
+ export function letterboxColor(metainfo) {
25
+ return parseHexColor(metainfo.branding.light) ?? 0xffffff;
26
+ }
27
+ /**
28
+ * Fits every screenshot onto the store's canvas. A shot that would need a blurry enlargement is an
29
+ * issue rather than an image. `encode: false` validates geometry without producing bytes.
30
+ */
31
+ export function renderScreenshots(store, shots, background, encode) {
32
+ const spec = STORE_SPECS[store];
33
+ const images = [];
34
+ const issues = [];
35
+ const add = (field, message) => issues.push({ store, language: '*', field, message: `${spec.label} ${field}: ${message}` });
36
+ if (shots.length < spec.screenshots.min) {
37
+ add('screenshots', `${shots.length} found, at least ${spec.screenshots.min} required — ${spec.screenshots.url}`);
38
+ }
39
+ const kept = shots.slice(0, spec.screenshots.max);
40
+ if (shots.length > kept.length) {
41
+ add('screenshots', `${shots.length} found, at most ${spec.screenshots.max} allowed — ${spec.screenshots.url}`);
42
+ }
43
+ for (const [index, shot] of kept.entries()) {
44
+ const source = decodeImageFile(shot.file);
45
+ const plan = planPlacement(source, spec.screenshots);
46
+ if (!plan.ok) {
47
+ add(`screenshots[${index + 1}]`, `${shot.slug}: ${plan.reason}`);
48
+ continue;
49
+ }
50
+ const name = `${String(index + 1).padStart(2, '0')}-${shot.slug}.png`;
51
+ const bytes = encode ? source.placed(spec.screenshots, plan.fit, background).encode('png') : undefined;
52
+ images.push({ name, bytes });
53
+ }
54
+ return { images, issues };
55
+ }
56
+ /** The icon at the store's edge length, rendered from the SVG (or scaled from a PNG). */
57
+ export function renderIcon(store, file) {
58
+ const { size } = STORE_SPECS[store].icon;
59
+ const image = decodeImageFileAtSize(file, size, size);
60
+ if (image.width !== size || image.height !== size) {
61
+ const fit = fitInto(image, { width: size, height: size });
62
+ return {
63
+ name: `icon-${size}.png`,
64
+ bytes: image.placed({ width: size, height: size }, fit, 0xffffff).encode('png'),
65
+ };
66
+ }
67
+ return { name: `icon-${size}.png`, bytes: image.encode('png') };
68
+ }
@@ -0,0 +1,7 @@
1
+ export * from './build.js';
2
+ export * from './config.js';
3
+ export * from './fit.js';
4
+ export * from './images.js';
5
+ export * from './limits.js';
6
+ export * from './run.js';
7
+ export * from './text.js';
@@ -0,0 +1,7 @@
1
+ export * from './build.js';
2
+ export * from './config.js';
3
+ export * from './fit.js';
4
+ export * from './images.js';
5
+ export * from './limits.js';
6
+ export * from './run.js';
7
+ export * from './text.js';
@@ -0,0 +1,36 @@
1
+ export type Store = 'cws' | 'edge' | 'amo';
2
+ export declare const STORES: readonly Store[];
3
+ export type TextField = 'name' | 'summary' | 'description';
4
+ export interface TextLimit {
5
+ min?: number;
6
+ max?: number;
7
+ url: string;
8
+ verified: boolean;
9
+ }
10
+ export interface Size {
11
+ width: number;
12
+ height: number;
13
+ }
14
+ export interface StoreSpec {
15
+ label: string;
16
+ text: Partial<Record<TextField, TextLimit>>;
17
+ /** Screenshot canvas, count, and the page that states them. */
18
+ screenshots: Size & {
19
+ min: number;
20
+ max: number;
21
+ url: string;
22
+ };
23
+ /** Square icon edge in pixels. */
24
+ icon: {
25
+ size: number;
26
+ url: string;
27
+ };
28
+ /** Summaries may not contain URLs (AMO enforces this server-side). */
29
+ summaryHasNoUrls?: boolean;
30
+ /** Emit `description.html` next to the plain text. */
31
+ descriptionHtml?: boolean;
32
+ /** Needs one justification per permission and a single-purpose statement. */
33
+ justifiesPermissions: boolean;
34
+ categoryRequired: boolean;
35
+ }
36
+ export declare const STORE_SPECS: Record<Store, StoreSpec>;
@@ -0,0 +1,59 @@
1
+ // What each store accepts, with the page that says so. `verified: false` marks a number that no
2
+ // first-party document states (ADR 0135 lists them); the check still enforces it, because an
3
+ // unverified ceiling that is too low is a message to fix, while one that is too high ships a rejection.
4
+ export const STORES = ['cws', 'edge', 'amo'];
5
+ const CWS_IMAGES = 'https://developer.chrome.com/docs/webstore/images';
6
+ const EDGE_PUBLISH = 'https://learn.microsoft.com/en-us/microsoft-edge/extensions/publish/publish-extension';
7
+ const AMO_LISTING = 'https://extensionworkshop.com/documentation/develop/create-an-appealing-listing/';
8
+ export const STORE_SPECS = {
9
+ cws: {
10
+ label: 'Chrome Web Store',
11
+ text: {
12
+ name: {
13
+ max: 75,
14
+ url: 'https://developer.chrome.com/docs/extensions/reference/manifest/name',
15
+ verified: true,
16
+ },
17
+ summary: {
18
+ max: 132,
19
+ url: 'https://developer.chrome.com/docs/extensions/reference/manifest/description',
20
+ verified: true,
21
+ },
22
+ description: {
23
+ max: 16000,
24
+ url: 'https://developer.chrome.com/docs/webstore/cws-dashboard-listing',
25
+ verified: false,
26
+ },
27
+ },
28
+ screenshots: { width: 1280, height: 800, min: 1, max: 5, url: CWS_IMAGES },
29
+ icon: { size: 128, url: CWS_IMAGES },
30
+ justifiesPermissions: true,
31
+ categoryRequired: true,
32
+ },
33
+ edge: {
34
+ label: 'Edge Add-ons',
35
+ text: {
36
+ // Name and short description are read from the manifest; the manifest ceilings are Chromium's.
37
+ name: { max: 75, url: EDGE_PUBLISH, verified: false },
38
+ summary: { max: 132, url: EDGE_PUBLISH, verified: false },
39
+ description: { min: 250, max: 10000, url: EDGE_PUBLISH, verified: true },
40
+ },
41
+ screenshots: { width: 1280, height: 800, min: 1, max: 6, url: EDGE_PUBLISH },
42
+ icon: { size: 300, url: EDGE_PUBLISH },
43
+ justifiesPermissions: true,
44
+ categoryRequired: true,
45
+ },
46
+ amo: {
47
+ label: 'Firefox Add-ons (AMO)',
48
+ text: {
49
+ name: { max: 50, url: AMO_LISTING, verified: true },
50
+ summary: { max: 250, url: AMO_LISTING, verified: true },
51
+ },
52
+ screenshots: { width: 1280, height: 800, min: 1, max: 10, url: AMO_LISTING },
53
+ icon: { size: 128, url: 'https://mozilla.github.io/addons-server/topics/api/addons.html' },
54
+ summaryHasNoUrls: true,
55
+ descriptionHtml: true,
56
+ justifiesPermissions: false,
57
+ categoryRequired: false,
58
+ },
59
+ };
@@ -0,0 +1,25 @@
1
+ import { type Issue, type StoreEntry } from './build.js';
2
+ import { type Store } from './limits.js';
3
+ export interface ListingOptions {
4
+ root: string;
5
+ out: string;
6
+ stores?: readonly Store[];
7
+ /** Validate only; nothing is written. */
8
+ check?: boolean;
9
+ /** Override of the `gjsify.webext.listing` block (a `--config` file). */
10
+ config?: unknown;
11
+ /** Overrides `metainfo` of the config. */
12
+ metainfo?: string;
13
+ /** Write the config strings as a `.pot` template here. */
14
+ pot?: string;
15
+ offline?: boolean;
16
+ cacheDir?: string;
17
+ warn?: (message: string) => void;
18
+ }
19
+ export interface ListingResult {
20
+ issues: Issue[];
21
+ warnings: string[];
22
+ files: string[];
23
+ }
24
+ export declare function permissionsMarkdown(entry: StoreEntry): string;
25
+ export declare function runListing(options: ListingOptions): Promise<ListingResult>;
@@ -0,0 +1,109 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { renderPo } from '../render/i18n.js';
5
+ import { parseMetainfo } from '../site-data/metainfo.js';
6
+ import { loadCatalogs } from '../site-data/load.js';
7
+ import { buildListingModel } from './build.js';
8
+ import { parseListingConfig } from './config.js';
9
+ import { letterboxColor, listingScreenshots, renderIcon, renderScreenshots } from './images.js';
10
+ import { STORE_SPECS, STORES } from './limits.js';
11
+ export function permissionsMarkdown(entry) {
12
+ const rows = entry.permissions.map(({ permission, justification }) => `| \`${permission}\` | ${justification.replace(/\|/g, '\\|')} |`);
13
+ return [
14
+ '# Privacy practices',
15
+ '',
16
+ '## Single purpose',
17
+ '',
18
+ entry.singlePurpose || '_not set_',
19
+ '',
20
+ '## Permission justifications',
21
+ '',
22
+ ...(rows.length > 0
23
+ ? ['| Permission | Justification |', '| --- | --- |', ...rows]
24
+ : ['_No permissions declared._']),
25
+ '',
26
+ ...(entry.privacyUrl ? ['## Privacy policy', '', entry.privacyUrl, ''] : []),
27
+ ].join('\n');
28
+ }
29
+ const ensureNewline = (text) => (text.endsWith('\n') ? text : `${text}\n`);
30
+ export async function runListing(options) {
31
+ const warn = options.warn ?? (() => { });
32
+ const root = resolve(options.root);
33
+ const manifestFile = join(root, 'package.json');
34
+ if (!existsSync(manifestFile))
35
+ throw new Error(`No package.json in ${root}`);
36
+ const manifest = JSON.parse(await readFile(manifestFile, 'utf8'));
37
+ const config = parseListingConfig(manifest, {
38
+ ...options.config,
39
+ ...(options.metainfo ? { metainfo: options.metainfo } : {}),
40
+ });
41
+ const metainfo = parseMetainfo(await readFile(resolve(root, config.metainfo), 'utf8'));
42
+ const catalogs = config.translations
43
+ ? await loadCatalogs(resolve(root, config.translations), config.languages)
44
+ : {};
45
+ const stores = options.stores ?? STORES;
46
+ const model = buildListingModel({ metainfo, config, catalogs, stores });
47
+ const issues = [...model.issues];
48
+ const warnings = [...model.warnings];
49
+ if (!config.translations && config.languages.some((language) => language !== config.sourceLanguage)) {
50
+ warnings.push('No translations directory: set gjsify.webext.listing.translations (a directory of .po files)');
51
+ }
52
+ const shots = await listingScreenshots(metainfo, config, root, {
53
+ cacheDir: options.cacheDir,
54
+ offline: options.offline,
55
+ warn: (message) => warnings.push(message),
56
+ });
57
+ const background = letterboxColor(metainfo);
58
+ const iconFile = config.icon ? resolve(root, config.icon) : undefined;
59
+ if (!iconFile || !existsSync(iconFile))
60
+ warnings.push('No icon: set gjsify.webext.listing.icon (or gjsify.site.icon)');
61
+ const out = resolve(options.out);
62
+ const write = !options.check;
63
+ const files = [];
64
+ const emit = async (path, data) => {
65
+ if (!write)
66
+ return;
67
+ const target = join(out, path);
68
+ await mkdir(dirname(target), { recursive: true });
69
+ await writeFile(target, data);
70
+ files.push(path);
71
+ };
72
+ if (write)
73
+ await rm(out, { recursive: true, force: true });
74
+ const index = {};
75
+ for (const store of stores) {
76
+ const entries = model.stores[store] ?? {};
77
+ const shotResult = renderScreenshots(store, shots, background, write);
78
+ issues.push(...shotResult.issues);
79
+ for (const [language, entry] of Object.entries(entries)) {
80
+ const dir = `${store}/${language}`;
81
+ await emit(`${dir}/name.txt`, ensureNewline(entry.name));
82
+ await emit(`${dir}/summary.txt`, ensureNewline(entry.summary));
83
+ await emit(`${dir}/description.txt`, ensureNewline(entry.description));
84
+ if (entry.descriptionHtml !== undefined)
85
+ await emit(`${dir}/description.html`, ensureNewline(entry.descriptionHtml));
86
+ if (STORE_SPECS[store].justifiesPermissions)
87
+ await emit(`${dir}/permissions.md`, permissionsMarkdown(entry));
88
+ }
89
+ const icon = iconFile && existsSync(iconFile) ? renderIcon(store, iconFile) : undefined;
90
+ if (icon?.bytes)
91
+ await emit(`${store}/${icon.name}`, icon.bytes);
92
+ for (const image of shotResult.images)
93
+ if (image.bytes)
94
+ await emit(`${store}/screenshots/${image.name}`, image.bytes);
95
+ index[store] = {
96
+ languages: entries,
97
+ screenshots: shotResult.images.map((image) => `screenshots/${image.name}`),
98
+ icon: icon?.name,
99
+ };
100
+ }
101
+ await emit('listing.json', `${JSON.stringify({ version: 1, app: metainfo.id, stores: index }, null, 2)}\n`);
102
+ if (options.pot) {
103
+ await writeFile(resolve(options.pot), renderPo(model.msgids, undefined));
104
+ files.push(options.pot);
105
+ }
106
+ for (const message of warnings)
107
+ warn(message);
108
+ return { issues, warnings, files };
109
+ }
@@ -0,0 +1,9 @@
1
+ import type { DescriptionBlock, Localized } from '../site-data/types.js';
2
+ type Resolve = (text: Localized) => string;
3
+ /** Paragraphs separated by a blank line, list items as `• item` — what every store's textarea shows. */
4
+ export declare function descriptionText(blocks: readonly DescriptionBlock[], resolve: Resolve): string;
5
+ /** The conservative subset (`p`, `ul`, `li`) every HTML sanitiser a store runs keeps. */
6
+ export declare function descriptionHtml(blocks: readonly DescriptionBlock[], resolve: Resolve): string;
7
+ /** Code points, not UTF-16 units: the stores count what a person sees. */
8
+ export declare function characters(value: string): number;
9
+ export {};
@@ -0,0 +1,21 @@
1
+ import { esc } from '../render/html.js';
2
+ /** Paragraphs separated by a blank line, list items as `• item` — what every store's textarea shows. */
3
+ export function descriptionText(blocks, resolve) {
4
+ return blocks
5
+ .map((block) => block.type === 'paragraph'
6
+ ? resolve(block.text)
7
+ : block.items.map((item) => `• ${resolve(item)}`).join('\n'))
8
+ .join('\n\n');
9
+ }
10
+ /** The conservative subset (`p`, `ul`, `li`) every HTML sanitiser a store runs keeps. */
11
+ export function descriptionHtml(blocks, resolve) {
12
+ return blocks
13
+ .map((block) => block.type === 'paragraph'
14
+ ? `<p>${esc(resolve(block.text))}</p>`
15
+ : `<ul>\n${block.items.map((item) => ` <li>${esc(resolve(item))}</li>`).join('\n')}\n</ul>`)
16
+ .join('\n');
17
+ }
18
+ /** Code points, not UTF-16 units: the stores count what a person sees. */
19
+ export function characters(value) {
20
+ return [...value].length;
21
+ }
@@ -21,9 +21,22 @@ export interface RasterImage {
21
21
  /** Keeps the aspect ratio. Never enlarges: a width at or above this one answers the same image. */
22
22
  /** `nearest` keeps pixel art's hard edges; the default resamples smoothly. */
23
23
  resizedToWidth(width: number, filter?: 'smooth' | 'nearest'): RasterImage;
24
+ /**
25
+ * A canvas of `canvas` size filled with the opaque colour `background` (`0xRRGGBB`), with this image
26
+ * scaled to `at.width` x `at.height` and composited at `at.x`, `at.y`. The answer has no alpha channel.
27
+ */
28
+ placed(canvas: {
29
+ width: number;
30
+ height: number;
31
+ }, at: Rect, background: number): RasterImage;
24
32
  encode(format: ImageFormat): Uint8Array;
25
33
  }
26
34
  export declare function decodeImageFile(path: string): RasterImage;
35
+ /**
36
+ * Decodes at an exact size; for an SVG that is a crisp render, for a raster a plain scale. Goes
37
+ * through the loader rather than `new_from_file_at_scale`, which does not sniff an SVG here.
38
+ */
39
+ export declare function decodeImageFileAtSize(path: string, width: number, height: number): RasterImage;
27
40
  export declare function decodeImageBytes(bytes: Uint8Array): RasterImage;
28
41
  /** Rasterises an SVG document at its own size, through the same loader set as the screenshots. */
29
42
  export declare function svgToPng(svg: string): Uint8Array;
@@ -17,6 +17,7 @@
17
17
  // from `ImageFormat`: JPEG. `save_to_bufferv('jpeg', …)` fails on an RGBA
18
18
  // pixbuf ("the encoder or decoder for Jpeg does not support the color type
19
19
  // `Rgba8`"), and screenshots of a window are RGBA.
20
+ import { readFileSync } from 'node:fs';
20
21
  import GdkPixbuf from 'gi://GdkPixbuf?version=2.0';
21
22
  /** Encoder options per format, matching what the renditions were tuned to. */
22
23
  const ENCODER = {
@@ -103,6 +104,17 @@ function wrap(pixbuf) {
103
104
  throw new Error(`Could not scale an image to ${target}px`);
104
105
  return wrap(scaled);
105
106
  },
107
+ placed(canvas, at, background) {
108
+ const target = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, false, 8, canvas.width, canvas.height);
109
+ if (!target)
110
+ throw new Error(`Could not allocate a ${canvas.width}x${canvas.height} canvas`);
111
+ target.fill((((background & 0xffffff) << 8) | 0xff) >>> 0);
112
+ const scaled = pixbuf.scale_simple(at.width, at.height, GdkPixbuf.InterpType.HYPER);
113
+ if (!scaled)
114
+ throw new Error(`Could not scale an image to ${at.width}x${at.height}`);
115
+ scaled.composite(target, at.x, at.y, at.width, at.height, at.x, at.y, 1, 1, GdkPixbuf.InterpType.NEAREST, 255);
116
+ return wrap(target);
117
+ },
106
118
  encode(format) {
107
119
  assertEncodable(format);
108
120
  const { keys, values } = ENCODER[format];
@@ -119,6 +131,30 @@ export function decodeImageFile(path) {
119
131
  throw new Error(`Could not decode ${path}`);
120
132
  return wrap(pixbuf);
121
133
  }
134
+ /**
135
+ * glycin decides "is this an SVG" from the first bytes, and a long licence comment ahead of `<svg`
136
+ * makes it answer `application/xml`; the prologue carries nothing a render needs.
137
+ */
138
+ function svgWithoutPrologue(bytes) {
139
+ const text = new TextDecoder().decode(bytes);
140
+ const root = text.search(/<svg[\s>]/);
141
+ return root > 0 ? new TextEncoder().encode(text.slice(root)) : bytes;
142
+ }
143
+ /**
144
+ * Decodes at an exact size; for an SVG that is a crisp render, for a raster a plain scale. Goes
145
+ * through the loader rather than `new_from_file_at_scale`, which does not sniff an SVG here.
146
+ */
147
+ export function decodeImageFileAtSize(path, width, height) {
148
+ const loader = new GdkPixbuf.PixbufLoader();
149
+ loader.set_size(width, height);
150
+ const bytes = new Uint8Array(readFileSync(path));
151
+ loader.write(path.toLowerCase().endsWith('.svg') ? svgWithoutPrologue(bytes) : bytes);
152
+ loader.close();
153
+ const pixbuf = loader.get_pixbuf();
154
+ if (!pixbuf)
155
+ throw new Error(`Could not decode ${path}`);
156
+ return wrap(pixbuf);
157
+ }
122
158
  export function decodeImageBytes(bytes) {
123
159
  const loader = new GdkPixbuf.PixbufLoader();
124
160
  loader.write(bytes);
@@ -86,6 +86,7 @@ const URL_KEYS = {
86
86
  faq: 'faq',
87
87
  contact: 'contact',
88
88
  contribute: 'contribute',
89
+ privacy: 'privacy',
89
90
  };
90
91
  /** Links are rendered as `href`, so `javascript:` and friends are dropped. */
91
92
  const SAFE_URL = /^(https?:|mailto:)/i;
@@ -306,6 +306,7 @@ export interface AppUrls {
306
306
  faq?: string;
307
307
  contact?: string;
308
308
  contribute?: string;
309
+ privacy?: string;
309
310
  }
310
311
  /** What the AppStream metainfo says, before any site configuration is mixed in. */
311
312
  export interface Metainfo {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/site",
3
- "version": "0.64.17",
3
+ "version": "0.64.19",
4
4
  "description": "Static landing page generator for a GNOME-style app, rendered from its AppStream metainfo — downloads, screenshots, release notes, support links, no JavaScript required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -54,21 +54,21 @@
54
54
  "license": "MIT",
55
55
  "dependencies": {
56
56
  "@girs/gdkpixbuf-2.0": "5.4.0",
57
- "@gjsify/console": "^0.64.17",
58
- "@gjsify/crypto": "^0.64.17",
59
- "@gjsify/fs": "^0.64.17",
60
- "@gjsify/module": "^0.64.17",
61
- "@gjsify/os": "^0.64.17",
62
- "@gjsify/path": "^0.64.17",
63
- "@gjsify/process": "^0.64.17",
64
- "@gjsify/url": "^0.64.17",
57
+ "@gjsify/console": "^0.64.19",
58
+ "@gjsify/crypto": "^0.64.19",
59
+ "@gjsify/fs": "^0.64.19",
60
+ "@gjsify/module": "^0.64.19",
61
+ "@gjsify/os": "^0.64.19",
62
+ "@gjsify/path": "^0.64.19",
63
+ "@gjsify/process": "^0.64.19",
64
+ "@gjsify/url": "^0.64.19",
65
65
  "fast-xml-parser": "^5.11.2",
66
66
  "qrcode-generator": "^2.0.4"
67
67
  },
68
68
  "devDependencies": {
69
- "@gjsify/cli": "^0.64.17",
69
+ "@gjsify/cli": "^0.64.19",
70
70
  "@gjsify/node-gi": "file:../../node-gi/node-gi",
71
- "@gjsify/unit": "^0.64.17",
71
+ "@gjsify/unit": "^0.64.19",
72
72
  "@types/node": "^25.9.2",
73
73
  "typescript": "^6.0.3"
74
74
  },