@gjsify/site 0.64.12-next.281 → 0.64.12
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 +3 -1
- package/lib/client/site.css +16 -0
- package/lib/render/blog.d.ts +2 -0
- package/lib/render/blog.js +8 -1
- package/lib/render/i18n.d.ts +2 -0
- package/lib/render/i18n.js +9 -3
- package/lib/render/pages.d.ts +9 -0
- package/lib/render/pages.js +33 -4
- package/lib/site-data/config.js +3 -0
- package/lib/site-data/downloads.d.ts +19 -1
- package/lib/site-data/downloads.js +40 -0
- package/lib/site-data/github.d.ts +7 -1
- package/lib/site-data/github.js +29 -0
- package/lib/site-data/load.js +10 -3
- package/lib/site-data/types.d.ts +13 -0
- package/lib/site.js +3 -0
- package/package.json +12 -13
- package/po/de.po +18 -3
- package/po/site.pot +18 -3
package/README.md
CHANGED
|
@@ -150,6 +150,7 @@ Lives in the root `package.json`, next to `ship` and `flatpak`.
|
|
|
150
150
|
| `downloads` | `[{platform, format, url?, label?, available?, component?}]`: targets the release assets do not cover, e.g. browser stores or a source install. `platform` is an OS or `firefox` / `chromium`; `format` adds `store`, `xpi`, `source`. `url` is required unless `available` is `false`, which lists the target as "Not yet available" and never recommends it. `component` is a `components[].id`. |
|
|
151
151
|
| `releaseAssets` | `false` ignores the GitHub release assets, so `downloads` is the whole list. Default `true`. |
|
|
152
152
|
| `relatedApps` | `[url \| {url, name, summary, icon}]`: sites of other apps, listed in order under "More apps by <developer>" on the landing page, see below. Optional — without it there is no section and nothing is fetched. |
|
|
153
|
+
| `developerUrl` | The developer's own site, e.g. a [developer site](#developer-sites): the "by <developer>" line under the app name links to it. Optional. |
|
|
153
154
|
| `support`, `legal` | `support`: `{kind, url}` links, or `{kind: "sepa", name, iban, bic?, reference?}`, a bank transfer shown as IBAN plus an EPC QR code. `legal`: `{label, url}` links. Both empty by default: nothing is invented. |
|
|
154
155
|
| `privacy` | `{statements: {<lang>: <path>}, hostingPolicyUrl?}`: a privacy page at `/<lang>/privacy/`, see below. Absent: no such page. |
|
|
155
156
|
| `legalNotice` | `{statements: {<lang>: <path>}}`: a Legal Notice/Impressum page at `/<lang>/legal-notice/`, see below. Absent: no such page. |
|
|
@@ -281,7 +282,8 @@ Everything else in the directory — images, downloads — is published under `/
|
|
|
281
282
|
relative path, so `` works in the editor and on the page. A link to another post's
|
|
282
283
|
`.md` file becomes a link to that post's page; one to a post the build does not render (a draft,
|
|
283
284
|
a typo) fails the build, since no Markdown is ever published. Posts are `BlogPosting` JSON-LD with
|
|
284
|
-
`article:*` meta, and the landing page shows the newest three of its language.
|
|
285
|
+
`article:*` meta, and the landing page shows the newest three of its language. On a site with
|
|
286
|
+
`support` entries, every post ends with a note linking to the support page.
|
|
285
287
|
|
|
286
288
|
## Theme
|
|
287
289
|
|
package/lib/client/site.css
CHANGED
|
@@ -1549,6 +1549,22 @@ app-support {
|
|
|
1549
1549
|
color: var(--muted);
|
|
1550
1550
|
}
|
|
1551
1551
|
|
|
1552
|
+
.post-support {
|
|
1553
|
+
display: flex;
|
|
1554
|
+
flex-wrap: wrap;
|
|
1555
|
+
align-items: center;
|
|
1556
|
+
justify-content: space-between;
|
|
1557
|
+
gap: 1rem;
|
|
1558
|
+
margin-block-start: 2.5rem;
|
|
1559
|
+
padding: 1.25rem;
|
|
1560
|
+
border-radius: var(--radius-lg);
|
|
1561
|
+
background: var(--surface);
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
.post-support p {
|
|
1565
|
+
margin: 0;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1552
1568
|
.latest-posts {
|
|
1553
1569
|
margin-block: 2.5rem;
|
|
1554
1570
|
}
|
package/lib/render/blog.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface BlogPostOptions {
|
|
|
36
36
|
name: string;
|
|
37
37
|
url?: string;
|
|
38
38
|
};
|
|
39
|
+
/** The site's support page in the post's language; without one the post ends with its text. */
|
|
40
|
+
supportUrl?: string;
|
|
39
41
|
}
|
|
40
42
|
/** `/<lang>/blog/<slug>/`. */
|
|
41
43
|
export declare function renderBlogPost(shell: Shell, post: BlogPost, options: BlogPostOptions): string;
|
package/lib/render/blog.js
CHANGED
|
@@ -178,9 +178,16 @@ ${postMeta(post, shell)}
|
|
|
178
178
|
<div class="post-body">
|
|
179
179
|
${options.html}
|
|
180
180
|
</div>
|
|
181
|
-
</article>
|
|
181
|
+
${options.supportUrl ? postSupport(shell, options.supportUrl) : ''}</article>
|
|
182
182
|
</main>`, 'blog-post');
|
|
183
183
|
}
|
|
184
|
+
function postSupport({ t }, href) {
|
|
185
|
+
return `<aside class="post-support">
|
|
186
|
+
<p>${esc(t('If this post was useful to you, you can support my work.'))}</p>
|
|
187
|
+
<a class="button" href="${esc(href)}">${icon('heart')}<span>${esc(t('Support my work'))}</span></a>
|
|
188
|
+
</aside>
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
184
191
|
/** The newest posts as a landing-page section; empty when the language has none. */
|
|
185
192
|
export function latestPostsSection(shell, posts, count = LATEST_POSTS) {
|
|
186
193
|
const { config, lang, t } = shell;
|
package/lib/render/i18n.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { type Catalog, type AnySiteConfig } from '../site-data/index.js';
|
|
|
3
3
|
* The msgids the renderer itself can emit: every translator call in its sources, plus the default
|
|
4
4
|
* download labels. They belong to this package and are translated in its own `po/`.
|
|
5
5
|
*/
|
|
6
|
+
/** The msgids of every translator call in one source file. */
|
|
7
|
+
export declare function translatorCalls(source: string): string[];
|
|
6
8
|
export declare function rendererMsgids(renderDir: string): Promise<string[]>;
|
|
7
9
|
/**
|
|
8
10
|
* The msgids a configuration introduces: the download labels its author wrote. They belong to the
|
package/lib/render/i18n.js
CHANGED
|
@@ -8,7 +8,9 @@ import { DOWNLOAD_LABELS, FORMAT_LABELS, SUPPORT_KIND_LABELS, UI_SOURCE_LANGUAGE
|
|
|
8
8
|
// pattern then extracted nothing and the sync offered to delete every translated string in `po/`.
|
|
9
9
|
// Hence also the floor below: an extractor that matches nothing must fail, never quietly empty the
|
|
10
10
|
// catalogues. Note that this pattern reads THIS file too, so no prose here spells out a call.
|
|
11
|
-
|
|
11
|
+
// The optional comma is the formatter's again: a msgid too long for one line is wrapped onto its
|
|
12
|
+
// own and gets a trailing comma, and without `,?` that string silently left the catalogue.
|
|
13
|
+
const T_CALL = /\bt\(\s*(['"])((?:\\.|(?!\1)[^\\])*)\1\s*,?\s*\)/g;
|
|
12
14
|
/** Decodes a JS string literal body, whichever quote style it was written in. */
|
|
13
15
|
function unquote(body, quote) {
|
|
14
16
|
const json = quote === '"' ? body : body.replace(/\\'/g, "'").replace(/"/g, '\\"');
|
|
@@ -18,6 +20,10 @@ function unquote(body, quote) {
|
|
|
18
20
|
* The msgids the renderer itself can emit: every translator call in its sources, plus the default
|
|
19
21
|
* download labels. They belong to this package and are translated in its own `po/`.
|
|
20
22
|
*/
|
|
23
|
+
/** The msgids of every translator call in one source file. */
|
|
24
|
+
export function translatorCalls(source) {
|
|
25
|
+
return [...source.matchAll(T_CALL)].map((match) => unquote(match[2], match[1]));
|
|
26
|
+
}
|
|
21
27
|
export async function rendererMsgids(renderDir) {
|
|
22
28
|
const msgids = new Set();
|
|
23
29
|
let fromSources = 0;
|
|
@@ -25,8 +31,8 @@ export async function rendererMsgids(renderDir) {
|
|
|
25
31
|
if (!file.endsWith('.ts') || file.endsWith('.spec.ts'))
|
|
26
32
|
continue;
|
|
27
33
|
const source = await readFile(join(renderDir, file), 'utf8');
|
|
28
|
-
for (const
|
|
29
|
-
msgids.add(
|
|
34
|
+
for (const msgid of translatorCalls(source)) {
|
|
35
|
+
msgids.add(msgid);
|
|
30
36
|
fromSources++;
|
|
31
37
|
}
|
|
32
38
|
}
|
package/lib/render/pages.d.ts
CHANGED
|
@@ -63,6 +63,15 @@ export declare function relatedCard(app: RelatedApp, lang: string): string;
|
|
|
63
63
|
export declare function relatedSection(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>): string;
|
|
64
64
|
/** A bank transfer as a card: the details to read or copy, and an EPC QR code that fills them into a banking app. */
|
|
65
65
|
export declare function sepaCard(entry: SiteSupportSepa, t: Translate): string;
|
|
66
|
+
/**
|
|
67
|
+
* Everything above the text: the icon and name, the install button, the facts row and the
|
|
68
|
+
* screenshots. One element, so the app's brand colour is behind all of it rather than behind the
|
|
69
|
+
* screenshots alone. The bar is its sibling and not its child — a `position: sticky` element
|
|
70
|
+
* cannot outlive its own containing block — but it is painted in the same colour, so it reads as
|
|
71
|
+
* the top of this header and needs no script to tell whether it has detached.
|
|
72
|
+
*/
|
|
73
|
+
/** "by <developer>" under the app name, linking to the developer's site when one is configured. */
|
|
74
|
+
export declare function heroBy({ data, t }: Pick<RenderContext, 'data' | 't'>): string;
|
|
66
75
|
/** The title and description of one page; also what the build checks for weak ones. */
|
|
67
76
|
export declare function pageMeta(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>, view?: 'app' | 'support' | 'privacy' | 'legal-notice'): {
|
|
68
77
|
name: string;
|
package/lib/render/pages.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { epcPayload, formatIban, isBrowserPlatform, PLATFORM_ORDER, pickByLanguage, relatedText, relatedUrl, } from '../site-data/index.js';
|
|
1
|
+
import { epcPayload, formatIban, isBrowserPlatform, needsOpenAnywayHint, PLATFORM_ORDER, pickByLanguage, relatedText, relatedUrl, } from '../site-data/index.js';
|
|
2
2
|
import { blogFeedUrl, blogIndexUrl } from './blog.js';
|
|
3
3
|
import { attrs, esc, fmt, plain } from './html.js';
|
|
4
4
|
import { icon } from './icons.js';
|
|
@@ -81,6 +81,23 @@ function osName(os, t) {
|
|
|
81
81
|
chromium: t('Chromium'),
|
|
82
82
|
}[os];
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* How to get past the OS's warning for a download without the signature it wants (ADR 0124). The
|
|
86
|
+
* wording follows each system's own button labels, which is what the visitor has to find.
|
|
87
|
+
*/
|
|
88
|
+
function openAnywayHint(os, t) {
|
|
89
|
+
if (os === 'darwin') {
|
|
90
|
+
return t('macOS asks before opening an app Apple has not checked. Try to open it once, then choose System Settings → Privacy & Security → Open Anyway.');
|
|
91
|
+
}
|
|
92
|
+
if (os === 'win32') {
|
|
93
|
+
return t('Windows may warn about an unknown publisher. Choose More info → Run anyway.');
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
/** The short form beside the target itself; the group's note says what to do about it. */
|
|
98
|
+
function unsignedMark(os, t) {
|
|
99
|
+
return os === 'darwin' ? t('Not notarized') : t('Unsigned');
|
|
100
|
+
}
|
|
84
101
|
function sizeLabel(bytes, lang) {
|
|
85
102
|
if (!bytes)
|
|
86
103
|
return '';
|
|
@@ -228,10 +245,12 @@ function componentBlocks(data) {
|
|
|
228
245
|
function targetItem(target, ctx) {
|
|
229
246
|
const { t, lang } = ctx;
|
|
230
247
|
const size = sizeLabel(target.size, lang);
|
|
248
|
+
const unsigned = target.available && needsOpenAnywayHint(target);
|
|
231
249
|
const meta = [
|
|
232
250
|
target.arch === 'noarch' ? '' : target.arch,
|
|
233
251
|
target.recommended ? t('Recommended') : '',
|
|
234
252
|
target.available ? '' : t('Not yet available'),
|
|
253
|
+
unsigned ? unsignedMark(target.os, t) : '',
|
|
235
254
|
]
|
|
236
255
|
.filter(Boolean)
|
|
237
256
|
.join(' · ');
|
|
@@ -244,6 +263,7 @@ ${size ? `<span class="target-size">${esc(size)}</span>` : ''}`;
|
|
|
244
263
|
'data-arch': target.arch,
|
|
245
264
|
'data-recommended': target.recommended,
|
|
246
265
|
'data-unavailable': !target.available,
|
|
266
|
+
'data-unsigned': unsigned,
|
|
247
267
|
'data-cta': target.available ? ctaLabel(target, t) : undefined,
|
|
248
268
|
'data-size': size || undefined,
|
|
249
269
|
})}>
|
|
@@ -282,6 +302,7 @@ function componentSection(block, slug, step, ctx) {
|
|
|
282
302
|
${own.map((target) => targetItem(target, ctx)).join('\n')}
|
|
283
303
|
</ul>
|
|
284
304
|
${os === 'darwin' ? `<p class="os-note">${esc(t('Apple Silicon is assumed. Pick the Intel build if your Mac has an Intel processor.'))}</p>` : ''}
|
|
305
|
+
${own.some((target) => target.available && needsOpenAnywayHint(target)) ? `<p class="os-note os-note-unsigned">${esc(openAnywayHint(os, t) ?? '')}</p>` : ''}
|
|
285
306
|
</section>`);
|
|
286
307
|
const head = block.name
|
|
287
308
|
? `<div class="component-head">
|
|
@@ -569,15 +590,23 @@ function tags(ctx) {
|
|
|
569
590
|
* cannot outlive its own containing block — but it is painted in the same colour, so it reads as
|
|
570
591
|
* the top of this header and needs no script to tell whether it has detached.
|
|
571
592
|
*/
|
|
572
|
-
|
|
573
|
-
|
|
593
|
+
/** "by <developer>" under the app name, linking to the developer's site when one is configured. */
|
|
594
|
+
export function heroBy({ data, t }) {
|
|
574
595
|
const developer = data.app.developer.name;
|
|
596
|
+
if (!developer)
|
|
597
|
+
return '';
|
|
598
|
+
const url = data.config.developerUrl;
|
|
599
|
+
const name = url ? `<a href="${esc(url)}">${esc(developer)}</a>` : esc(developer);
|
|
600
|
+
return `<p class="hero-by">${esc(t('by {developer}')).replace('{developer}', name)}</p>`;
|
|
601
|
+
}
|
|
602
|
+
function masthead(ctx) {
|
|
603
|
+
const { data, lang } = ctx;
|
|
575
604
|
return `<header class="masthead">
|
|
576
605
|
<div class="hero-inner">
|
|
577
606
|
${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
|
|
578
607
|
<div class="hero-text">
|
|
579
608
|
<h1>${esc(localized(data.app.name, lang))}</h1>
|
|
580
|
-
${
|
|
609
|
+
${heroBy(ctx)}
|
|
581
610
|
</div>
|
|
582
611
|
${downloadWidget(ctx)}
|
|
583
612
|
</div>
|
package/lib/site-data/config.js
CHANGED
|
@@ -340,6 +340,9 @@ export function parseSiteConfig(manifest) {
|
|
|
340
340
|
downloads: parseDownloads(raw, components),
|
|
341
341
|
releaseAssets: parseReleaseAssets(raw),
|
|
342
342
|
relatedApps: parseRelatedApps(raw.relatedApps, 'gjsify.site.relatedApps'),
|
|
343
|
+
developerUrl: raw.developerUrl === undefined
|
|
344
|
+
? undefined
|
|
345
|
+
: httpUrl(requireString(raw, 'developerUrl'), 'gjsify.site.developerUrl'),
|
|
343
346
|
};
|
|
344
347
|
}
|
|
345
348
|
/** The `kind` labels the support page knows by name; the page catalogues translate them. */
|
|
@@ -1,4 +1,20 @@
|
|
|
1
|
-
import type { DownloadTarget, ReleaseAsset, SiteComponent, SiteDownloadConfig, TargetArch, TargetFormat, TargetPlatform } from './types.js';
|
|
1
|
+
import type { DownloadTarget, ReleaseAsset, Signing, SiteComponent, SiteDownloadConfig, TargetArch, TargetFormat, TargetPlatform } from './types.js';
|
|
2
|
+
/** What `gjsify ship` appends to an artifact's name for its release record (ADR 0124). */
|
|
3
|
+
export declare const RELEASE_RECORD_SUFFIX = ".gjsify-release.json";
|
|
4
|
+
/** The artifact a release-record asset describes, or `undefined` when the asset is not a record. */
|
|
5
|
+
export declare function recordedArtifact(name: string): string | undefined;
|
|
6
|
+
/**
|
|
7
|
+
* What a release record proves about one asset. The record is a file anyone with upload rights can
|
|
8
|
+
* write, so it counts only when its hash is the one GitHub computed for the asset; an asset GitHub
|
|
9
|
+
* gave no digest, a record for other bytes, or no record at all is `unsigned`. An ad-hoc signature
|
|
10
|
+
* is `unsigned` too: Gatekeeper and SmartScreen both treat it as no publisher at all.
|
|
11
|
+
*/
|
|
12
|
+
export declare function signingOf(asset: ReleaseAsset, record: unknown): Signing;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the visitor's OS will stop this download and ask to be overruled. macOS opens only a
|
|
15
|
+
* notarised app without asking; Windows SmartScreen is content with a signature.
|
|
16
|
+
*/
|
|
17
|
+
export declare function needsOpenAnywayHint(target: Pick<DownloadTarget, 'os' | 'signing'>): boolean;
|
|
2
18
|
/** Browser extensions are listed after the OS builds; a page usually has only one kind. */
|
|
3
19
|
export declare const PLATFORM_ORDER: readonly TargetPlatform[];
|
|
4
20
|
/** Platforms that are browsers: their targets are picked by the visitor's browser, not OS. */
|
|
@@ -33,6 +49,8 @@ export interface DownloadSources {
|
|
|
33
49
|
configured?: readonly SiteDownloadConfig[];
|
|
34
50
|
/** `gjsify.site.components`, in install order; empty when the product is one thing to download. */
|
|
35
51
|
components?: readonly SiteComponent[];
|
|
52
|
+
/** Parsed release records, by the artifact name they describe; see {@link signingOf}. */
|
|
53
|
+
records?: ReadonlyMap<string, unknown>;
|
|
36
54
|
}
|
|
37
55
|
/**
|
|
38
56
|
* Builds the sorted target list and marks the first available target of every platform as
|
|
@@ -1,3 +1,41 @@
|
|
|
1
|
+
/** What `gjsify ship` appends to an artifact's name for its release record (ADR 0124). */
|
|
2
|
+
export const RELEASE_RECORD_SUFFIX = '.gjsify-release.json';
|
|
3
|
+
/** The artifact a release-record asset describes, or `undefined` when the asset is not a record. */
|
|
4
|
+
export function recordedArtifact(name) {
|
|
5
|
+
return name.endsWith(RELEASE_RECORD_SUFFIX) ? name.slice(0, -RELEASE_RECORD_SUFFIX.length) : undefined;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* What a release record proves about one asset. The record is a file anyone with upload rights can
|
|
9
|
+
* write, so it counts only when its hash is the one GitHub computed for the asset; an asset GitHub
|
|
10
|
+
* gave no digest, a record for other bytes, or no record at all is `unsigned`. An ad-hoc signature
|
|
11
|
+
* is `unsigned` too: Gatekeeper and SmartScreen both treat it as no publisher at all.
|
|
12
|
+
*/
|
|
13
|
+
export function signingOf(asset, record) {
|
|
14
|
+
if (typeof record !== 'object' || record === null)
|
|
15
|
+
return 'unsigned';
|
|
16
|
+
const fields = record;
|
|
17
|
+
if (fields.schema !== 1 || fields.artifact !== asset.name)
|
|
18
|
+
return 'unsigned';
|
|
19
|
+
if (!asset.sha256 || typeof fields.sha256 !== 'string' || fields.sha256.toLowerCase() !== asset.sha256) {
|
|
20
|
+
return 'unsigned';
|
|
21
|
+
}
|
|
22
|
+
if (fields.signature?.kind !== 'identity')
|
|
23
|
+
return 'unsigned';
|
|
24
|
+
return fields.notarized === true ? 'notarized' : 'signed';
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether the visitor's OS will stop this download and ask to be overruled. macOS opens only a
|
|
28
|
+
* notarised app without asking; Windows SmartScreen is content with a signature.
|
|
29
|
+
*/
|
|
30
|
+
export function needsOpenAnywayHint(target) {
|
|
31
|
+
if (target.signing === undefined)
|
|
32
|
+
return false;
|
|
33
|
+
if (target.os === 'darwin')
|
|
34
|
+
return target.signing !== 'notarized';
|
|
35
|
+
if (target.os === 'win32')
|
|
36
|
+
return target.signing === 'unsigned';
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
1
39
|
/** Browser extensions are listed after the OS builds; a page usually has only one kind. */
|
|
2
40
|
export const PLATFORM_ORDER = [
|
|
3
41
|
'linux',
|
|
@@ -173,6 +211,7 @@ export function buildDownloadTargets(sources) {
|
|
|
173
211
|
const kind = classifyAsset(asset);
|
|
174
212
|
if (!kind)
|
|
175
213
|
continue;
|
|
214
|
+
const gated = kind.os === 'darwin' || kind.os === 'win32';
|
|
176
215
|
targets.push({
|
|
177
216
|
...kind,
|
|
178
217
|
component: componentOf(asset.name, components),
|
|
@@ -182,6 +221,7 @@ export function buildDownloadTargets(sources) {
|
|
|
182
221
|
size: asset.size,
|
|
183
222
|
downloadCount: asset.downloadCount,
|
|
184
223
|
recommended: false,
|
|
224
|
+
...(gated ? { signing: signingOf(asset, sources.records?.get(asset.name)) } : {}),
|
|
185
225
|
});
|
|
186
226
|
}
|
|
187
227
|
if (sources.webAppUrl) {
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { type NetworkOptions } from './network.js';
|
|
2
|
-
import type { LatestRelease } from './types.js';
|
|
2
|
+
import type { LatestRelease, ReleaseAsset } from './types.js';
|
|
3
3
|
/** Parses the GitHub REST `releases/latest` payload; anything malformed throws so the caller can warn and degrade. */
|
|
4
4
|
export declare function parseLatestRelease(payload: unknown): LatestRelease;
|
|
5
|
+
/**
|
|
6
|
+
* Fetches every release record among `assets`, by the artifact it describes. A record that does not
|
|
7
|
+
* download is reported through `warn` and left out, which makes its artifact `unsigned` — the page
|
|
8
|
+
* then shows one hint too many rather than one too few.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fetchReleaseRecords(assets: readonly ReleaseAsset[], options: NetworkOptions, warn: (message: string) => void): Promise<Map<string, unknown>>;
|
|
5
11
|
/**
|
|
6
12
|
* Fetches the latest published (non-draft, non-prerelease) release; `null` when the repository has
|
|
7
13
|
* none yet, which is a state and not an error. `token` raises the rate limit.
|
package/lib/site-data/github.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
+
import { recordedArtifact } from './downloads.js';
|
|
1
2
|
import { fetchJson, HttpError } from './network.js';
|
|
3
|
+
/** GitHub spells the digest `sha256:<hex>`; any other algorithm is not one a release record carries. */
|
|
4
|
+
function sha256Of(digest) {
|
|
5
|
+
if (typeof digest !== 'string')
|
|
6
|
+
return undefined;
|
|
7
|
+
const match = /^sha256:([0-9a-f]{64})$/i.exec(digest);
|
|
8
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
9
|
+
}
|
|
2
10
|
/** Parses the GitHub REST `releases/latest` payload; anything malformed throws so the caller can warn and degrade. */
|
|
3
11
|
export function parseLatestRelease(payload) {
|
|
4
12
|
const release = payload;
|
|
@@ -12,6 +20,7 @@ export function parseLatestRelease(payload) {
|
|
|
12
20
|
size: typeof asset.size === 'number' ? asset.size : 0,
|
|
13
21
|
downloadUrl: asset.browser_download_url,
|
|
14
22
|
downloadCount: typeof asset.download_count === 'number' ? asset.download_count : 0,
|
|
23
|
+
...(sha256Of(asset.digest) ? { sha256: sha256Of(asset.digest) } : {}),
|
|
15
24
|
},
|
|
16
25
|
]
|
|
17
26
|
: []);
|
|
@@ -23,6 +32,26 @@ export function parseLatestRelease(payload) {
|
|
|
23
32
|
assets,
|
|
24
33
|
};
|
|
25
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Fetches every release record among `assets`, by the artifact it describes. A record that does not
|
|
37
|
+
* download is reported through `warn` and left out, which makes its artifact `unsigned` — the page
|
|
38
|
+
* then shows one hint too many rather than one too few.
|
|
39
|
+
*/
|
|
40
|
+
export async function fetchReleaseRecords(assets, options, warn) {
|
|
41
|
+
const records = new Map();
|
|
42
|
+
await Promise.all(assets.map(async (asset) => {
|
|
43
|
+
const artifact = recordedArtifact(asset.name);
|
|
44
|
+
if (artifact === undefined)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
records.set(artifact, await fetchJson(asset.downloadUrl, { Accept: 'application/json' }, options));
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
warn(`Could not fetch the release record ${asset.name} (${String(error)}); treating ${artifact} as unsigned`);
|
|
51
|
+
}
|
|
52
|
+
}));
|
|
53
|
+
return records;
|
|
54
|
+
}
|
|
26
55
|
/**
|
|
27
56
|
* Fetches the latest published (non-draft, non-prerelease) release; `null` when the repository has
|
|
28
57
|
* none yet, which is a state and not an error. `token` raises the rate limit.
|
package/lib/site-data/load.js
CHANGED
|
@@ -2,9 +2,9 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join, resolve } from 'node:path';
|
|
4
4
|
import { parseSiteConfig } from './config.js';
|
|
5
|
-
import { buildDownloadTargets } from './downloads.js';
|
|
5
|
+
import { buildDownloadTargets, recordedArtifact } from './downloads.js';
|
|
6
6
|
import { fetchFlathubStats, fetchFlathubSummary } from './flathub.js';
|
|
7
|
-
import { fetchLatestRelease } from './github.js';
|
|
7
|
+
import { fetchLatestRelease, fetchReleaseRecords } from './github.js';
|
|
8
8
|
import { parseMetainfo } from './metainfo.js';
|
|
9
9
|
import { minimumAge } from './oars.js';
|
|
10
10
|
import { completeLocalized, languageCandidates, parseCatalog } from './po.js';
|
|
@@ -85,6 +85,7 @@ export async function loadSiteData(options) {
|
|
|
85
85
|
let latestRelease = null;
|
|
86
86
|
let hasReleases = true;
|
|
87
87
|
let flathubSize;
|
|
88
|
+
let records;
|
|
88
89
|
const stats = {};
|
|
89
90
|
if (!options.offline) {
|
|
90
91
|
const network = options.network ?? {};
|
|
@@ -97,7 +98,12 @@ export async function loadSiteData(options) {
|
|
|
97
98
|
latestRelease = release.value;
|
|
98
99
|
hasReleases = latestRelease !== null;
|
|
99
100
|
if (latestRelease) {
|
|
100
|
-
|
|
101
|
+
// A release record is fetched by every site build, not by a person installing.
|
|
102
|
+
stats.githubDownloads = latestRelease.assets
|
|
103
|
+
.filter((asset) => recordedArtifact(asset.name) === undefined)
|
|
104
|
+
.reduce((sum, asset) => sum + asset.downloadCount, 0);
|
|
105
|
+
if (config.releaseAssets)
|
|
106
|
+
records = await fetchReleaseRecords(latestRelease.assets, network, warn);
|
|
101
107
|
}
|
|
102
108
|
}
|
|
103
109
|
else {
|
|
@@ -158,6 +164,7 @@ export async function loadSiteData(options) {
|
|
|
158
164
|
assets: config.releaseAssets ? (latestRelease?.assets ?? []) : [],
|
|
159
165
|
configured: config.downloads,
|
|
160
166
|
components: config.components,
|
|
167
|
+
records,
|
|
161
168
|
flathub: config.flathub,
|
|
162
169
|
flathubSize,
|
|
163
170
|
webAppUrl: config.webApp ? `${config.base}${config.webApp.replace(/^\//, '')}` : undefined,
|
package/lib/site-data/types.d.ts
CHANGED
|
@@ -223,6 +223,8 @@ export interface SiteConfig extends SiteCommonConfig {
|
|
|
223
223
|
releaseAssets: boolean;
|
|
224
224
|
/** Sites of other apps to list under "More apps by <developer>", in order. Empty: no section. */
|
|
225
225
|
relatedApps: SiteRelatedAppConfig[];
|
|
226
|
+
/** The developer's own site; the "by <developer>" line under the app name links to it. */
|
|
227
|
+
developerUrl?: string;
|
|
226
228
|
}
|
|
227
229
|
/**
|
|
228
230
|
* One entry of the metainfo `<screenshots>` list, with its artwork located on disk. The same images
|
|
@@ -261,12 +263,23 @@ export interface DownloadTarget {
|
|
|
261
263
|
downloadCount?: number;
|
|
262
264
|
/** The one target to offer first for its OS. */
|
|
263
265
|
recommended: boolean;
|
|
266
|
+
/**
|
|
267
|
+
* What the release record beside the asset proves (ADR 0124). Set on macOS and Windows release
|
|
268
|
+
* assets only — the two systems that stop an unsigned download; a store page or the web build
|
|
269
|
+
* has nothing to warn about. `unsigned` when no
|
|
270
|
+
* record exists or its hash does not match the asset, because a claim that cannot be checked is
|
|
271
|
+
* not shown.
|
|
272
|
+
*/
|
|
273
|
+
signing?: Signing;
|
|
264
274
|
}
|
|
275
|
+
export type Signing = 'notarized' | 'signed' | 'unsigned';
|
|
265
276
|
export interface ReleaseAsset {
|
|
266
277
|
name: string;
|
|
267
278
|
size: number;
|
|
268
279
|
downloadUrl: string;
|
|
269
280
|
downloadCount: number;
|
|
281
|
+
/** Lower-case hex SHA-256 GitHub computed on upload; absent on assets uploaded before it did. */
|
|
282
|
+
sha256?: string;
|
|
270
283
|
}
|
|
271
284
|
export interface LatestRelease {
|
|
272
285
|
tag: string;
|
package/lib/site.js
CHANGED
|
@@ -163,6 +163,7 @@ async function writeBlog(config, out, pages) {
|
|
|
163
163
|
posts: blog.posts,
|
|
164
164
|
html,
|
|
165
165
|
author: { name: pages.author.name, url: pages.author.uri },
|
|
166
|
+
supportUrl: pages.supportUrl?.(lang),
|
|
166
167
|
}));
|
|
167
168
|
const card = ogCardSvg({ ...pages.card, name: post.title, summary: postDescription(post) });
|
|
168
169
|
await writeFile(join(out, relative(postOgImage(config, post))), svgToPng(card));
|
|
@@ -273,6 +274,7 @@ async function buildAppSite(build) {
|
|
|
273
274
|
card,
|
|
274
275
|
iconUrl,
|
|
275
276
|
ogImage: (lang) => `${data.config.base}og-${lang}.png`,
|
|
277
|
+
supportUrl: hasSupport ? (lang) => supportUrl(data, lang) : undefined,
|
|
276
278
|
})));
|
|
277
279
|
}
|
|
278
280
|
await Promise.all(writeSitemap(data.config, target.out, pages, data.config.relatedApps));
|
|
@@ -358,6 +360,7 @@ async function buildDeveloperSite(config, build) {
|
|
|
358
360
|
card,
|
|
359
361
|
iconUrl: avatarUrl,
|
|
360
362
|
ogImage: (lang) => `${config.base}og-${lang}.png`,
|
|
363
|
+
supportUrl: config.support.length > 0 ? (lang) => developerPageUrl({ data }, lang, 'support') : undefined,
|
|
361
364
|
})));
|
|
362
365
|
}
|
|
363
366
|
await Promise.all(writeSitemap(config, target.out, pages, config.developer.apps));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/site",
|
|
3
|
-
"version": "0.64.12
|
|
3
|
+
"version": "0.64.12",
|
|
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,26 +54,25 @@
|
|
|
54
54
|
"license": "MIT",
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@girs/gdkpixbuf-2.0": "5.4.0",
|
|
57
|
-
"@gjsify/console": "0.64.12
|
|
58
|
-
"@gjsify/crypto": "0.64.12
|
|
59
|
-
"@gjsify/fs": "0.64.12
|
|
60
|
-
"@gjsify/module": "0.64.12
|
|
61
|
-
"@gjsify/os": "0.64.12
|
|
62
|
-
"@gjsify/path": "0.64.12
|
|
63
|
-
"@gjsify/process": "0.64.12
|
|
64
|
-
"@gjsify/url": "0.64.12
|
|
57
|
+
"@gjsify/console": "^0.64.12",
|
|
58
|
+
"@gjsify/crypto": "^0.64.12",
|
|
59
|
+
"@gjsify/fs": "^0.64.12",
|
|
60
|
+
"@gjsify/module": "^0.64.12",
|
|
61
|
+
"@gjsify/os": "^0.64.12",
|
|
62
|
+
"@gjsify/path": "^0.64.12",
|
|
63
|
+
"@gjsify/process": "^0.64.12",
|
|
64
|
+
"@gjsify/url": "^0.64.12",
|
|
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.12
|
|
69
|
+
"@gjsify/cli": "^0.64.12",
|
|
70
70
|
"@gjsify/node-gi": "file:../../node-gi/node-gi",
|
|
71
|
-
"@gjsify/unit": "0.64.12
|
|
71
|
+
"@gjsify/unit": "^0.64.12",
|
|
72
72
|
"@types/node": "^25.9.2",
|
|
73
73
|
"typescript": "^6.0.3"
|
|
74
74
|
},
|
|
75
75
|
"gjsify": {
|
|
76
76
|
"tier": 2
|
|
77
|
-
}
|
|
78
|
-
"gitHead": "c4d2e266c0d9849860b9828c14ebfe37b6d493b8"
|
|
77
|
+
}
|
|
79
78
|
}
|
package/po/de.po
CHANGED
|
@@ -30,6 +30,12 @@ msgstr "In dieser Sprache gibt es noch keine Beiträge."
|
|
|
30
30
|
msgid "All posts"
|
|
31
31
|
msgstr "Alle Beiträge"
|
|
32
32
|
|
|
33
|
+
msgid "If this post was useful to you, you can support my work."
|
|
34
|
+
msgstr "Wenn dir dieser Beitrag geholfen hat, kannst du meine Arbeit unterstützen."
|
|
35
|
+
|
|
36
|
+
msgid "Support my work"
|
|
37
|
+
msgstr "Meine Arbeit unterstützen"
|
|
38
|
+
|
|
33
39
|
msgid "From the blog"
|
|
34
40
|
msgstr "Aus dem Blog"
|
|
35
41
|
|
|
@@ -39,9 +45,6 @@ msgstr "Lebenslauf"
|
|
|
39
45
|
msgid "Apps"
|
|
40
46
|
msgstr "Apps"
|
|
41
47
|
|
|
42
|
-
msgid "Support my work"
|
|
43
|
-
msgstr "Meine Arbeit unterstützen"
|
|
44
|
-
|
|
45
48
|
msgid "Read my CV"
|
|
46
49
|
msgstr "Zum Lebenslauf"
|
|
47
50
|
|
|
@@ -141,6 +144,18 @@ msgstr "Firefox"
|
|
|
141
144
|
msgid "Chromium"
|
|
142
145
|
msgstr "Chromium"
|
|
143
146
|
|
|
147
|
+
msgid "macOS asks before opening an app Apple has not checked. Try to open it once, then choose System Settings → Privacy & Security → Open Anyway."
|
|
148
|
+
msgstr "macOS fragt nach, bevor es eine App öffnet, die Apple nicht geprüft hat. Einmal öffnen, dann Systemeinstellungen → Datenschutz & Sicherheit → Dennoch öffnen wählen."
|
|
149
|
+
|
|
150
|
+
msgid "Windows may warn about an unknown publisher. Choose More info → Run anyway."
|
|
151
|
+
msgstr "Windows warnt eventuell vor einem unbekannten Herausgeber. Weitere Informationen → Trotzdem ausführen wählen."
|
|
152
|
+
|
|
153
|
+
msgid "Not notarized"
|
|
154
|
+
msgstr "Nicht notarisiert"
|
|
155
|
+
|
|
156
|
+
msgid "Unsigned"
|
|
157
|
+
msgstr "Nicht signiert"
|
|
158
|
+
|
|
144
159
|
msgid "Install from Flathub"
|
|
145
160
|
msgstr "Über Flathub installieren"
|
|
146
161
|
|
package/po/site.pot
CHANGED
|
@@ -29,6 +29,12 @@ msgstr ""
|
|
|
29
29
|
msgid "All posts"
|
|
30
30
|
msgstr ""
|
|
31
31
|
|
|
32
|
+
msgid "If this post was useful to you, you can support my work."
|
|
33
|
+
msgstr ""
|
|
34
|
+
|
|
35
|
+
msgid "Support my work"
|
|
36
|
+
msgstr ""
|
|
37
|
+
|
|
32
38
|
msgid "From the blog"
|
|
33
39
|
msgstr ""
|
|
34
40
|
|
|
@@ -38,9 +44,6 @@ msgstr ""
|
|
|
38
44
|
msgid "Apps"
|
|
39
45
|
msgstr ""
|
|
40
46
|
|
|
41
|
-
msgid "Support my work"
|
|
42
|
-
msgstr ""
|
|
43
|
-
|
|
44
47
|
msgid "Read my CV"
|
|
45
48
|
msgstr ""
|
|
46
49
|
|
|
@@ -140,6 +143,18 @@ msgstr ""
|
|
|
140
143
|
msgid "Chromium"
|
|
141
144
|
msgstr ""
|
|
142
145
|
|
|
146
|
+
msgid "macOS asks before opening an app Apple has not checked. Try to open it once, then choose System Settings → Privacy & Security → Open Anyway."
|
|
147
|
+
msgstr ""
|
|
148
|
+
|
|
149
|
+
msgid "Windows may warn about an unknown publisher. Choose More info → Run anyway."
|
|
150
|
+
msgstr ""
|
|
151
|
+
|
|
152
|
+
msgid "Not notarized"
|
|
153
|
+
msgstr ""
|
|
154
|
+
|
|
155
|
+
msgid "Unsigned"
|
|
156
|
+
msgstr ""
|
|
157
|
+
|
|
143
158
|
msgid "Install from Flathub"
|
|
144
159
|
msgstr ""
|
|
145
160
|
|