@gjsify/site 0.64.18 → 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 +43 -0
- package/lib/cli.js +35 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/listing/build.d.ts +42 -0
- package/lib/listing/build.js +91 -0
- package/lib/listing/config.d.ts +42 -0
- package/lib/listing/config.js +79 -0
- package/lib/listing/fit.d.ts +23 -0
- package/lib/listing/fit.js +29 -0
- package/lib/listing/images.d.ts +27 -0
- package/lib/listing/images.js +68 -0
- package/lib/listing/index.d.ts +7 -0
- package/lib/listing/index.js +7 -0
- package/lib/listing/limits.d.ts +36 -0
- package/lib/listing/limits.js +59 -0
- package/lib/listing/run.d.ts +25 -0
- package/lib/listing/run.js +109 -0
- package/lib/listing/text.d.ts +9 -0
- package/lib/listing/text.js +21 -0
- package/lib/render/image-backend.d.ts +13 -0
- package/lib/render/image-backend.js +36 -0
- package/lib/site-data/metainfo.js +1 -0
- package/lib/site-data/types.d.ts +1 -0
- package/package.json +11 -11
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
package/lib/index.js
CHANGED
|
@@ -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,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);
|
package/lib/site-data/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/site",
|
|
3
|
-
"version": "0.64.
|
|
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.
|
|
58
|
-
"@gjsify/crypto": "^0.64.
|
|
59
|
-
"@gjsify/fs": "^0.64.
|
|
60
|
-
"@gjsify/module": "^0.64.
|
|
61
|
-
"@gjsify/os": "^0.64.
|
|
62
|
-
"@gjsify/path": "^0.64.
|
|
63
|
-
"@gjsify/process": "^0.64.
|
|
64
|
-
"@gjsify/url": "^0.64.
|
|
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.
|
|
69
|
+
"@gjsify/cli": "^0.64.19",
|
|
70
70
|
"@gjsify/node-gi": "file:../../node-gi/node-gi",
|
|
71
|
-
"@gjsify/unit": "^0.64.
|
|
71
|
+
"@gjsify/unit": "^0.64.19",
|
|
72
72
|
"@types/node": "^25.9.2",
|
|
73
73
|
"typescript": "^6.0.3"
|
|
74
74
|
},
|