@gjsify/site 0.64.3-next.260 → 0.64.3-next.262
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 +23 -0
- package/lib/client/site.css +68 -0
- package/lib/render/pages.d.ts +2 -0
- package/lib/render/pages.js +28 -1
- package/lib/site-data/config.js +35 -1
- package/lib/site-data/index.d.ts +1 -0
- package/lib/site-data/index.js +1 -0
- package/lib/site-data/load.js +7 -0
- package/lib/site-data/related.d.ts +23 -0
- package/lib/site-data/related.js +102 -0
- package/lib/site-data/types.d.ts +45 -0
- package/lib/site.js +3 -1
- package/package.json +12 -12
- package/po/de.po +6 -0
- package/po/site.pot +6 -0
package/README.md
CHANGED
|
@@ -107,6 +107,7 @@ Lives in the root `package.json`, next to `ship` and `flatpak`.
|
|
|
107
107
|
| `components` | `[{id, name, summary?, match?}]`: the separately installed parts of the product, in install order, see below. Optional — without it the dialog is one undivided list. |
|
|
108
108
|
| `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`. |
|
|
109
109
|
| `releaseAssets` | `false` ignores the GitHub release assets, so `downloads` is the whole list. Default `true`. |
|
|
110
|
+
| `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. |
|
|
110
111
|
| `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. |
|
|
111
112
|
|
|
112
113
|
A `sepa` entry carries no amount, and its IBAN is checked when the config loads — length per
|
|
@@ -140,6 +141,28 @@ The recommendation ("Recommended for your system") is made once per part, so eac
|
|
|
140
141
|
highlights its own best match, and the big button at the top of the dialog offers the first
|
|
141
142
|
declared component: the thing to install first.
|
|
142
143
|
|
|
144
|
+
### Related apps
|
|
145
|
+
|
|
146
|
+
Every build also writes `app.json` next to `index.html` — `{schema: 1, id, name, summary, icon, url,
|
|
147
|
+
languages, developer}`, names and summaries per language from the metainfo, `icon` and `url`
|
|
148
|
+
absolute. `relatedApps` reads those of other sites:
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
"relatedApps": [
|
|
152
|
+
"https://example.org/notes/",
|
|
153
|
+
{ "url": "https://example.org/plain/", "name": "Plain", "summary": "No app.json here", "icon": "https://example.org/plain/icon.png" }
|
|
154
|
+
]
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
A string is a site address: the build fetches `<url>app.json` and renders a card (icon, name,
|
|
158
|
+
summary in the page's language, else English) linking to the app's page in that language. An
|
|
159
|
+
object is an inline entry for an app that publishes no `app.json`; nothing is fetched for it.
|
|
160
|
+
The site's own address and app id are dropped, so all apps can share one list.
|
|
161
|
+
|
|
162
|
+
The section is plain HTML rendered at build time. A site that cannot be reached, or whose
|
|
163
|
+
`app.json` is invalid, is served from the last good copy in the build cache; with none, that card
|
|
164
|
+
is skipped with a warning. The build never fails because of it.
|
|
165
|
+
|
|
143
166
|
### Screenshots
|
|
144
167
|
|
|
145
168
|
Nothing to configure: the page shows the metainfo `<screenshots>`, in document order, with their
|
package/lib/client/site.css
CHANGED
|
@@ -1115,6 +1115,74 @@ app-support {
|
|
|
1115
1115
|
margin-block: 1.5rem 0;
|
|
1116
1116
|
}
|
|
1117
1117
|
|
|
1118
|
+
/* Related apps: same card list as the links, with the app's own icon */
|
|
1119
|
+
|
|
1120
|
+
.related {
|
|
1121
|
+
margin-block: 2rem;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
.related h2 {
|
|
1125
|
+
font-size: 1.15rem;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.related-list {
|
|
1129
|
+
border: 1px solid var(--line);
|
|
1130
|
+
border-radius: var(--radius-lg);
|
|
1131
|
+
background: var(--surface);
|
|
1132
|
+
overflow: hidden;
|
|
1133
|
+
list-style: none;
|
|
1134
|
+
padding: 0;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
.related-list li + li {
|
|
1138
|
+
border-top: 1px solid var(--line);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
.related-list a {
|
|
1142
|
+
display: flex;
|
|
1143
|
+
align-items: center;
|
|
1144
|
+
gap: 0.875rem;
|
|
1145
|
+
padding: 0.75rem 1rem;
|
|
1146
|
+
text-decoration: none;
|
|
1147
|
+
color: inherit;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
.related-list a:hover {
|
|
1151
|
+
background: color-mix(in srgb, var(--fg) 6%, transparent);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
.related-icon {
|
|
1155
|
+
flex: none;
|
|
1156
|
+
width: 3rem;
|
|
1157
|
+
height: 3rem;
|
|
1158
|
+
border-radius: 10px;
|
|
1159
|
+
object-fit: contain;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
.related-icon-blank {
|
|
1163
|
+
display: flex;
|
|
1164
|
+
align-items: center;
|
|
1165
|
+
justify-content: center;
|
|
1166
|
+
background: var(--bg);
|
|
1167
|
+
color: var(--muted);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
.related-text {
|
|
1171
|
+
display: flex;
|
|
1172
|
+
flex-direction: column;
|
|
1173
|
+
min-width: 0;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
.related-name {
|
|
1177
|
+
font-weight: 600;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
.related-summary {
|
|
1181
|
+
color: var(--muted);
|
|
1182
|
+
font-size: 0.85rem;
|
|
1183
|
+
overflow-wrap: anywhere;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1118
1186
|
/* Footer */
|
|
1119
1187
|
|
|
1120
1188
|
.footer {
|
package/lib/render/pages.d.ts
CHANGED
|
@@ -51,6 +51,8 @@ export declare function downloadWidget(ctx: DownloadContext): string;
|
|
|
51
51
|
*/
|
|
52
52
|
export declare function badges(ctx: Pick<RenderContext, 'data' | 't' | 'lang'>): string;
|
|
53
53
|
export declare function linkCard(href: string, title: string, glyph: IconName): string;
|
|
54
|
+
/** Other apps of the same developer, resolved at build time so the page needs no script. */
|
|
55
|
+
export declare function relatedSection(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>): string;
|
|
54
56
|
/** A bank transfer as a card: the details to read or copy, and an EPC QR code that fills them into a banking app. */
|
|
55
57
|
export declare function sepaCard(entry: SiteSupportSepa, t: Translate): string;
|
|
56
58
|
/** The title and description of one page; also what the build checks for weak ones. */
|
package/lib/render/pages.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { epcPayload, formatIban, isBrowserPlatform, PLATFORM_ORDER, pickByLanguage, } from '../site-data/index.js';
|
|
1
|
+
import { epcPayload, formatIban, isBrowserPlatform, PLATFORM_ORDER, pickByLanguage, relatedText, relatedUrl, } from '../site-data/index.js';
|
|
2
2
|
import { readableText } from './color.js';
|
|
3
3
|
import { attrs, esc, fmt, plain } from './html.js';
|
|
4
4
|
import { icon } from './icons.js';
|
|
@@ -466,6 +466,32 @@ ${items.join('\n')}
|
|
|
466
466
|
</ul>
|
|
467
467
|
</section>`;
|
|
468
468
|
}
|
|
469
|
+
function relatedCard(app, lang) {
|
|
470
|
+
const summary = relatedText(app.summary, lang);
|
|
471
|
+
const mark = app.icon
|
|
472
|
+
? `<img class="related-icon" src="${esc(app.icon)}" alt="" width="48" height="48" loading="lazy">`
|
|
473
|
+
: `<span class="related-icon related-icon-blank">${icon('package')}</span>`;
|
|
474
|
+
return `<li>
|
|
475
|
+
<a href="${esc(relatedUrl(app, lang))}">
|
|
476
|
+
${mark}
|
|
477
|
+
<span class="related-text"><span class="related-name">${esc(relatedText(app.name, lang))}</span>${summary ? `<span class="related-summary">${esc(summary)}</span>` : ''}</span>
|
|
478
|
+
</a>
|
|
479
|
+
</li>`;
|
|
480
|
+
}
|
|
481
|
+
/** Other apps of the same developer, resolved at build time so the page needs no script. */
|
|
482
|
+
export function relatedSection(ctx) {
|
|
483
|
+
const { data, lang, t } = ctx;
|
|
484
|
+
if (data.relatedApps.length === 0)
|
|
485
|
+
return '';
|
|
486
|
+
const developer = data.app.developer.name;
|
|
487
|
+
const heading = developer ? fmt(t('More apps by {developer}'), { developer }) : esc(t('More apps'));
|
|
488
|
+
return `<section class="related">
|
|
489
|
+
<h2>${heading}</h2>
|
|
490
|
+
<ul class="related-list">
|
|
491
|
+
${data.relatedApps.map((app) => relatedCard(app, lang)).join('\n')}
|
|
492
|
+
</ul>
|
|
493
|
+
</section>`;
|
|
494
|
+
}
|
|
469
495
|
/** A bank transfer as a card: the details to read or copy, and an EPC QR code that fills them into a banking app. */
|
|
470
496
|
export function sepaCard(entry, t) {
|
|
471
497
|
const grouped = formatIban(entry.iban);
|
|
@@ -708,6 +734,7 @@ ${blocks(data.app.description, lang)}
|
|
|
708
734
|
${releases(ctx)}
|
|
709
735
|
${support(ctx)}
|
|
710
736
|
${links(ctx)}
|
|
737
|
+
${relatedSection(ctx)}
|
|
711
738
|
${tags(ctx)}
|
|
712
739
|
</div>
|
|
713
740
|
</main>
|
package/lib/site-data/config.js
CHANGED
|
@@ -54,6 +54,39 @@ function parseSupport(entry, where) {
|
|
|
54
54
|
}
|
|
55
55
|
return { kind: 'sepa', name, iban, bic, reference };
|
|
56
56
|
}
|
|
57
|
+
function httpUrl(value, where) {
|
|
58
|
+
if (!/^https?:\/\//.test(value))
|
|
59
|
+
throw new Error(`${where} must be an http(s) address`);
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/** Strings are site addresses to fetch `app.json` from; objects are inline entries for apps without one. */
|
|
63
|
+
function parseRelatedApps(raw) {
|
|
64
|
+
const value = raw.relatedApps;
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return [];
|
|
67
|
+
if (!Array.isArray(value))
|
|
68
|
+
throw new Error('gjsify.site.relatedApps must be a list');
|
|
69
|
+
return value.map((entry, index) => {
|
|
70
|
+
const where = `gjsify.site.relatedApps[${index}]`;
|
|
71
|
+
if (typeof entry === 'string')
|
|
72
|
+
return { url: withSlash(httpUrl(entry, where)) };
|
|
73
|
+
if (!isRecord(entry))
|
|
74
|
+
throw new Error(`${where} must be a site address or an object`);
|
|
75
|
+
const field = (key) => (entry[key] === undefined ? undefined : stringField(entry, key, where));
|
|
76
|
+
const url = withSlash(httpUrl(stringField(entry, 'url', where), `${where}.url`));
|
|
77
|
+
const name = field('name');
|
|
78
|
+
if (name === undefined) {
|
|
79
|
+
for (const key of ['summary', 'icon']) {
|
|
80
|
+
if (entry[key] !== undefined)
|
|
81
|
+
throw new Error(`${where}.${key} needs "name": inline entries have all three`);
|
|
82
|
+
}
|
|
83
|
+
return { url };
|
|
84
|
+
}
|
|
85
|
+
const icon = field('icon');
|
|
86
|
+
return { url, name, summary: field('summary'), icon: icon && httpUrl(icon, `${where}.icon`) };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
const withSlash = (url) => (url.endsWith('/') ? url : `${url}/`);
|
|
57
90
|
const PLATFORMS = ['linux', 'darwin', 'win32', 'android', 'web', 'firefox', 'chromium'];
|
|
58
91
|
const FORMATS = [
|
|
59
92
|
'flathub',
|
|
@@ -149,7 +182,7 @@ export function parseSiteConfig(manifest) {
|
|
|
149
182
|
return {
|
|
150
183
|
metainfo: requireString(raw, 'metainfo'),
|
|
151
184
|
base: normalizeBase(requireString(raw, 'base')),
|
|
152
|
-
url: url
|
|
185
|
+
url: withSlash(url),
|
|
153
186
|
languages: languages,
|
|
154
187
|
defaultLanguage,
|
|
155
188
|
sourceLanguage: optionalString(raw, 'sourceLanguage') ?? 'en',
|
|
@@ -167,6 +200,7 @@ export function parseSiteConfig(manifest) {
|
|
|
167
200
|
label: stringField(entry, 'label', `gjsify.site.legal[${index}]`),
|
|
168
201
|
url: stringField(entry, 'url', `gjsify.site.legal[${index}]`),
|
|
169
202
|
})),
|
|
203
|
+
relatedApps: parseRelatedApps(raw),
|
|
170
204
|
};
|
|
171
205
|
}
|
|
172
206
|
/** The `kind` labels the support page knows by name; the page catalogues translate them. */
|
package/lib/site-data/index.d.ts
CHANGED
package/lib/site-data/index.js
CHANGED
package/lib/site-data/load.js
CHANGED
|
@@ -8,6 +8,7 @@ import { fetchLatestRelease } 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';
|
|
11
|
+
import { loadRelatedApps } from './related.js';
|
|
11
12
|
import { resolveScreenshots } from './screenshots.js';
|
|
12
13
|
/** Reads `<dir>/<lang>.po` for every language, trying `pt-BR` and `pt_BR` spellings; missing files are skipped. */
|
|
13
14
|
export async function loadCatalogs(dir, languages) {
|
|
@@ -122,6 +123,11 @@ export async function loadSiteData(options) {
|
|
|
122
123
|
const developer = metainfo.developer.name
|
|
123
124
|
? metainfo.developer
|
|
124
125
|
: { id: manifest.gjsify?.flatpak?.developer?.id, name: manifest.gjsify?.flatpak?.developer?.name ?? '' };
|
|
126
|
+
const relatedApps = await loadRelatedApps(config, metainfo.id, {
|
|
127
|
+
offline: options.offline,
|
|
128
|
+
network: options.network,
|
|
129
|
+
warn,
|
|
130
|
+
});
|
|
125
131
|
const releases = metainfo.releases.map((release) => ({
|
|
126
132
|
...release,
|
|
127
133
|
description: completeBlocks(release.description),
|
|
@@ -160,6 +166,7 @@ export async function loadSiteData(options) {
|
|
|
160
166
|
hasReleases,
|
|
161
167
|
},
|
|
162
168
|
stats,
|
|
169
|
+
relatedApps,
|
|
163
170
|
warnings,
|
|
164
171
|
};
|
|
165
172
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type NetworkOptions } from './network.js';
|
|
2
|
+
import type { AppManifest, RelatedApp, SiteConfig, SiteData } from './types.js';
|
|
3
|
+
/** Where a site publishes its `app.json`. */
|
|
4
|
+
export declare const APP_MANIFEST_FILE = "app.json";
|
|
5
|
+
/** The `app.json` of a site: what another site needs to list this app. Icon URL is absolute. */
|
|
6
|
+
export declare function buildAppManifest(data: SiteData, icon: string | undefined): AppManifest;
|
|
7
|
+
/** Validates what came off the wire; returns why not when it is unusable. */
|
|
8
|
+
export declare function parseAppManifest(raw: unknown): RelatedApp | string;
|
|
9
|
+
export interface RelatedAppsOptions {
|
|
10
|
+
/** Never touch the network; only a copy from an earlier build can answer. */
|
|
11
|
+
offline?: boolean;
|
|
12
|
+
network?: NetworkOptions;
|
|
13
|
+
warn: (message: string) => void;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolves `gjsify.site.relatedApps` in configured order. The site's own entry is dropped, so every
|
|
17
|
+
* app can share one list. Never throws: an entry that cannot be had is skipped with a warning.
|
|
18
|
+
*/
|
|
19
|
+
export declare function loadRelatedApps(config: SiteConfig, ownId: string, options: RelatedAppsOptions): Promise<RelatedApp[]>;
|
|
20
|
+
/** A text in the reader's language, else English, else whatever the app has. */
|
|
21
|
+
export declare function relatedText(text: Record<string, string>, lang: string): string;
|
|
22
|
+
/** The page of a related app in the reader's language, or its start page when it has no such one. */
|
|
23
|
+
export declare function relatedUrl(app: RelatedApp, lang: string): string;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { fetchJson } from './network.js';
|
|
2
|
+
import { pickByLanguage } from './po.js';
|
|
3
|
+
/** Where a site publishes its `app.json`. */
|
|
4
|
+
export const APP_MANIFEST_FILE = 'app.json';
|
|
5
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
const urlKey = (url) => url.replace(/\/+$/, '').toLowerCase();
|
|
7
|
+
const sameUrl = (a, b) => urlKey(a) === urlKey(b);
|
|
8
|
+
const HTTP_URL = /^https?:\/\//;
|
|
9
|
+
/** The `app.json` of a site: what another site needs to list this app. Icon URL is absolute. */
|
|
10
|
+
export function buildAppManifest(data, icon) {
|
|
11
|
+
const texts = (text) => ({
|
|
12
|
+
[data.config.sourceLanguage]: text.source,
|
|
13
|
+
...text.byLanguage,
|
|
14
|
+
});
|
|
15
|
+
return {
|
|
16
|
+
schema: 1,
|
|
17
|
+
id: data.app.id,
|
|
18
|
+
name: texts(data.app.name),
|
|
19
|
+
summary: texts(data.app.summary),
|
|
20
|
+
icon,
|
|
21
|
+
url: data.config.url,
|
|
22
|
+
languages: [...data.config.languages],
|
|
23
|
+
developer: { id: data.app.developer.id, name: data.app.developer.name },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function stringRecord(value) {
|
|
27
|
+
if (!isRecord(value))
|
|
28
|
+
return undefined;
|
|
29
|
+
const entries = Object.entries(value);
|
|
30
|
+
if (!entries.every(([, text]) => typeof text === 'string'))
|
|
31
|
+
return undefined;
|
|
32
|
+
return Object.fromEntries(entries);
|
|
33
|
+
}
|
|
34
|
+
/** Validates what came off the wire; returns why not when it is unusable. */
|
|
35
|
+
export function parseAppManifest(raw) {
|
|
36
|
+
if (!isRecord(raw))
|
|
37
|
+
return 'not a JSON object';
|
|
38
|
+
if (raw.schema !== 1)
|
|
39
|
+
return `unknown schema ${JSON.stringify(raw.schema)}`;
|
|
40
|
+
const name = stringRecord(raw.name);
|
|
41
|
+
if (!name || Object.keys(name).length === 0)
|
|
42
|
+
return 'no "name"';
|
|
43
|
+
const summary = stringRecord(raw.summary) ?? {};
|
|
44
|
+
if (typeof raw.url !== 'string' || !HTTP_URL.test(raw.url))
|
|
45
|
+
return 'no http(s) "url"';
|
|
46
|
+
const languages = Array.isArray(raw.languages) ? raw.languages.filter((l) => typeof l === 'string') : [];
|
|
47
|
+
const icon = typeof raw.icon === 'string' && HTTP_URL.test(raw.icon) ? raw.icon : undefined;
|
|
48
|
+
return {
|
|
49
|
+
id: typeof raw.id === 'string' ? raw.id : undefined,
|
|
50
|
+
name,
|
|
51
|
+
summary,
|
|
52
|
+
icon,
|
|
53
|
+
url: raw.url.endsWith('/') ? raw.url : `${raw.url}/`,
|
|
54
|
+
languages,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const offlineFetch = () => Promise.reject(new Error('offline build'));
|
|
58
|
+
async function loadOne(entry, options) {
|
|
59
|
+
if (entry.name !== undefined) {
|
|
60
|
+
return {
|
|
61
|
+
name: { en: entry.name },
|
|
62
|
+
summary: entry.summary === undefined ? {} : { en: entry.summary },
|
|
63
|
+
icon: entry.icon,
|
|
64
|
+
url: entry.url,
|
|
65
|
+
languages: [],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const file = `${entry.url}${APP_MANIFEST_FILE}`;
|
|
69
|
+
try {
|
|
70
|
+
// A TTL of zero: always ask first, and let the cache answer only when the site does not.
|
|
71
|
+
const raw = await fetchJson(file, { accept: 'application/json' }, { ...options.network, cacheTtlMs: 0, fetch: options.offline ? offlineFetch : options.network?.fetch });
|
|
72
|
+
const parsed = parseAppManifest(raw);
|
|
73
|
+
if (typeof parsed === 'string') {
|
|
74
|
+
options.warn(`Related app ${file}: ${parsed}; skipping it`);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
options.warn(`Related app ${file} is unavailable (${String(error)}) and no earlier copy exists; skipping it`);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolves `gjsify.site.relatedApps` in configured order. The site's own entry is dropped, so every
|
|
86
|
+
* app can share one list. Never throws: an entry that cannot be had is skipped with a warning.
|
|
87
|
+
*/
|
|
88
|
+
export async function loadRelatedApps(config, ownId, options) {
|
|
89
|
+
const candidates = config.relatedApps.filter((entry) => !sameUrl(entry.url, config.url));
|
|
90
|
+
const loaded = await Promise.all(candidates.map((entry) => loadOne(entry, options)));
|
|
91
|
+
return loaded.filter((app) => app !== undefined && app.id !== ownId && !sameUrl(app.url, config.url));
|
|
92
|
+
}
|
|
93
|
+
/** A text in the reader's language, else English, else whatever the app has. */
|
|
94
|
+
export function relatedText(text, lang) {
|
|
95
|
+
return pickByLanguage(text, lang) ?? text.en ?? Object.values(text)[0] ?? '';
|
|
96
|
+
}
|
|
97
|
+
/** The page of a related app in the reader's language, or its start page when it has no such one. */
|
|
98
|
+
export function relatedUrl(app, lang) {
|
|
99
|
+
if (app.languages.length === 0)
|
|
100
|
+
return app.url;
|
|
101
|
+
return `${app.url}${app.languages.includes(lang) ? lang : app.languages[0]}/`;
|
|
102
|
+
}
|
package/lib/site-data/types.d.ts
CHANGED
|
@@ -73,6 +73,47 @@ export interface SiteDownloadConfig {
|
|
|
73
73
|
/** Id of the `gjsify.site.components` entry this target installs. */
|
|
74
74
|
component?: string;
|
|
75
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* One entry of `gjsify.site.relatedApps`: the site of another app, whose `app.json` is fetched at
|
|
78
|
+
* build time. With `name` set it is an inline entry instead — for an app that publishes no
|
|
79
|
+
* `app.json` (not built with gjsify) — and nothing is fetched.
|
|
80
|
+
*/
|
|
81
|
+
export interface SiteRelatedAppConfig {
|
|
82
|
+
url: string;
|
|
83
|
+
name?: string;
|
|
84
|
+
summary?: string;
|
|
85
|
+
icon?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* `app.json`, published next to every site's `index.html` so other sites can list the app. Bump
|
|
89
|
+
* `schema` on a breaking change; a reader ignores a schema it does not know.
|
|
90
|
+
*/
|
|
91
|
+
export interface AppManifest {
|
|
92
|
+
schema: 1;
|
|
93
|
+
id: string;
|
|
94
|
+
/** Keyed by language, the metainfo's source text under `sourceLanguage`. */
|
|
95
|
+
name: Record<string, string>;
|
|
96
|
+
summary: Record<string, string>;
|
|
97
|
+
/** Absolute URL of the app icon. */
|
|
98
|
+
icon?: string;
|
|
99
|
+
/** The site's own address. */
|
|
100
|
+
url: string;
|
|
101
|
+
/** Languages the site has pages for: `<url><lang>/` exists. */
|
|
102
|
+
languages: string[];
|
|
103
|
+
developer: {
|
|
104
|
+
id?: string;
|
|
105
|
+
name: string;
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** A related app as the page shows it: an `app.json` or an inline entry, validated. */
|
|
109
|
+
export interface RelatedApp {
|
|
110
|
+
id?: string;
|
|
111
|
+
name: Record<string, string>;
|
|
112
|
+
summary: Record<string, string>;
|
|
113
|
+
icon?: string;
|
|
114
|
+
url: string;
|
|
115
|
+
languages: string[];
|
|
116
|
+
}
|
|
76
117
|
/** The `gjsify.site` block of the root package.json, with defaults applied. */
|
|
77
118
|
export interface SiteConfig {
|
|
78
119
|
metainfo: string;
|
|
@@ -107,6 +148,8 @@ export interface SiteConfig {
|
|
|
107
148
|
releaseAssets: boolean;
|
|
108
149
|
support: SiteSupportEntry[];
|
|
109
150
|
legal: SiteLegalLink[];
|
|
151
|
+
/** Sites of other apps to list under "More apps by <developer>", in order. Empty: no section. */
|
|
152
|
+
relatedApps: SiteRelatedAppConfig[];
|
|
110
153
|
}
|
|
111
154
|
/**
|
|
112
155
|
* One entry of the metainfo `<screenshots>` list, with its artwork located on disk. The same images
|
|
@@ -247,6 +290,8 @@ export interface SiteData {
|
|
|
247
290
|
hasReleases: boolean;
|
|
248
291
|
};
|
|
249
292
|
stats: Stats;
|
|
293
|
+
/** Resolved `gjsify.site.relatedApps`, in configured order, without this site's own app. */
|
|
294
|
+
relatedApps: RelatedApp[];
|
|
250
295
|
/** Non-fatal problems met while collecting data, already logged. */
|
|
251
296
|
warnings: string[];
|
|
252
297
|
}
|
package/lib/site.js
CHANGED
|
@@ -13,7 +13,7 @@ import { ogCardSvg } from './render/og-card.js';
|
|
|
13
13
|
import { absolute, localized, pageMeta, pageUrl, renderPage, renderRoot, renderSupportPage, supportUrl, } from './render/pages.js';
|
|
14
14
|
import { renderOverview } from './render/overview.js';
|
|
15
15
|
import { robotsTxt, seoWarnings, sitemapXml } from './render/seo.js';
|
|
16
|
-
import { UI_SOURCE_LANGUAGE, loadCatalogs, loadSiteData, mergeCatalogs, parseMetainfo, parseSiteConfig, pickByLanguage, translator, } from './site-data/index.js';
|
|
16
|
+
import { UI_SOURCE_LANGUAGE, loadCatalogs, loadSiteData, mergeCatalogs, parseMetainfo, parseSiteConfig, pickByLanguage, translator, APP_MANIFEST_FILE, buildAppManifest, } from './site-data/index.js';
|
|
17
17
|
/**
|
|
18
18
|
* This package's own directory: `lib/` holds the client bundle, `po/` the page catalogues.
|
|
19
19
|
*
|
|
@@ -150,6 +150,8 @@ export async function buildSite(options) {
|
|
|
150
150
|
await writeFile(join(target.out, lang, 'support', 'index.html'), renderSupportPage(ctx));
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
const manifest = buildAppManifest(data, iconUrl ? absolute(data, iconUrl) : undefined);
|
|
154
|
+
await writeFile(join(target.out, APP_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
153
155
|
await writeFile(join(target.out, 'index.html'), renderRoot(data, assets, iconUrl));
|
|
154
156
|
const address = (build) => Object.fromEntries(data.config.languages.map((lang) => [lang, absolute(data, build(data, lang))]));
|
|
155
157
|
const pages = [address(pageUrl), ...(hasSupport ? [address(supportUrl)] : [])];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/site",
|
|
3
|
-
"version": "0.64.3-next.
|
|
3
|
+
"version": "0.64.3-next.262",
|
|
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,26 @@
|
|
|
54
54
|
"license": "MIT",
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@girs/gdkpixbuf-2.0": "5.4.0",
|
|
57
|
-
"@gjsify/console": "0.64.3-next.
|
|
58
|
-
"@gjsify/crypto": "0.64.3-next.
|
|
59
|
-
"@gjsify/fs": "0.64.3-next.
|
|
60
|
-
"@gjsify/module": "0.64.3-next.
|
|
61
|
-
"@gjsify/os": "0.64.3-next.
|
|
62
|
-
"@gjsify/path": "0.64.3-next.
|
|
63
|
-
"@gjsify/process": "0.64.3-next.
|
|
64
|
-
"@gjsify/url": "0.64.3-next.
|
|
57
|
+
"@gjsify/console": "0.64.3-next.262",
|
|
58
|
+
"@gjsify/crypto": "0.64.3-next.262",
|
|
59
|
+
"@gjsify/fs": "0.64.3-next.262",
|
|
60
|
+
"@gjsify/module": "0.64.3-next.262",
|
|
61
|
+
"@gjsify/os": "0.64.3-next.262",
|
|
62
|
+
"@gjsify/path": "0.64.3-next.262",
|
|
63
|
+
"@gjsify/process": "0.64.3-next.262",
|
|
64
|
+
"@gjsify/url": "0.64.3-next.262",
|
|
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.3-next.
|
|
69
|
+
"@gjsify/cli": "0.64.3-next.262",
|
|
70
70
|
"@gjsify/node-gi": "file:../../node-gi/node-gi",
|
|
71
|
-
"@gjsify/unit": "0.64.3-next.
|
|
71
|
+
"@gjsify/unit": "0.64.3-next.262",
|
|
72
72
|
"@types/node": "^25.9.2",
|
|
73
73
|
"typescript": "^6.0.3"
|
|
74
74
|
},
|
|
75
75
|
"gjsify": {
|
|
76
76
|
"tier": 2
|
|
77
77
|
},
|
|
78
|
-
"gitHead": "
|
|
78
|
+
"gitHead": "0f5ca82aab25bf9df8d1e790d28483b1f90fe3a9"
|
|
79
79
|
}
|
package/po/de.po
CHANGED
|
@@ -168,6 +168,12 @@ msgstr "Ältere Versionen"
|
|
|
168
168
|
msgid "Links"
|
|
169
169
|
msgstr "Links"
|
|
170
170
|
|
|
171
|
+
msgid "More apps by {developer}"
|
|
172
|
+
msgstr "Weitere Apps von {developer}"
|
|
173
|
+
|
|
174
|
+
msgid "More apps"
|
|
175
|
+
msgstr "Weitere Apps"
|
|
176
|
+
|
|
171
177
|
msgid "Copied"
|
|
172
178
|
msgstr "Kopiert"
|
|
173
179
|
|