@gjsify/cli 0.64.1-next.243 → 0.64.1-next.245
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 +13 -6
- package/dist/cli.gjs.mjs +255 -255
- package/lib/commands/flatpak/check.d.ts +4 -0
- package/lib/commands/flatpak/check.js +77 -3
- package/lib/utils/metainfo-quality.d.ts +40 -0
- package/lib/utils/metainfo-quality.js +380 -0
- package/package.json +22 -22
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
// Both ship inside the `org.flatpak.Builder` flatpak. We exec them from
|
|
10
10
|
// the host PATH; if missing, print an install hint pointing at
|
|
11
11
|
// `flatpak install -y flathub org.flatpak.Builder`.
|
|
12
|
-
import { existsSync, readdirSync } from 'node:fs';
|
|
13
|
-
import { resolve, join } from 'node:path';
|
|
12
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
13
|
+
import { resolve, join, dirname } from 'node:path';
|
|
14
14
|
import { spawnToCompletion } from '../../utils/spawn.js';
|
|
15
15
|
import { readPackageJson, looksLikeAppId } from './utils.js';
|
|
16
16
|
import { Config } from '../../config.js';
|
|
17
|
+
import { formatFinding, lintMetainfo, readImageInfo, summarizeFindings, } from '../../utils/metainfo-quality.js';
|
|
17
18
|
export const flatpakCheckCommand = {
|
|
18
19
|
command: 'check [manifest]',
|
|
19
20
|
description: 'Run Flathub pre-submission linters: appstreamcli validate + flatpak-builder-lint.',
|
|
@@ -38,6 +39,26 @@ export const flatpakCheckCommand = {
|
|
|
38
39
|
description: 'Run `appstreamcli validate --strict` (default: true).',
|
|
39
40
|
type: 'boolean',
|
|
40
41
|
default: true,
|
|
42
|
+
})
|
|
43
|
+
.option('quality', {
|
|
44
|
+
description: "Run Flathub's quality guidelines (name, summary, branding, screenshots, release notes) on the MetaInfo (default: true).",
|
|
45
|
+
type: 'boolean',
|
|
46
|
+
default: true,
|
|
47
|
+
})
|
|
48
|
+
.option('strict', {
|
|
49
|
+
description: 'Treat quality warnings as errors.',
|
|
50
|
+
type: 'boolean',
|
|
51
|
+
default: false,
|
|
52
|
+
})
|
|
53
|
+
.option('json', {
|
|
54
|
+
description: 'Print the quality findings as JSON (and nothing else on stdout).',
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
default: false,
|
|
57
|
+
})
|
|
58
|
+
.option('quality-network', {
|
|
59
|
+
description: 'Let the quality check fetch http(s) screenshot/icon URLs to measure their size.',
|
|
60
|
+
type: 'boolean',
|
|
61
|
+
default: false,
|
|
41
62
|
})
|
|
42
63
|
.option('builder-lint', {
|
|
43
64
|
description: 'Run `flatpak-builder-lint manifest` (default: true).',
|
|
@@ -70,7 +91,15 @@ export const flatpakCheckCommand = {
|
|
|
70
91
|
failures++;
|
|
71
92
|
}
|
|
72
93
|
}
|
|
73
|
-
if (args.
|
|
94
|
+
if (args.quality !== false) {
|
|
95
|
+
if (!existsSync(metainfoAbs)) {
|
|
96
|
+
console.warn(`[gjsify flatpak check] skipping quality check — ${metainfoAbs} not found`);
|
|
97
|
+
}
|
|
98
|
+
else if (!(await runQuality(metainfoAbs, appId, args))) {
|
|
99
|
+
failures++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (args.builderLint !== false && !args.json) {
|
|
74
103
|
if (!existsSync(manifestPath)) {
|
|
75
104
|
console.error(`[gjsify flatpak check] manifest not found: ${manifestPath}`);
|
|
76
105
|
return process.exit(1);
|
|
@@ -94,6 +123,51 @@ export const flatpakCheckCommand = {
|
|
|
94
123
|
console.log('[gjsify flatpak check] all checks passed.');
|
|
95
124
|
},
|
|
96
125
|
};
|
|
126
|
+
async function runQuality(metainfoAbs, appId, args) {
|
|
127
|
+
const base = dirname(metainfoAbs);
|
|
128
|
+
const probeImage = async (ref) => {
|
|
129
|
+
if (/^https?:\/\//.test(ref)) {
|
|
130
|
+
if (!args.qualityNetwork)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
const res = await fetch(ref);
|
|
134
|
+
return res.ok ? readImageInfo(new Uint8Array(await res.arrayBuffer())) : null;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const file = resolve(base, ref);
|
|
141
|
+
return existsSync(file) ? readImageInfo(new Uint8Array(readFileSync(file))) : null;
|
|
142
|
+
};
|
|
143
|
+
const id = appId ?? '';
|
|
144
|
+
const iconRefs = id
|
|
145
|
+
? [
|
|
146
|
+
`icons/hicolor/scalable/apps/${id}.svg`,
|
|
147
|
+
`icons/${id}.svg`,
|
|
148
|
+
`${id}.svg`,
|
|
149
|
+
`${id}.png`,
|
|
150
|
+
`../data/icons/hicolor/scalable/apps/${id}.svg`,
|
|
151
|
+
]
|
|
152
|
+
: [];
|
|
153
|
+
const findings = await lintMetainfo(readFileSync(metainfoAbs, 'utf-8'), { probeImage, iconRefs });
|
|
154
|
+
const strict = args.strict === true;
|
|
155
|
+
const { errors, warnings } = summarizeFindings(findings, strict);
|
|
156
|
+
if (args.json) {
|
|
157
|
+
console.log(JSON.stringify({
|
|
158
|
+
file: metainfoAbs,
|
|
159
|
+
errors,
|
|
160
|
+
warnings,
|
|
161
|
+
findings: findings.map((f) => ({ ...f, severity: strict ? 'error' : f.severity })),
|
|
162
|
+
}, null, 2));
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
for (const f of findings)
|
|
166
|
+
console.log(formatFinding(f, strict));
|
|
167
|
+
console.log(`[gjsify flatpak check] ${errors === 0 ? 'OK' : 'FAIL'}: quality ${metainfoAbs} — ${errors} error(s), ${warnings} warning(s)`);
|
|
168
|
+
}
|
|
169
|
+
return errors === 0;
|
|
170
|
+
}
|
|
97
171
|
function resolveAppId(explicit, flatpak, cwd) {
|
|
98
172
|
if (flatpak.appId)
|
|
99
173
|
return flatpak.appId;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type QualitySeverity = 'error' | 'warning';
|
|
2
|
+
export interface QualityFinding {
|
|
3
|
+
/** Stable rule id, e.g. `summary-too-long`. */
|
|
4
|
+
rule: string;
|
|
5
|
+
severity: QualitySeverity;
|
|
6
|
+
message: string;
|
|
7
|
+
/** Where in the document: `name[de]`, `screenshot[2]/caption`, `release 1.0.0`. */
|
|
8
|
+
location: string;
|
|
9
|
+
/** Link to the guideline anchor the rule comes from. */
|
|
10
|
+
url: string;
|
|
11
|
+
}
|
|
12
|
+
export type ImageInfo = {
|
|
13
|
+
kind: 'svg';
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'raster';
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
/** Resolve an image reference (URL or path) to its dimensions; `null` = cannot tell. */
|
|
20
|
+
export type ImageProbe = (ref: string) => Promise<ImageInfo | null>;
|
|
21
|
+
export interface QualityOptions {
|
|
22
|
+
/** Terms a summary must not contain (case-insensitive, whole word). Replaces the default list. */
|
|
23
|
+
technicalTerms?: string[];
|
|
24
|
+
/** Candidate references for the app icon, tried in order; the first the probe answers for wins. */
|
|
25
|
+
iconRefs?: string[];
|
|
26
|
+
probeImage?: ImageProbe;
|
|
27
|
+
}
|
|
28
|
+
/** Words that tell a non-developer nothing: toolkit, language, licence, and generic nouns. */
|
|
29
|
+
export declare const DEFAULT_TECHNICAL_TERMS: readonly string[];
|
|
30
|
+
/** Dimensions or SVG-ness from the first bytes of an image file. `null` = unknown format. */
|
|
31
|
+
export declare function readImageInfo(bytes: Uint8Array): ImageInfo | null;
|
|
32
|
+
/** Lint one MetaInfo document. Never throws on a malformed file: it reports what it could read. */
|
|
33
|
+
export declare function lintMetainfo(xml: string, options?: QualityOptions): Promise<QualityFinding[]>;
|
|
34
|
+
/** Counts after `--strict`, which promotes every warning to an error. */
|
|
35
|
+
export declare function summarizeFindings(findings: QualityFinding[], strict: boolean): {
|
|
36
|
+
errors: number;
|
|
37
|
+
warnings: number;
|
|
38
|
+
};
|
|
39
|
+
/** One line per finding: `error name-too-long name: message (link)`. */
|
|
40
|
+
export declare function formatFinding(f: QualityFinding, strict: boolean): string;
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Flathub quality guidelines for an AppStream MetaInfo file, as a pure function.
|
|
2
|
+
//
|
|
3
|
+
// Source of truth: https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines
|
|
4
|
+
// `appstreamcli validate` answers "is this file VALID"; Flathub's curation asks "is this listing
|
|
5
|
+
// GOOD" (name length, summary wording, branding colours, screenshot captions, release notes), and
|
|
6
|
+
// no linter on the PATH answers that. This module is the second question, so `gjsify flatpak check`
|
|
7
|
+
// runs it beside the first instead of replacing it.
|
|
8
|
+
//
|
|
9
|
+
// Pure TypeScript on purpose — no `node:*` import, no spawn, no GI — so the same rules run under
|
|
10
|
+
// Node and GJS, in the unit suite and in an app's CI. The two things that need the file system or
|
|
11
|
+
// the network (image dimensions) arrive through an injected {@link ImageProbe}; a probe that cannot
|
|
12
|
+
// answer returns `null` and the rule is skipped, never failed: an unreachable screenshot is not a
|
|
13
|
+
// bad screenshot.
|
|
14
|
+
//
|
|
15
|
+
// Severity follows the page's own wording: `must` / "no longer than" limits are errors, everything
|
|
16
|
+
// phrased as "should", "avoid" or "ideally" is a warning. `--strict` in the caller promotes warnings.
|
|
17
|
+
import { scanXml } from '@gjsify/utils/core';
|
|
18
|
+
const GUIDELINES = 'https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines';
|
|
19
|
+
/** Words that tell a non-developer nothing: toolkit, language, licence, and generic nouns. */
|
|
20
|
+
export const DEFAULT_TECHNICAL_TERMS = [
|
|
21
|
+
'GTK',
|
|
22
|
+
'GTK3',
|
|
23
|
+
'GTK4',
|
|
24
|
+
'Qt',
|
|
25
|
+
'Qt5',
|
|
26
|
+
'Qt6',
|
|
27
|
+
'libadwaita',
|
|
28
|
+
'Electron',
|
|
29
|
+
'Rust',
|
|
30
|
+
'Python',
|
|
31
|
+
'JavaScript',
|
|
32
|
+
'TypeScript',
|
|
33
|
+
'GJS',
|
|
34
|
+
'Flatpak',
|
|
35
|
+
'written in',
|
|
36
|
+
'open source',
|
|
37
|
+
'open-source',
|
|
38
|
+
'free software',
|
|
39
|
+
'FOSS',
|
|
40
|
+
'app',
|
|
41
|
+
'application',
|
|
42
|
+
'tool',
|
|
43
|
+
'client',
|
|
44
|
+
'wrapper',
|
|
45
|
+
'App',
|
|
46
|
+
'Anwendung',
|
|
47
|
+
'Werkzeug',
|
|
48
|
+
];
|
|
49
|
+
const GENERIC_RELEASE_NOTES = [
|
|
50
|
+
/^(minor |various |general |misc(ellaneous)? )?bug ?fix(es)?( (and|&) (minor )?(performance )?(improvements?|fixes|tweaks))?$/i,
|
|
51
|
+
/^(minor |various |general )?(performance )?improvements?( (and|&) (bug ?fix(es)?|fixes))?$/i,
|
|
52
|
+
/^bug ?fix(es)?,? (and )?(performance|stability) improvements?$/i,
|
|
53
|
+
/^(performance|stability) (improvements?|fixes)( (and|&) bug ?fix(es)?)?$/i,
|
|
54
|
+
/^(fehlerbehebungen?|fehlerkorrekturen?|verbesserungen)( (und|&) (fehlerbehebungen?|verbesserungen|leistungsverbesserungen))?$/i,
|
|
55
|
+
/^(kleinere )?(fehlerbehebungen|verbesserungen)$/i,
|
|
56
|
+
];
|
|
57
|
+
const ARTICLES = {
|
|
58
|
+
en: ['a', 'an', 'the'],
|
|
59
|
+
de: ['ein', 'eine', 'einen', 'einem', 'einer', 'der', 'die', 'das', 'den', 'dem'],
|
|
60
|
+
};
|
|
61
|
+
/** Strip the legacy intltool `_` prefix (`<_summary>`), which `.metainfo.xml.in` files still use. */
|
|
62
|
+
const normName = (name) => (name.startsWith('_') ? name.slice(1) : name);
|
|
63
|
+
function buildTree(xml) {
|
|
64
|
+
const root = { name: '#root', attrs: {}, children: [], text: '' };
|
|
65
|
+
const stack = [root];
|
|
66
|
+
for (const token of scanXml(xml)) {
|
|
67
|
+
const top = stack[stack.length - 1];
|
|
68
|
+
if (token.kind === 'text') {
|
|
69
|
+
for (const node of stack)
|
|
70
|
+
node.text += token.text;
|
|
71
|
+
}
|
|
72
|
+
else if (token.kind === 'open') {
|
|
73
|
+
const node = { name: normName(token.name), attrs: token.attributes, children: [], text: '' };
|
|
74
|
+
top.children.push(node);
|
|
75
|
+
if (!token.selfClosing)
|
|
76
|
+
stack.push(node);
|
|
77
|
+
}
|
|
78
|
+
else if (stack.length > 1) {
|
|
79
|
+
stack.pop();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
const child = (node, name) => node?.children.find((c) => c.name === name);
|
|
85
|
+
const kids = (node, name) => node?.children.filter((c) => c.name === name) ?? [];
|
|
86
|
+
const clean = (text) => text.replace(/\s+/g, ' ').trim();
|
|
87
|
+
const lang = (node) => node.attrs['xml:lang'] ?? '';
|
|
88
|
+
const langBase = (code) => code.split(/[_\-.@]/)[0].toLowerCase();
|
|
89
|
+
/** Every localisation of a child element, keyed by `xml:lang` (`''` = the source string). */
|
|
90
|
+
function localized(parent, name) {
|
|
91
|
+
const out = new Map();
|
|
92
|
+
for (const node of kids(parent, name)) {
|
|
93
|
+
const text = clean(node.text);
|
|
94
|
+
if (text !== '' && !out.has(lang(node)))
|
|
95
|
+
out.set(lang(node), text);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
function parseHex(value) {
|
|
100
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value.trim());
|
|
101
|
+
if (m === null)
|
|
102
|
+
return null;
|
|
103
|
+
let hex = m[1];
|
|
104
|
+
if (hex.length === 3)
|
|
105
|
+
hex = hex.replace(/./g, (c) => c + c);
|
|
106
|
+
const r = Number.parseInt(hex.slice(0, 2), 16) / 255;
|
|
107
|
+
const g = Number.parseInt(hex.slice(2, 4), 16) / 255;
|
|
108
|
+
const b = Number.parseInt(hex.slice(4, 6), 16) / 255;
|
|
109
|
+
const max = Math.max(r, g, b);
|
|
110
|
+
const min = Math.min(r, g, b);
|
|
111
|
+
const l = (max + min) / 2;
|
|
112
|
+
const d = max - min;
|
|
113
|
+
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
|
114
|
+
return { s, l };
|
|
115
|
+
}
|
|
116
|
+
// ── Image size parsing (PNG + JPEG; both are what screenshots are) ───────────
|
|
117
|
+
/** Dimensions or SVG-ness from the first bytes of an image file. `null` = unknown format. */
|
|
118
|
+
export function readImageInfo(bytes) {
|
|
119
|
+
if (bytes.length >= 24 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
|
120
|
+
const u32 = (o) => ((bytes[o] << 24) | (bytes[o + 1] << 16) | (bytes[o + 2] << 8) | bytes[o + 3]) >>> 0;
|
|
121
|
+
return { kind: 'raster', width: u32(16), height: u32(20) };
|
|
122
|
+
}
|
|
123
|
+
if (bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
|
|
124
|
+
let o = 2;
|
|
125
|
+
while (o + 9 < bytes.length) {
|
|
126
|
+
if (bytes[o] !== 0xff) {
|
|
127
|
+
o++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const marker = bytes[o + 1];
|
|
131
|
+
if (marker === 0xff) {
|
|
132
|
+
o++;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
136
|
+
return {
|
|
137
|
+
kind: 'raster',
|
|
138
|
+
height: (bytes[o + 5] << 8) | bytes[o + 6],
|
|
139
|
+
width: (bytes[o + 7] << 8) | bytes[o + 8],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
o += 2 + ((bytes[o + 2] << 8) | bytes[o + 3]);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
let head = '';
|
|
147
|
+
for (let i = 0; i < Math.min(bytes.length, 1024); i++)
|
|
148
|
+
head += String.fromCharCode(bytes[i]);
|
|
149
|
+
return /<svg[\s>]/.test(head) ? { kind: 'svg' } : null;
|
|
150
|
+
}
|
|
151
|
+
// ── The rules ───────────────────────────────────────────────────────────────
|
|
152
|
+
function termPattern(term, flags = 'iu') {
|
|
153
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
|
|
154
|
+
return new RegExp(`(^|[^\\p{L}\\p{N}])${escaped}($|[^\\p{L}\\p{N}])`, flags);
|
|
155
|
+
}
|
|
156
|
+
/** Lint one MetaInfo document. Never throws on a malformed file: it reports what it could read. */
|
|
157
|
+
export async function lintMetainfo(xml, options = {}) {
|
|
158
|
+
const findings = [];
|
|
159
|
+
const add = (rule, severity, anchor, location, message) => {
|
|
160
|
+
findings.push({ rule, severity, message, location, url: `${GUIDELINES}#${anchor}` });
|
|
161
|
+
};
|
|
162
|
+
const component = child(buildTree(xml), 'component');
|
|
163
|
+
if (component === undefined) {
|
|
164
|
+
add('no-component', 'error', 'app-name', 'document', 'no <component> element found');
|
|
165
|
+
return findings;
|
|
166
|
+
}
|
|
167
|
+
const loc = (field, code) => (code === '' ? field : `${field}[${code}]`);
|
|
168
|
+
const names = localized(component, 'name');
|
|
169
|
+
const summaries = localized(component, 'summary');
|
|
170
|
+
// App name ---------------------------------------------------------------
|
|
171
|
+
for (const [code, name] of names) {
|
|
172
|
+
const where = loc('name', code);
|
|
173
|
+
const length = [...name].length;
|
|
174
|
+
if (length >= 20) {
|
|
175
|
+
add('name-too-long', 'error', 'not-too-long', where, `"${name}" is ${length} characters; a name must be shorter than 20`);
|
|
176
|
+
}
|
|
177
|
+
else if (length > 15) {
|
|
178
|
+
add('name-long', 'warning', 'not-too-long', where, `"${name}" is ${length} characters; ideally at most 15`);
|
|
179
|
+
}
|
|
180
|
+
if (/\s[-–—:]\s|:\s|\(.+\)/.test(name) ||
|
|
181
|
+
(/\b(web browser|editor|client|viewer|player)$/i.test(name) && name.includes(' '))) {
|
|
182
|
+
add('name-extra-info', 'warning', 'just-the-name', where, `"${name}" carries a description; keep just the name and put the rest in the summary`);
|
|
183
|
+
}
|
|
184
|
+
else if (/[.-]/.test(name)) {
|
|
185
|
+
add('name-formatting', 'warning', 'no-weird-formatting', where, `"${name}" contains a dot or dash; avoid punctuation in names`);
|
|
186
|
+
}
|
|
187
|
+
else if (/\p{L}/u.test(name)) {
|
|
188
|
+
const letters = name.replace(/[^\p{L}]/gu, '');
|
|
189
|
+
if (name === name.toLowerCase()) {
|
|
190
|
+
add('name-formatting', 'warning', 'no-weird-formatting', where, `"${name}" is all lowercase`);
|
|
191
|
+
}
|
|
192
|
+
else if (letters.length > 1 && name === name.toUpperCase()) {
|
|
193
|
+
add('name-formatting', 'warning', 'no-weird-formatting', where, `"${name}" is all uppercase (exempt only for an established brand such as VLC)`);
|
|
194
|
+
}
|
|
195
|
+
else if (!/\s/.test(name) && /\p{Ll}\p{Lu}/u.test(name)) {
|
|
196
|
+
add('name-formatting', 'warning', 'no-weird-formatting', where, `"${name}" is camel/pascal case`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Summary ----------------------------------------------------------------
|
|
201
|
+
const terms = (options.technicalTerms ?? [...DEFAULT_TECHNICAL_TERMS]).map((term) => ({
|
|
202
|
+
term,
|
|
203
|
+
re: termPattern(term),
|
|
204
|
+
}));
|
|
205
|
+
for (const [code, summary] of summaries) {
|
|
206
|
+
const where = loc('summary', code);
|
|
207
|
+
const length = [...summary].length;
|
|
208
|
+
if (length > 35) {
|
|
209
|
+
add('summary-too-long', 'error', 'not-too-long-1', where, `"${summary}" is ${length} characters; at most 35 allowed`);
|
|
210
|
+
}
|
|
211
|
+
else if (length > 25 || length < 10) {
|
|
212
|
+
add('summary-length', 'warning', 'not-too-long-1', where, `"${summary}" is ${length} characters; ideal is 10 to 25`);
|
|
213
|
+
}
|
|
214
|
+
if (/[.。]$/.test(summary)) {
|
|
215
|
+
add('summary-full-stop', 'warning', 'no-weird-formatting-1', where, 'a summary does not end with a full stop');
|
|
216
|
+
}
|
|
217
|
+
const words = summary.split(/\s+/).filter((w) => /^\p{L}/u.test(w));
|
|
218
|
+
const capitalised = words.filter((w) => /^\p{Lu}/u.test(w)).length;
|
|
219
|
+
if (words.length >= 3 && capitalised / words.length >= 0.75) {
|
|
220
|
+
add('summary-title-case', 'warning', 'no-weird-formatting-1', where, 'use sentence case, not Title Case');
|
|
221
|
+
}
|
|
222
|
+
const articles = ARTICLES[langBase(code === '' ? 'en' : code)];
|
|
223
|
+
const first = summary.split(/\s+/)[0]?.toLowerCase();
|
|
224
|
+
if (articles?.includes(first)) {
|
|
225
|
+
add('summary-article', 'warning', 'dont-start-with-an-article', where, `starts with the article "${first}"; omit it or phrase it with a verb`);
|
|
226
|
+
}
|
|
227
|
+
const name = names.get(code) ?? names.get('');
|
|
228
|
+
if (name !== undefined && [...name].length >= 3 && termPattern(name, 'u').test(summary)) {
|
|
229
|
+
add('summary-repeats-name', 'warning', 'dont-repeat-the-name', where, `repeats the app name "${name}"`);
|
|
230
|
+
}
|
|
231
|
+
const hit = terms.find(({ re }) => re.test(summary));
|
|
232
|
+
if (hit !== undefined) {
|
|
233
|
+
add('summary-technical', 'warning', 'not-technical', where, `mentions "${hit.term}"; say what the app does for people, not how it is built`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Description ------------------------------------------------------------
|
|
237
|
+
const description = kids(component, 'description').find((d) => lang(d) === '') ?? child(component, 'description');
|
|
238
|
+
if (description !== undefined) {
|
|
239
|
+
const summary = summaries.get('') ?? '';
|
|
240
|
+
const paragraphs = kids(description, 'p')
|
|
241
|
+
.filter((p) => lang(p) === '')
|
|
242
|
+
.map((p) => clean(p.text));
|
|
243
|
+
const body = paragraphs.join(' ');
|
|
244
|
+
if (summary !== '' &&
|
|
245
|
+
paragraphs.length > 0 &&
|
|
246
|
+
paragraphs[0].replace(/[.!]$/, '').toLowerCase() === summary.toLowerCase()) {
|
|
247
|
+
add('description-repeats-summary', 'warning', 'dont-repeat-the-summary', 'description', 'the first paragraph repeats the summary');
|
|
248
|
+
}
|
|
249
|
+
if (summary !== '' && [...body].length < Math.max(80, 3 * [...summary].length)) {
|
|
250
|
+
add('description-short', 'warning', 'not-too-short', 'description', `${[...body].length} characters; it should be substantially longer than the summary`);
|
|
251
|
+
}
|
|
252
|
+
for (const list of [...kids(description, 'ul'), ...kids(description, 'ol')]) {
|
|
253
|
+
const items = kids(list, 'li').filter((li) => lang(li) === '').length;
|
|
254
|
+
if (items > 10) {
|
|
255
|
+
add('description-long-list', 'warning', 'no-super-long-lists', 'description', `a list with ${items} items; group them or use paragraphs (max 10)`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Branding ---------------------------------------------------------------
|
|
260
|
+
const colors = kids(child(component, 'branding'), 'color').filter((c) => c.attrs.type === 'primary');
|
|
261
|
+
const light = colors.find((c) => c.attrs.scheme_preference === 'light');
|
|
262
|
+
const dark = colors.find((c) => c.attrs.scheme_preference === 'dark');
|
|
263
|
+
if (light === undefined || dark === undefined) {
|
|
264
|
+
add('branding-missing', 'warning', 'has-primary-brand-colors', 'branding', 'set two primary brand colors, scheme_preference="light" and "dark"');
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const lightText = clean(light.text).toLowerCase();
|
|
268
|
+
const darkText = clean(dark.text).toLowerCase();
|
|
269
|
+
if (lightText === darkText) {
|
|
270
|
+
add('branding-identical', 'warning', 'good-brand-colors', 'branding', 'light and dark primary colors are the same');
|
|
271
|
+
}
|
|
272
|
+
const l = parseHex(lightText);
|
|
273
|
+
const d = parseHex(darkText);
|
|
274
|
+
for (const [scheme, hsl, text] of [
|
|
275
|
+
['light', l, lightText],
|
|
276
|
+
['dark', d, darkText],
|
|
277
|
+
]) {
|
|
278
|
+
if (hsl !== null && (hsl.s < 0.15 || hsl.l > 0.93 || hsl.l < 0.07)) {
|
|
279
|
+
add('branding-dull', 'warning', 'good-brand-colors', `branding/${scheme}`, `${text} is near white, black or gray; brand colors should be colorful`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (l !== null && d !== null && lightText !== darkText && d.l >= l.l) {
|
|
283
|
+
add('branding-dark-not-darker', 'warning', 'good-brand-colors', 'branding/dark', 'the dark variant should be darker than the light one');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Icon -------------------------------------------------------------------
|
|
287
|
+
if (options.probeImage !== undefined) {
|
|
288
|
+
for (const ref of options.iconRefs ?? []) {
|
|
289
|
+
const info = await options.probeImage(ref);
|
|
290
|
+
if (info === null)
|
|
291
|
+
continue;
|
|
292
|
+
if (info.kind === 'raster') {
|
|
293
|
+
if (info.width !== info.height) {
|
|
294
|
+
add('icon-not-square', 'error', 'icon-size', 'icon', `${ref} is ${info.width}×${info.height}; the icon has to be square`);
|
|
295
|
+
}
|
|
296
|
+
else if (info.width < 256) {
|
|
297
|
+
add('icon-too-small', 'error', 'icon-size', 'icon', `${ref} is ${info.width}×${info.height}; use SVG or at least 256×256`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Screenshots ------------------------------------------------------------
|
|
304
|
+
const shots = kids(child(component, 'screenshots'), 'screenshot');
|
|
305
|
+
if (shots.length === 0) {
|
|
306
|
+
add('screenshot-missing', 'warning', 'at-least-one-screenshot', 'screenshots', 'at least one screenshot is expected');
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
const isEnglish = (s) => {
|
|
310
|
+
const tags = [lang(s), ...kids(s, 'image').map(lang)];
|
|
311
|
+
return tags.every((t) => t === '' || langBase(t) === 'en');
|
|
312
|
+
};
|
|
313
|
+
if (!shots.some(isEnglish)) {
|
|
314
|
+
add('screenshot-no-english', 'warning', 'at-least-one-screenshot', 'screenshots', 'no screenshot without xml:lang (or in English)');
|
|
315
|
+
}
|
|
316
|
+
if (shots[0].attrs.type !== 'default') {
|
|
317
|
+
add('screenshot-default-not-first', 'warning', 'at-least-one-screenshot', 'screenshot[1]', 'the first screenshot should be type="default" — it is the one users see first');
|
|
318
|
+
}
|
|
319
|
+
for (let i = 0; i < shots.length; i++) {
|
|
320
|
+
const shot = shots[i];
|
|
321
|
+
const where = `screenshot[${i + 1}]`;
|
|
322
|
+
const captions = kids(shot, 'caption');
|
|
323
|
+
if (captions.length === 0) {
|
|
324
|
+
add('screenshot-caption-missing', 'warning', 'image-captions', where, 'no <caption>');
|
|
325
|
+
}
|
|
326
|
+
for (const caption of captions) {
|
|
327
|
+
const text = clean(caption.text);
|
|
328
|
+
const at = `${where}/${loc('caption', lang(caption))}`;
|
|
329
|
+
if (/[.。]$/.test(text))
|
|
330
|
+
add('caption-full-stop', 'warning', 'image-captions', at, 'a caption does not end with a full stop');
|
|
331
|
+
if (/^\d/.test(text))
|
|
332
|
+
add('caption-starts-with-number', 'warning', 'image-captions', at, 'a caption does not start with a number');
|
|
333
|
+
if (/[.!?]\s+\p{Lu}/u.test(text))
|
|
334
|
+
add('caption-multiple-sentences', 'warning', 'image-captions', at, 'a caption is one sentence');
|
|
335
|
+
}
|
|
336
|
+
if (options.probeImage !== undefined) {
|
|
337
|
+
const source = kids(shot, 'image').find((img) => (img.attrs.type ?? 'source') === 'source');
|
|
338
|
+
const ref = source === undefined ? '' : clean(source.text);
|
|
339
|
+
const info = ref === '' ? null : await options.probeImage(ref);
|
|
340
|
+
if (info?.kind === 'raster' && (info.width > 2000 || info.height > 1400)) {
|
|
341
|
+
add('screenshot-too-large', 'warning', 'reasonable-window-size', where, `${info.width}×${info.height}; keep the window at most 1000×700 (2000×1400 on HiDPI)`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// Releases ---------------------------------------------------------------
|
|
347
|
+
for (const release of kids(child(component, 'releases'), 'release')) {
|
|
348
|
+
const where = `release ${release.attrs.version ?? '?'}`;
|
|
349
|
+
const notes = child(release, 'description');
|
|
350
|
+
const text = notes === undefined ? '' : clean(notes.text);
|
|
351
|
+
if (text === '') {
|
|
352
|
+
add('release-notes-missing', 'warning', 'release-notes-for-every-release', where, 'no release notes');
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (GENERIC_RELEASE_NOTES.some((re) => re.test(text.replace(/[.!]+$/, '')))) {
|
|
356
|
+
add('release-notes-generic', 'warning', 'no-bug-fixes-and-performance-improvements', where, `"${text}" says nothing about what changed`);
|
|
357
|
+
}
|
|
358
|
+
else if ([...text].length > 600 || kids(child(notes, 'ul'), 'li').length > 10) {
|
|
359
|
+
add('release-notes-long', 'warning', 'short-but-informative', where, 'release notes are long; summarise what changed for users');
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return findings;
|
|
363
|
+
}
|
|
364
|
+
/** Counts after `--strict`, which promotes every warning to an error. */
|
|
365
|
+
export function summarizeFindings(findings, strict) {
|
|
366
|
+
let errors = 0;
|
|
367
|
+
let warnings = 0;
|
|
368
|
+
for (const f of findings) {
|
|
369
|
+
if (f.severity === 'error' || strict)
|
|
370
|
+
errors++;
|
|
371
|
+
else
|
|
372
|
+
warnings++;
|
|
373
|
+
}
|
|
374
|
+
return { errors, warnings };
|
|
375
|
+
}
|
|
376
|
+
/** One line per finding: `error name-too-long name: message (link)`. */
|
|
377
|
+
export function formatFinding(f, strict) {
|
|
378
|
+
const severity = strict ? 'error' : f.severity;
|
|
379
|
+
return `${severity.padEnd(7)} ${f.rule} ${f.location}: ${f.message}\n ${f.url}`;
|
|
380
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/cli",
|
|
3
|
-
"version": "0.64.1-next.
|
|
3
|
+
"version": "0.64.1-next.245",
|
|
4
4
|
"description": "CLI for Gjsify",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"categories": [
|
|
77
77
|
"Development"
|
|
78
78
|
],
|
|
79
|
-
"homepageUrl": "https://gjsify.
|
|
79
|
+
"homepageUrl": "https://gjsify.org/gjsify/",
|
|
80
80
|
"vcsBrowserUrl": "https://github.com/gjsify/gjsify",
|
|
81
81
|
"issueTrackerUrl": "https://github.com/gjsify/gjsify/issues",
|
|
82
82
|
"modules": [
|
|
@@ -142,22 +142,22 @@
|
|
|
142
142
|
"cli"
|
|
143
143
|
],
|
|
144
144
|
"dependencies": {
|
|
145
|
-
"@gjsify/blueprint": "0.64.1-next.
|
|
146
|
-
"@gjsify/buffer": "0.64.1-next.
|
|
147
|
-
"@gjsify/create-app": "0.64.1-next.
|
|
148
|
-
"@gjsify/node-globals": "0.64.1-next.
|
|
149
|
-
"@gjsify/node-polyfills": "0.64.1-next.
|
|
150
|
-
"@gjsify/npm-registry": "0.64.1-next.
|
|
151
|
-
"@gjsify/resolve-npm": "0.64.1-next.
|
|
152
|
-
"@gjsify/rolldown-plugin-gjsify": "0.64.1-next.
|
|
153
|
-
"@gjsify/vite-plugin-blueprint": "0.64.1-next.
|
|
154
|
-
"@gjsify/rolldown-plugin-pnp": "0.64.1-next.
|
|
155
|
-
"@gjsify/semver": "0.64.1-next.
|
|
156
|
-
"@gjsify/tar": "0.64.1-next.
|
|
157
|
-
"@gjsify/tsc": "0.64.1-next.
|
|
158
|
-
"@gjsify/utils": "0.64.1-next.
|
|
159
|
-
"@gjsify/web-polyfills": "0.64.1-next.
|
|
160
|
-
"@gjsify/workspace": "0.64.1-next.
|
|
145
|
+
"@gjsify/blueprint": "0.64.1-next.245",
|
|
146
|
+
"@gjsify/buffer": "0.64.1-next.245",
|
|
147
|
+
"@gjsify/create-app": "0.64.1-next.245",
|
|
148
|
+
"@gjsify/node-globals": "0.64.1-next.245",
|
|
149
|
+
"@gjsify/node-polyfills": "0.64.1-next.245",
|
|
150
|
+
"@gjsify/npm-registry": "0.64.1-next.245",
|
|
151
|
+
"@gjsify/resolve-npm": "0.64.1-next.245",
|
|
152
|
+
"@gjsify/rolldown-plugin-gjsify": "0.64.1-next.245",
|
|
153
|
+
"@gjsify/vite-plugin-blueprint": "0.64.1-next.245",
|
|
154
|
+
"@gjsify/rolldown-plugin-pnp": "0.64.1-next.245",
|
|
155
|
+
"@gjsify/semver": "0.64.1-next.245",
|
|
156
|
+
"@gjsify/tar": "0.64.1-next.245",
|
|
157
|
+
"@gjsify/tsc": "0.64.1-next.245",
|
|
158
|
+
"@gjsify/utils": "0.64.1-next.245",
|
|
159
|
+
"@gjsify/web-polyfills": "0.64.1-next.245",
|
|
160
|
+
"@gjsify/workspace": "0.64.1-next.245",
|
|
161
161
|
"acorn": "^8.17.0",
|
|
162
162
|
"cosmiconfig": "^9.0.2",
|
|
163
163
|
"get-tsconfig": "^4.14.0",
|
|
@@ -166,13 +166,13 @@
|
|
|
166
166
|
"yargs": "^18.0.0"
|
|
167
167
|
},
|
|
168
168
|
"devDependencies": {
|
|
169
|
-
"@gjsify/unit": "0.64.1-next.
|
|
169
|
+
"@gjsify/unit": "0.64.1-next.245",
|
|
170
170
|
"@types/yargs": "^17.0.35",
|
|
171
171
|
"typescript": "^6.0.3"
|
|
172
172
|
},
|
|
173
173
|
"peerDependencies": {
|
|
174
|
-
"@gjsify/oxfmt-native": "0.64.1-next.
|
|
175
|
-
"@gjsify/rolldown-native": "0.64.1-next.
|
|
174
|
+
"@gjsify/oxfmt-native": "0.64.1-next.245",
|
|
175
|
+
"@gjsify/rolldown-native": "0.64.1-next.245"
|
|
176
176
|
},
|
|
177
177
|
"peerDependenciesMeta": {
|
|
178
178
|
"@gjsify/oxfmt-native": {
|
|
@@ -192,5 +192,5 @@
|
|
|
192
192
|
"url": "https://github.com/gjsify/gjsify/issues"
|
|
193
193
|
},
|
|
194
194
|
"homepage": "https://github.com/gjsify/gjsify/tree/main/packages/infra/cli#readme",
|
|
195
|
-
"gitHead": "
|
|
195
|
+
"gitHead": "9a6ca179c1a74477f23791c5b8f6d5a7bffaee61"
|
|
196
196
|
}
|