@gjsify/site 0.64.6 → 0.64.7
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 +60 -1
- package/lib/render/markdown.d.ts +1 -0
- package/lib/render/markdown.js +113 -0
- package/lib/render/pages.d.ts +23 -2
- package/lib/render/pages.js +148 -7
- package/lib/site-data/config.js +44 -0
- package/lib/site-data/downloads.js +9 -2
- package/lib/site-data/index.d.ts +2 -0
- package/lib/site-data/index.js +2 -0
- package/lib/site-data/legal-notice.d.ts +7 -0
- package/lib/site-data/legal-notice.js +19 -0
- package/lib/site-data/privacy.d.ts +8 -0
- package/lib/site-data/privacy.js +22 -0
- package/lib/site-data/types.d.ts +31 -0
- package/lib/site.js +22 -3
- package/package.json +11 -11
- package/po/de.po +21 -0
- package/po/site.pot +21 -0
package/README.md
CHANGED
|
@@ -63,6 +63,61 @@ and rasterised by GdkPixbuf), `SoftwareApplication` JSON-LD, `hreflang` alternat
|
|
|
63
63
|
root owns it and must list this sitemap. A page title or meta description that is too short
|
|
64
64
|
(or only the app name) is reported as a build warning.
|
|
65
65
|
|
|
66
|
+
A metainfo URL that points into the site's own `/<lang>/…` pages (it starts with `gjsify.site.url`)
|
|
67
|
+
is rewritten to the language of the page being rendered, so `/en/support/` becomes `/de/support/` on
|
|
68
|
+
the German page. Other addresses are left alone.
|
|
69
|
+
|
|
70
|
+
## Privacy pages
|
|
71
|
+
|
|
72
|
+
With `gjsify.site.privacy` set, a build also writes `/<base><lang>/privacy/index.html` and links it
|
|
73
|
+
from the footer of every page (title "Privacy" / "Datenschutz"). It opens with one built-in
|
|
74
|
+
paragraph — about visiting THIS page, which sets no cookie, runs no analytics and loads nothing
|
|
75
|
+
from a third-party domain, because this package adds none of those — then the consumer's own
|
|
76
|
+
statement of what the APP does with data, read verbatim from the file `statements[lang]` names and
|
|
77
|
+
rendered through a small Markdown subset (headings, paragraphs, lists, GFM tables, links, bold,
|
|
78
|
+
inline code; `#`/`##`/`###` render as `h2`/`h3`/`h4`, one level up, since the page already has its
|
|
79
|
+
own `h1`).
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
"privacy": {
|
|
83
|
+
"statements": { "en": "docs/privacy.md", "de": "docs/privacy.de.md" },
|
|
84
|
+
"hostingPolicyUrl": "https://docs.github.com/en/site-policy/privacy-policies/github-privacy-statement"
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**The statement is never generated**, and every configured language needs its own entry — a
|
|
89
|
+
missing one fails the build rather than silently showing English where a translation was promised.
|
|
90
|
+
This is deliberate, not a gap to close: what the APP does with data is a claim about its own code,
|
|
91
|
+
checked the same way `docs/store/privacy.md` already is in a consumer like Beifahrer (every
|
|
92
|
+
substantive line cited to a `file:line`), and an app that talks to no data only because the page
|
|
93
|
+
*says* so is exactly the "inaccurate privacy policy" app stores remove listings for. `@gjsify/site`
|
|
94
|
+
can derive the hosting paragraph because it is a fact about what THIS package ships, not about the
|
|
95
|
+
product — see [ADR 0109](../../../docs/adr/0109-a-static-site-generator-reads-an-app-from-its-metainfo.md)'s
|
|
96
|
+
amendment on why that line is drawn there. `hostingPolicyUrl` is the one hosting fact that is a
|
|
97
|
+
per-deployment choice rather than a package guarantee, so it is config, named only when given.
|
|
98
|
+
|
|
99
|
+
## Legal Notice / Impressum pages
|
|
100
|
+
|
|
101
|
+
With `gjsify.site.legalNotice` set, a build also writes `/<base><lang>/legal-notice/index.html` and
|
|
102
|
+
links it from the footer of every page (title "Legal Notice" / "Impressum"). Same shape as
|
|
103
|
+
`privacy` — one statement file per configured language, read verbatim and rendered through the same
|
|
104
|
+
Markdown subset — but **no built-in paragraph is prepended**: unlike the privacy page, this package
|
|
105
|
+
has nothing of its own to say about who is legally responsible for a site, so the whole page is the
|
|
106
|
+
consumer's own statement.
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
"legalNotice": {
|
|
110
|
+
"statements": { "en": "docs/legal-notice.md", "de": "docs/legal-notice.de.md" }
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
German law (§ 5 DDG, formerly § 5 TMG) requires a "Legal Notice"/Impressum directly on a site tied
|
|
115
|
+
to a business, not merely linked from another domain — the page this key builds satisfies that.
|
|
116
|
+
Content is typically identical across one publisher's apps (same legal entity, same address), but
|
|
117
|
+
this package does not assume or enforce that: each consumer supplies its own files, the same way
|
|
118
|
+
`privacy` does, because what counts as identical is a fact about the publisher, not something a
|
|
119
|
+
shared package can derive.
|
|
120
|
+
|
|
66
121
|
`gjsify site build --overview <file> --out <dir>` renders one page that lists several apps, each
|
|
67
122
|
linking to its own support page, for a host such as `jumplink.eu/support/`:
|
|
68
123
|
|
|
@@ -109,6 +164,8 @@ Lives in the root `package.json`, next to `ship` and `flatpak`.
|
|
|
109
164
|
| `releaseAssets` | `false` ignores the GitHub release assets, so `downloads` is the whole list. Default `true`. |
|
|
110
165
|
| `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. |
|
|
111
166
|
| `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. |
|
|
167
|
+
| `privacy` | `{statements: {<lang>: <path>}, hostingPolicyUrl?}`: a privacy page at `/<lang>/privacy/`, see below. Absent: no such page. |
|
|
168
|
+
| `legalNotice` | `{statements: {<lang>: <path>}}`: a Legal Notice/Impressum page at `/<lang>/legal-notice/`, see below. Absent: no such page. |
|
|
112
169
|
|
|
113
170
|
A `sepa` entry carries no amount, and its IBAN is checked when the config loads — length per
|
|
114
171
|
country, alphabet, and the mod-97 checksum — so a typo fails the build instead of printing a QR
|
|
@@ -139,7 +196,9 @@ unknown id fails the build with the known ids listed.
|
|
|
139
196
|
|
|
140
197
|
The recommendation ("Recommended for your system") is made once per part, so each block
|
|
141
198
|
highlights its own best match, and the big button at the top of the dialog offers the first
|
|
142
|
-
declared component: the thing to install first.
|
|
199
|
+
declared component: the thing to install first. On Linux the order is Flathub, the Flatpak
|
|
200
|
+
bundle, the AppImage, then deb and rpm: the Flatpak runs sandboxed on the shared GNOME runtime
|
|
201
|
+
and updates with it, where an AppImage trusts the host's typelibs and needs FUSE.
|
|
143
202
|
|
|
144
203
|
### Related apps
|
|
145
204
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function renderMarkdown(source: string): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { esc } from './html.js';
|
|
2
|
+
const HEADING = /^(#{1,3})\s+(.*)$/;
|
|
3
|
+
const ORDERED = /^\d+\.\s+(.*)$/;
|
|
4
|
+
const UNORDERED = /^[-*]\s+(.*)$/;
|
|
5
|
+
const HR = /^-{3,}$/;
|
|
6
|
+
const TABLE_SEPARATOR = /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/;
|
|
7
|
+
/** One pipe-table row, split on unescaped `|`, outer pipes dropped. */
|
|
8
|
+
function splitRow(line) {
|
|
9
|
+
return line
|
|
10
|
+
.trim()
|
|
11
|
+
.replace(/^\|/, '')
|
|
12
|
+
.replace(/\|$/, '')
|
|
13
|
+
.split('|')
|
|
14
|
+
.map((cell) => cell.trim());
|
|
15
|
+
}
|
|
16
|
+
function isTableStart(lines, index) {
|
|
17
|
+
const line = lines[index];
|
|
18
|
+
const next = lines[index + 1];
|
|
19
|
+
return line !== undefined && line.includes('|') && next !== undefined && TABLE_SEPARATOR.test(next.trim());
|
|
20
|
+
}
|
|
21
|
+
function isBlockStart(lines, index) {
|
|
22
|
+
const line = lines[index];
|
|
23
|
+
return (line.trim() === '' ||
|
|
24
|
+
HEADING.test(line) ||
|
|
25
|
+
HR.test(line.trim()) ||
|
|
26
|
+
ORDERED.test(line) ||
|
|
27
|
+
UNORDERED.test(line) ||
|
|
28
|
+
isTableStart(lines, index));
|
|
29
|
+
}
|
|
30
|
+
function parseBlocks(source) {
|
|
31
|
+
const lines = source.replace(/\r\n/g, '\n').split('\n');
|
|
32
|
+
const blocks = [];
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < lines.length) {
|
|
35
|
+
const line = lines[i];
|
|
36
|
+
if (line.trim() === '') {
|
|
37
|
+
i++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (HR.test(line.trim())) {
|
|
41
|
+
blocks.push({ kind: 'hr' });
|
|
42
|
+
i++;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const heading = HEADING.exec(line);
|
|
46
|
+
if (heading) {
|
|
47
|
+
blocks.push({ kind: 'heading', level: (heading[1].length + 1), text: heading[2].trim() });
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (isTableStart(lines, i)) {
|
|
52
|
+
const header = splitRow(line);
|
|
53
|
+
i += 2;
|
|
54
|
+
const rows = [];
|
|
55
|
+
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
|
|
56
|
+
rows.push(splitRow(lines[i]));
|
|
57
|
+
i++;
|
|
58
|
+
}
|
|
59
|
+
blocks.push({ kind: 'table', header, rows });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (ORDERED.test(line) || UNORDERED.test(line)) {
|
|
63
|
+
const ordered = ORDERED.test(line);
|
|
64
|
+
const pattern = ordered ? ORDERED : UNORDERED;
|
|
65
|
+
const items = [];
|
|
66
|
+
while (i < lines.length && pattern.test(lines[i])) {
|
|
67
|
+
items.push(pattern.exec(lines[i])[1].trim());
|
|
68
|
+
i++;
|
|
69
|
+
}
|
|
70
|
+
blocks.push({ kind: 'list', ordered, items });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const paragraph = [];
|
|
74
|
+
while (i < lines.length && !isBlockStart(lines, i)) {
|
|
75
|
+
paragraph.push(lines[i].trim());
|
|
76
|
+
i++;
|
|
77
|
+
}
|
|
78
|
+
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') });
|
|
79
|
+
}
|
|
80
|
+
return blocks;
|
|
81
|
+
}
|
|
82
|
+
/** Escapes first, then marks up the already-escaped text — a link's URL and label are both safe. */
|
|
83
|
+
function renderInline(text) {
|
|
84
|
+
let html = esc(text);
|
|
85
|
+
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, url) => `<a href="${url}">${label}</a>`);
|
|
86
|
+
html = html.replace(/`([^`]+)`/g, (_match, code) => `<code>${code}</code>`);
|
|
87
|
+
html = html.replace(/\*\*([^*]+)\*\*/g, (_match, bold) => `<strong>${bold}</strong>`);
|
|
88
|
+
return html;
|
|
89
|
+
}
|
|
90
|
+
function renderBlock(block) {
|
|
91
|
+
switch (block.kind) {
|
|
92
|
+
case 'heading':
|
|
93
|
+
return `<h${block.level}>${renderInline(block.text)}</h${block.level}>`;
|
|
94
|
+
case 'paragraph':
|
|
95
|
+
return `<p>${renderInline(block.text)}</p>`;
|
|
96
|
+
case 'list': {
|
|
97
|
+
const tag = block.ordered ? 'ol' : 'ul';
|
|
98
|
+
return `<${tag}>${block.items.map((item) => `<li>${renderInline(item)}</li>`).join('')}</${tag}>`;
|
|
99
|
+
}
|
|
100
|
+
case 'table': {
|
|
101
|
+
const head = `<thead><tr>${block.header.map((cell) => `<th>${renderInline(cell)}</th>`).join('')}</tr></thead>`;
|
|
102
|
+
const body = `<tbody>${block.rows
|
|
103
|
+
.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell)}</td>`).join('')}</tr>`)
|
|
104
|
+
.join('')}</tbody>`;
|
|
105
|
+
return `<table>${head}${body}</table>`;
|
|
106
|
+
}
|
|
107
|
+
case 'hr':
|
|
108
|
+
return '<hr>';
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function renderMarkdown(source) {
|
|
112
|
+
return parseBlocks(source).map(renderBlock).join('\n');
|
|
113
|
+
}
|
package/lib/render/pages.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface RenderContext {
|
|
|
15
15
|
/** Published URL of the app icon, when there is one. */
|
|
16
16
|
iconUrl?: string;
|
|
17
17
|
/** Which page of the app this renders; the language switch and the head follow it. Default `app`. */
|
|
18
|
-
view?: 'app' | 'support';
|
|
18
|
+
view?: 'app' | 'support' | 'privacy' | 'legal-notice';
|
|
19
19
|
/** Root-relative URL of the link-preview card for this language. */
|
|
20
20
|
ogImage?: string;
|
|
21
21
|
}
|
|
@@ -25,7 +25,14 @@ export type DownloadContext = Pick<RenderContext, 'data' | 't' | 'lang'>;
|
|
|
25
25
|
export declare function localized(text: Localized, lang: string): string;
|
|
26
26
|
export declare function pageUrl(data: SiteData, lang: string): string;
|
|
27
27
|
export declare function absolute(data: SiteData, rootRelative: string): string;
|
|
28
|
+
/**
|
|
29
|
+
* A link into this site's own `/<lang>/…` pages, moved to the language being rendered; any other
|
|
30
|
+
* address is returned as it came. Apps put e.g. their English support page in the metainfo.
|
|
31
|
+
*/
|
|
32
|
+
export declare function localizeOwnUrl(data: SiteData, url: string, lang: string): string;
|
|
28
33
|
export declare function supportUrl(data: SiteData, lang: string): string;
|
|
34
|
+
export declare function privacyUrl(data: SiteData, lang: string): string;
|
|
35
|
+
export declare function legalNoticeUrl(data: SiteData, lang: string): string;
|
|
29
36
|
/** The transfer reference when the config sets none: one per app, so the bank statement says what it was for. */
|
|
30
37
|
export declare function supportReference(data: SiteData): string;
|
|
31
38
|
export declare function languageName(code: string): string;
|
|
@@ -56,7 +63,7 @@ export declare function relatedSection(ctx: Pick<RenderContext, 'data' | 'lang'
|
|
|
56
63
|
/** A bank transfer as a card: the details to read or copy, and an EPC QR code that fills them into a banking app. */
|
|
57
64
|
export declare function sepaCard(entry: SiteSupportSepa, t: Translate): string;
|
|
58
65
|
/** The title and description of one page; also what the build checks for weak ones. */
|
|
59
|
-
export declare function pageMeta(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>, view?: 'app' | 'support'): {
|
|
66
|
+
export declare function pageMeta(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>, view?: 'app' | 'support' | 'privacy' | 'legal-notice'): {
|
|
60
67
|
name: string;
|
|
61
68
|
title: string;
|
|
62
69
|
description: string;
|
|
@@ -64,6 +71,20 @@ export declare function pageMeta(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>
|
|
|
64
71
|
export declare function renderPage(ctx: RenderContext): string;
|
|
65
72
|
/** `/<lang>/support/`: the ways to support the app, as plain markup that needs no script. */
|
|
66
73
|
export declare function renderSupportPage(page: RenderContext): string;
|
|
74
|
+
/**
|
|
75
|
+
* `/<lang>/privacy/`: the hosting paragraph above, then the app's own statement of what it does
|
|
76
|
+
* with data — read verbatim from the file `gjsify.site.privacy.statements[lang]` names, rendered
|
|
77
|
+
* through the Markdown subset in `markdown.ts`. Never generated: see ADR 0109's amendment on why.
|
|
78
|
+
*/
|
|
79
|
+
export declare function renderPrivacyPage(page: RenderContext, statement: string): string;
|
|
80
|
+
/**
|
|
81
|
+
* `/<lang>/legal-notice/`: the app's own Legal Notice / Impressum statement, read verbatim from
|
|
82
|
+
* `gjsify.site.legalNotice.statements[lang]` and rendered through the same Markdown subset as
|
|
83
|
+
* `privacy`. Unlike `privacy`, no built-in paragraph is prepended — there is nothing about hosting
|
|
84
|
+
* to disclose here, only who is legally responsible for the site, and that is entirely the
|
|
85
|
+
* consumer's own statement.
|
|
86
|
+
*/
|
|
87
|
+
export declare function renderLegalNoticePage(page: RenderContext, statement: string): string;
|
|
67
88
|
/** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
|
|
68
89
|
export declare function renderRoot(data: SiteData, assets: ClientAssets, iconUrl: string | undefined): string;
|
|
69
90
|
export {};
|
package/lib/render/pages.js
CHANGED
|
@@ -2,6 +2,7 @@ import { epcPayload, formatIban, isBrowserPlatform, PLATFORM_ORDER, pickByLangua
|
|
|
2
2
|
import { readableText } from './color.js';
|
|
3
3
|
import { attrs, esc, fmt, plain } from './html.js';
|
|
4
4
|
import { icon } from './icons.js';
|
|
5
|
+
import { renderMarkdown } from './markdown.js';
|
|
5
6
|
import { OG_HEIGHT, OG_WIDTH } from './og-card.js';
|
|
6
7
|
import { qrSvg } from './qr.js';
|
|
7
8
|
import { metaDescription, ogLocale, pageTitle, softwareApplicationJsonLd } from './seo.js';
|
|
@@ -35,9 +36,29 @@ export function pageUrl(data, lang) {
|
|
|
35
36
|
export function absolute(data, rootRelative) {
|
|
36
37
|
return new URL(rootRelative.replace(/^\//, ''), `${new URL(data.config.url).origin}/`).href;
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A link into this site's own `/<lang>/…` pages, moved to the language being rendered; any other
|
|
41
|
+
* address is returned as it came. Apps put e.g. their English support page in the metainfo.
|
|
42
|
+
*/
|
|
43
|
+
export function localizeOwnUrl(data, url, lang) {
|
|
44
|
+
const own = `${data.config.url.replace(/\/+$/, '')}/`;
|
|
45
|
+
if (!url.startsWith(own))
|
|
46
|
+
return url;
|
|
47
|
+
const rest = url.slice(own.length);
|
|
48
|
+
const segment = /^[^/?#]*/.exec(rest)[0];
|
|
49
|
+
if (!data.config.languages.includes(segment))
|
|
50
|
+
return url;
|
|
51
|
+
return `${own}${lang}${rest.slice(segment.length)}`;
|
|
52
|
+
}
|
|
38
53
|
export function supportUrl(data, lang) {
|
|
39
54
|
return `${pageUrl(data, lang)}support/`;
|
|
40
55
|
}
|
|
56
|
+
export function privacyUrl(data, lang) {
|
|
57
|
+
return `${pageUrl(data, lang)}privacy/`;
|
|
58
|
+
}
|
|
59
|
+
export function legalNoticeUrl(data, lang) {
|
|
60
|
+
return `${pageUrl(data, lang)}legal-notice/`;
|
|
61
|
+
}
|
|
41
62
|
/** The transfer reference when the config sets none: one per app, so the bank statement says what it was for. */
|
|
42
63
|
export function supportReference(data) {
|
|
43
64
|
return `Support ${data.app.name.source}`;
|
|
@@ -447,13 +468,13 @@ function sameUrl(a, b) {
|
|
|
447
468
|
return a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
|
|
448
469
|
}
|
|
449
470
|
function links(ctx) {
|
|
450
|
-
const { data, t } = ctx;
|
|
471
|
+
const { data, lang, t } = ctx;
|
|
451
472
|
const items = LINKS.flatMap(([key, label, glyph]) => {
|
|
452
473
|
const href = data.app.urls[key];
|
|
453
474
|
// No point linking the page the reader is on: the metainfo's homepage is often this site.
|
|
454
475
|
if (!href || (key === 'homepage' && sameUrl(href, data.config.url)))
|
|
455
476
|
return [];
|
|
456
|
-
return [linkCard(href, label(t), glyph)];
|
|
477
|
+
return [linkCard(localizeOwnUrl(data, href, lang), label(t), glyph)];
|
|
457
478
|
});
|
|
458
479
|
if (data.config.flathub)
|
|
459
480
|
items.unshift(linkCard(`https://flathub.org/apps/${data.config.flathub}`, 'Flathub', 'package'));
|
|
@@ -543,13 +564,24 @@ function tags(ctx) {
|
|
|
543
564
|
].join('\n');
|
|
544
565
|
return body.trim() ? `<section class="tags-section">\n${body}\n</section>` : '';
|
|
545
566
|
}
|
|
567
|
+
/** Which address the current view's own page lives at, in another language. */
|
|
568
|
+
function viewUrl(ctx, code) {
|
|
569
|
+
const { data, view } = ctx;
|
|
570
|
+
if (view === 'support')
|
|
571
|
+
return supportUrl(data, code);
|
|
572
|
+
if (view === 'privacy')
|
|
573
|
+
return privacyUrl(data, code);
|
|
574
|
+
if (view === 'legal-notice')
|
|
575
|
+
return legalNoticeUrl(data, code);
|
|
576
|
+
return pageUrl(data, code);
|
|
577
|
+
}
|
|
546
578
|
function languageSwitch(ctx, className, style) {
|
|
547
579
|
const { data, lang, t } = ctx;
|
|
548
580
|
const items = data.config.languages.map((code) => {
|
|
549
581
|
const label = style === 'code' ? code.toUpperCase() : languageName(code);
|
|
550
582
|
return code === lang
|
|
551
583
|
? `<li><span aria-current="true">${esc(label)}</span></li>`
|
|
552
|
-
: `<li><a href="${esc(ctx
|
|
584
|
+
: `<li><a href="${esc(viewUrl(ctx, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(label)}</a></li>`;
|
|
553
585
|
});
|
|
554
586
|
return `<nav class="${className}" aria-label="${esc(t('Language'))}"><ul>${items.join('')}</ul></nav>`;
|
|
555
587
|
}
|
|
@@ -600,9 +632,16 @@ function footer(ctx) {
|
|
|
600
632
|
const { data, lang, t } = ctx;
|
|
601
633
|
const name = localized(data.app.name, lang);
|
|
602
634
|
const repo = repositoryUrl(data);
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
635
|
+
// The privacy and legal-notice links are derived, never configured: a consumer with a
|
|
636
|
+
// `gjsify.site.privacy`/`legalNotice` block gets the link on every page without also listing
|
|
637
|
+
// it in `legal`, which would be a second place for the two to disagree.
|
|
638
|
+
const legal = [
|
|
639
|
+
...(data.config.legalNotice
|
|
640
|
+
? [`<li><a href="${esc(legalNoticeUrl(data, lang))}">${esc(t('Legal Notice'))}</a></li>`]
|
|
641
|
+
: []),
|
|
642
|
+
...(data.config.privacy ? [`<li><a href="${esc(privacyUrl(data, lang))}">${esc(t('Privacy'))}</a></li>`] : []),
|
|
643
|
+
...data.config.legal.map((link) => `<li><a href="${esc(link.url)}">${esc(link.label)}</a></li>`),
|
|
644
|
+
].join('');
|
|
606
645
|
const licenses = [
|
|
607
646
|
data.app.license ? fmt(t('{app} is licensed under {license}.'), { app: name, license: data.app.license }) : '',
|
|
608
647
|
data.app.contentLicense
|
|
@@ -632,6 +671,20 @@ export function pageMeta(ctx, view = 'app') {
|
|
|
632
671
|
}),
|
|
633
672
|
};
|
|
634
673
|
}
|
|
674
|
+
if (view === 'privacy') {
|
|
675
|
+
return {
|
|
676
|
+
name,
|
|
677
|
+
title: `${t('Privacy')} – ${name}`,
|
|
678
|
+
description: plain(t('What {app} does with data.'), { app: name }),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
if (view === 'legal-notice') {
|
|
682
|
+
return {
|
|
683
|
+
name,
|
|
684
|
+
title: `${t('Legal Notice')} – ${name}`,
|
|
685
|
+
description: plain(t('Who publishes {app}, and how to reach them.'), { app: name }),
|
|
686
|
+
};
|
|
687
|
+
}
|
|
635
688
|
const summary = localized(data.app.summary, lang);
|
|
636
689
|
const first = data.app.description.find((block) => block.type === 'paragraph');
|
|
637
690
|
const paragraph = first?.type === 'paragraph' ? localized(first.text, lang) : undefined;
|
|
@@ -667,7 +720,7 @@ function structuredData(ctx, title, description) {
|
|
|
667
720
|
function head(ctx) {
|
|
668
721
|
const { data, lang } = ctx;
|
|
669
722
|
const view = ctx.view ?? 'app';
|
|
670
|
-
const viewPath = (code) => (
|
|
723
|
+
const viewPath = (code) => viewUrl({ data, view }, code);
|
|
671
724
|
const { name, title, description } = pageMeta(ctx, view);
|
|
672
725
|
const url = absolute(data, viewPath(lang));
|
|
673
726
|
const ogImage = ctx.ogImage ? absolute(data, ctx.ogImage) : undefined;
|
|
@@ -781,6 +834,94 @@ ${footer(ctx)}
|
|
|
781
834
|
</html>
|
|
782
835
|
`;
|
|
783
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* The paragraph every privacy page opens with, regardless of what the app does. It is about
|
|
839
|
+
* visiting THIS static page, not about the app — this package adds no cookie, no analytics and no
|
|
840
|
+
* third-party resource of its own, so that much is true for every consumer. `hostingPolicyUrl` is
|
|
841
|
+
* the one fact that varies by deployment and is not guessed; left out, nothing about hosting is said.
|
|
842
|
+
*/
|
|
843
|
+
function hostingNotice(ctx) {
|
|
844
|
+
const { data, t } = ctx;
|
|
845
|
+
const intro = t('This page is a static file: no cookie, no analytics, nothing from a third party.');
|
|
846
|
+
const logs = t('Whoever serves it may keep ordinary web-server access logs of the request.');
|
|
847
|
+
const link = data.config.privacy?.hostingPolicyUrl;
|
|
848
|
+
return `<p class="privacy-hosting">${esc(intro)} ${esc(logs)}${link ? ` <a href="${esc(link)}">${esc(t("See the hosting provider's notice"))}</a>` : ''}</p>`;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* `/<lang>/privacy/`: the hosting paragraph above, then the app's own statement of what it does
|
|
852
|
+
* with data — read verbatim from the file `gjsify.site.privacy.statements[lang]` names, rendered
|
|
853
|
+
* through the Markdown subset in `markdown.ts`. Never generated: see ADR 0109's amendment on why.
|
|
854
|
+
*/
|
|
855
|
+
export function renderPrivacyPage(page, statement) {
|
|
856
|
+
const ctx = { ...page, view: 'privacy' };
|
|
857
|
+
const { data, lang, t } = ctx;
|
|
858
|
+
const meta = pageMeta(ctx, 'privacy');
|
|
859
|
+
return `<!doctype html>
|
|
860
|
+
<html lang="${esc(lang)}">
|
|
861
|
+
<head>
|
|
862
|
+
${head(ctx)}
|
|
863
|
+
</head>
|
|
864
|
+
<body>
|
|
865
|
+
${topBar(ctx)}
|
|
866
|
+
<header class="masthead">
|
|
867
|
+
<div class="hero-inner">
|
|
868
|
+
${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
|
|
869
|
+
<div class="hero-text">
|
|
870
|
+
<h1>${esc(t('Privacy'))}</h1>
|
|
871
|
+
<p class="hero-by">${esc(meta.name)}</p>
|
|
872
|
+
</div>
|
|
873
|
+
</div>
|
|
874
|
+
</header>
|
|
875
|
+
<main>
|
|
876
|
+
<div class="column privacy-statement">
|
|
877
|
+
${hostingNotice(ctx)}
|
|
878
|
+
${renderMarkdown(statement)}
|
|
879
|
+
<p class="support-back"><a href="${esc(pageUrl(data, lang))}">${fmt(t('Back to {app}'), { app: meta.name })}</a></p>
|
|
880
|
+
</div>
|
|
881
|
+
</main>
|
|
882
|
+
${footer(ctx)}
|
|
883
|
+
</body>
|
|
884
|
+
</html>
|
|
885
|
+
`;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* `/<lang>/legal-notice/`: the app's own Legal Notice / Impressum statement, read verbatim from
|
|
889
|
+
* `gjsify.site.legalNotice.statements[lang]` and rendered through the same Markdown subset as
|
|
890
|
+
* `privacy`. Unlike `privacy`, no built-in paragraph is prepended — there is nothing about hosting
|
|
891
|
+
* to disclose here, only who is legally responsible for the site, and that is entirely the
|
|
892
|
+
* consumer's own statement.
|
|
893
|
+
*/
|
|
894
|
+
export function renderLegalNoticePage(page, statement) {
|
|
895
|
+
const ctx = { ...page, view: 'legal-notice' };
|
|
896
|
+
const { data, lang, t } = ctx;
|
|
897
|
+
const meta = pageMeta(ctx, 'legal-notice');
|
|
898
|
+
return `<!doctype html>
|
|
899
|
+
<html lang="${esc(lang)}">
|
|
900
|
+
<head>
|
|
901
|
+
${head(ctx)}
|
|
902
|
+
</head>
|
|
903
|
+
<body>
|
|
904
|
+
${topBar(ctx)}
|
|
905
|
+
<header class="masthead">
|
|
906
|
+
<div class="hero-inner">
|
|
907
|
+
${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
|
|
908
|
+
<div class="hero-text">
|
|
909
|
+
<h1>${esc(t('Legal Notice'))}</h1>
|
|
910
|
+
<p class="hero-by">${esc(meta.name)}</p>
|
|
911
|
+
</div>
|
|
912
|
+
</div>
|
|
913
|
+
</header>
|
|
914
|
+
<main>
|
|
915
|
+
<div class="column privacy-statement">
|
|
916
|
+
${renderMarkdown(statement)}
|
|
917
|
+
<p class="support-back"><a href="${esc(pageUrl(data, lang))}">${fmt(t('Back to {app}'), { app: meta.name })}</a></p>
|
|
918
|
+
</div>
|
|
919
|
+
</main>
|
|
920
|
+
${footer(ctx)}
|
|
921
|
+
</body>
|
|
922
|
+
</html>
|
|
923
|
+
`;
|
|
924
|
+
}
|
|
784
925
|
/** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
|
|
785
926
|
export function renderRoot(data, assets, iconUrl) {
|
|
786
927
|
const { config } = data;
|
package/lib/site-data/config.js
CHANGED
|
@@ -155,6 +155,48 @@ function parseDownloads(raw, components) {
|
|
|
155
155
|
return { platform, format, url, label, available, component };
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* `gjsify.site.privacy`: every configured language needs its own statement file. A language with
|
|
160
|
+
* no entry fails the build rather than falling back silently — a legal page missing a translation
|
|
161
|
+
* it promised is worse than no page at all.
|
|
162
|
+
*/
|
|
163
|
+
function parsePrivacy(raw, languages) {
|
|
164
|
+
const value = raw.privacy;
|
|
165
|
+
if (value === undefined)
|
|
166
|
+
return undefined;
|
|
167
|
+
if (!isRecord(value))
|
|
168
|
+
throw new Error('gjsify.site.privacy must be an object');
|
|
169
|
+
const statementsRaw = value.statements;
|
|
170
|
+
if (!isRecord(statementsRaw)) {
|
|
171
|
+
throw new Error('gjsify.site.privacy.statements must be an object mapping a language to a file path');
|
|
172
|
+
}
|
|
173
|
+
const statements = {};
|
|
174
|
+
for (const lang of languages) {
|
|
175
|
+
statements[lang] = stringField(statementsRaw, lang, 'gjsify.site.privacy.statements');
|
|
176
|
+
}
|
|
177
|
+
return { statements, hostingPolicyUrl: optionalString(value, 'hostingPolicyUrl') };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* `gjsify.site.legalNotice`: same per-language requirement as `privacy` — a language with no entry
|
|
181
|
+
* fails the build. The content is usually identical across a publisher's apps, but that is a fact
|
|
182
|
+
* about what the consumer WRITES, not something this parser assumes or enforces.
|
|
183
|
+
*/
|
|
184
|
+
function parseLegalNotice(raw, languages) {
|
|
185
|
+
const value = raw.legalNotice;
|
|
186
|
+
if (value === undefined)
|
|
187
|
+
return undefined;
|
|
188
|
+
if (!isRecord(value))
|
|
189
|
+
throw new Error('gjsify.site.legalNotice must be an object');
|
|
190
|
+
const statementsRaw = value.statements;
|
|
191
|
+
if (!isRecord(statementsRaw)) {
|
|
192
|
+
throw new Error('gjsify.site.legalNotice.statements must be an object mapping a language to a file path');
|
|
193
|
+
}
|
|
194
|
+
const statements = {};
|
|
195
|
+
for (const lang of languages) {
|
|
196
|
+
statements[lang] = stringField(statementsRaw, lang, 'gjsify.site.legalNotice.statements');
|
|
197
|
+
}
|
|
198
|
+
return { statements };
|
|
199
|
+
}
|
|
158
200
|
function parseReleaseAssets(raw) {
|
|
159
201
|
const value = raw.releaseAssets ?? true;
|
|
160
202
|
if (typeof value !== 'boolean')
|
|
@@ -196,6 +238,8 @@ export function parseSiteConfig(manifest) {
|
|
|
196
238
|
downloads: parseDownloads(raw, components),
|
|
197
239
|
releaseAssets: parseReleaseAssets(raw),
|
|
198
240
|
support: objectList(raw, 'support').map((entry, index) => parseSupport(entry, `gjsify.site.support[${index}]`)),
|
|
241
|
+
privacy: parsePrivacy(raw, languages),
|
|
242
|
+
legalNotice: parseLegalNotice(raw, languages),
|
|
199
243
|
legal: objectList(raw, 'legal').map((entry, index) => ({
|
|
200
244
|
label: stringField(entry, 'label', `gjsify.site.legal[${index}]`),
|
|
201
245
|
url: stringField(entry, 'url', `gjsify.site.legal[${index}]`),
|
|
@@ -12,9 +12,16 @@ export const PLATFORM_ORDER = [
|
|
|
12
12
|
export function isBrowserPlatform(platform) {
|
|
13
13
|
return platform === 'firefox' || platform === 'chromium';
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Best format first, per OS. The first target in this order is the recommended one.
|
|
17
|
+
*
|
|
18
|
+
* A Flatpak bundle outranks the AppImage on Linux: it runs sandboxed on the shared GNOME runtime
|
|
19
|
+
* and updates with that runtime through the system's Flatpak installation, while an AppImage runs
|
|
20
|
+
* against the host's typelibs and needs FUSE. Flathub stays ahead of both, as the same Flatpak
|
|
21
|
+
* with store updates.
|
|
22
|
+
*/
|
|
16
23
|
const FORMAT_RANK = {
|
|
17
|
-
linux: ['flathub', '
|
|
24
|
+
linux: ['flathub', 'flatpak', 'appimage', 'deb', 'rpm'],
|
|
18
25
|
darwin: ['dmg', 'zip'],
|
|
19
26
|
win32: ['msi', 'zip'],
|
|
20
27
|
android: ['apk'],
|
package/lib/site-data/index.d.ts
CHANGED
|
@@ -5,8 +5,10 @@ export * from './github.js';
|
|
|
5
5
|
export * from './load.js';
|
|
6
6
|
export * from './metainfo.js';
|
|
7
7
|
export * from './network.js';
|
|
8
|
+
export * from './legal-notice.js';
|
|
8
9
|
export * from './oars.js';
|
|
9
10
|
export * from './po.js';
|
|
11
|
+
export * from './privacy.js';
|
|
10
12
|
export * from './related.js';
|
|
11
13
|
export * from './screenshots.js';
|
|
12
14
|
export * from './sepa.js';
|
package/lib/site-data/index.js
CHANGED
|
@@ -5,8 +5,10 @@ export * from './github.js';
|
|
|
5
5
|
export * from './load.js';
|
|
6
6
|
export * from './metainfo.js';
|
|
7
7
|
export * from './network.js';
|
|
8
|
+
export * from './legal-notice.js';
|
|
8
9
|
export * from './oars.js';
|
|
9
10
|
export * from './po.js';
|
|
11
|
+
export * from './privacy.js';
|
|
10
12
|
export * from './related.js';
|
|
11
13
|
export * from './screenshots.js';
|
|
12
14
|
export * from './sepa.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { SiteLegalNoticeConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reads every language's Legal Notice / Impressum statement, verbatim — the same contract as
|
|
4
|
+
* `loadPrivacyStatements`. A file that cannot be read fails the build naming the language and the
|
|
5
|
+
* configured path.
|
|
6
|
+
*/
|
|
7
|
+
export declare function loadLegalNoticeStatements(legalNotice: SiteLegalNoticeConfig, root: string): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Reads every language's Legal Notice / Impressum statement, verbatim — the same contract as
|
|
5
|
+
* `loadPrivacyStatements`. A file that cannot be read fails the build naming the language and the
|
|
6
|
+
* configured path.
|
|
7
|
+
*/
|
|
8
|
+
export async function loadLegalNoticeStatements(legalNotice, root) {
|
|
9
|
+
const entries = await Promise.all(Object.entries(legalNotice.statements).map(async ([lang, path]) => {
|
|
10
|
+
const file = resolve(root, path);
|
|
11
|
+
try {
|
|
12
|
+
return [lang, await readFile(file, 'utf8')];
|
|
13
|
+
}
|
|
14
|
+
catch (cause) {
|
|
15
|
+
throw new Error(`gjsify.site.legalNotice.statements.${lang} points to "${path}", which could not be read`, { cause });
|
|
16
|
+
}
|
|
17
|
+
}));
|
|
18
|
+
return Object.fromEntries(entries);
|
|
19
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { SitePrivacyConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Reads every language's statement file, verbatim — never parsed beyond what the renderer's
|
|
4
|
+
* Markdown subset does at render time, never translated by this package. A file that cannot be
|
|
5
|
+
* read fails the build naming the language and the configured path, the same way a missing
|
|
6
|
+
* `gjsify.site.metainfo` does.
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadPrivacyStatements(privacy: SitePrivacyConfig, root: string): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Reads every language's statement file, verbatim — never parsed beyond what the renderer's
|
|
5
|
+
* Markdown subset does at render time, never translated by this package. A file that cannot be
|
|
6
|
+
* read fails the build naming the language and the configured path, the same way a missing
|
|
7
|
+
* `gjsify.site.metainfo` does.
|
|
8
|
+
*/
|
|
9
|
+
export async function loadPrivacyStatements(privacy, root) {
|
|
10
|
+
const entries = await Promise.all(Object.entries(privacy.statements).map(async ([lang, path]) => {
|
|
11
|
+
const file = resolve(root, path);
|
|
12
|
+
try {
|
|
13
|
+
return [lang, await readFile(file, 'utf8')];
|
|
14
|
+
}
|
|
15
|
+
catch (cause) {
|
|
16
|
+
throw new Error(`gjsify.site.privacy.statements.${lang} points to "${path}", which could not be read`, {
|
|
17
|
+
cause,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
}));
|
|
21
|
+
return Object.fromEntries(entries);
|
|
22
|
+
}
|
package/lib/site-data/types.d.ts
CHANGED
|
@@ -41,6 +41,33 @@ export interface SiteLegalLink {
|
|
|
41
41
|
label: string;
|
|
42
42
|
url: string;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* `gjsify.site.privacy`: a consumer-authored statement of what the APP does with data — never
|
|
46
|
+
* generated, because that claim is about the product's own code, not about this page. One file
|
|
47
|
+
* per configured language, each checked independently rather than translated by the generator.
|
|
48
|
+
*/
|
|
49
|
+
export interface SitePrivacyConfig {
|
|
50
|
+
/** `<lang>` (from `gjsify.site.languages`) → repo-relative path of that language's statement. */
|
|
51
|
+
statements: Record<string, string>;
|
|
52
|
+
/**
|
|
53
|
+
* Link to the hoster's own data-processing notice, named in the page's built-in hosting
|
|
54
|
+
* paragraph. Left out, nothing about hosting beyond what this package itself guarantees is
|
|
55
|
+
* said — a provider is a per-deployment fact this package cannot derive.
|
|
56
|
+
*/
|
|
57
|
+
hostingPolicyUrl?: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `gjsify.site.legalNotice`: a consumer-authored "Legal Notice" / Impressum statement — one file per
|
|
61
|
+
* configured language, like `privacy`. Unlike `privacy`, the content is typically IDENTICAL across a
|
|
62
|
+
* publisher's apps (same legal entity, same address), but this package never assumes that: it reads
|
|
63
|
+
* whatever each consumer's own file says, the same way `privacy` does. No built-in paragraph is added
|
|
64
|
+
* — unlike the hosting disclosure on the privacy page, there is nothing this package itself can say
|
|
65
|
+
* about who is legally responsible for a site's content.
|
|
66
|
+
*/
|
|
67
|
+
export interface SiteLegalNoticeConfig {
|
|
68
|
+
/** `<lang>` (from `gjsify.site.languages`) → repo-relative path of that language's statement. */
|
|
69
|
+
statements: Record<string, string>;
|
|
70
|
+
}
|
|
44
71
|
/**
|
|
45
72
|
* One part of the product a person installs separately — a desktop app, the browser extension it
|
|
46
73
|
* pairs with, a command line build. Declared in `gjsify.site.components`, in the order they are
|
|
@@ -148,6 +175,10 @@ export interface SiteConfig {
|
|
|
148
175
|
releaseAssets: boolean;
|
|
149
176
|
support: SiteSupportEntry[];
|
|
150
177
|
legal: SiteLegalLink[];
|
|
178
|
+
/** A privacy/data-practices page at `/<lang>/privacy/`. `undefined`: no such page is built. */
|
|
179
|
+
privacy?: SitePrivacyConfig;
|
|
180
|
+
/** A Legal Notice / Impressum page at `/<lang>/legal-notice/`. `undefined`: no such page is built. */
|
|
181
|
+
legalNotice?: SiteLegalNoticeConfig;
|
|
151
182
|
/** Sites of other apps to list under "More apps by <developer>", in order. Empty: no section. */
|
|
152
183
|
relatedApps: SiteRelatedAppConfig[];
|
|
153
184
|
}
|
package/lib/site.js
CHANGED
|
@@ -10,10 +10,10 @@ import { readableText } from './render/color.js';
|
|
|
10
10
|
import { svgToPng } from './render/image-backend.js';
|
|
11
11
|
import { processImage } from './render/images.js';
|
|
12
12
|
import { ogCardSvg } from './render/og-card.js';
|
|
13
|
-
import { absolute, localized, pageMeta, pageUrl, renderPage, renderRoot, renderSupportPage, supportUrl, } from './render/pages.js';
|
|
13
|
+
import { absolute, legalNoticeUrl, localized, pageMeta, pageUrl, privacyUrl, renderLegalNoticePage, renderPage, renderPrivacyPage, 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, APP_MANIFEST_FILE, buildAppManifest, } from './site-data/index.js';
|
|
16
|
+
import { UI_SOURCE_LANGUAGE, loadCatalogs, loadSiteData, mergeCatalogs, parseMetainfo, parseSiteConfig, pickByLanguage, translator, APP_MANIFEST_FILE, buildAppManifest, loadLegalNoticeStatements, loadPrivacyStatements, } 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
|
*
|
|
@@ -129,6 +129,12 @@ export async function buildSite(options) {
|
|
|
129
129
|
const iconHref = await iconDataUri(data, target);
|
|
130
130
|
const { light = '#84bbfd' } = data.app.branding;
|
|
131
131
|
const hasSupport = data.config.support.length > 0;
|
|
132
|
+
const privacyStatements = data.config.privacy
|
|
133
|
+
? await loadPrivacyStatements(data.config.privacy, target.root)
|
|
134
|
+
: undefined;
|
|
135
|
+
const legalNoticeStatements = data.config.legalNotice
|
|
136
|
+
? await loadLegalNoticeStatements(data.config.legalNotice, target.root)
|
|
137
|
+
: undefined;
|
|
132
138
|
for (const lang of data.config.languages) {
|
|
133
139
|
const t = translator(catalogs[lang]);
|
|
134
140
|
const ctx = { data, lang, t, assets, images, iconUrl, ogImage: `${data.config.base}og-${lang}.png` };
|
|
@@ -149,12 +155,25 @@ export async function buildSite(options) {
|
|
|
149
155
|
await mkdir(join(target.out, lang, 'support'), { recursive: true });
|
|
150
156
|
await writeFile(join(target.out, lang, 'support', 'index.html'), renderSupportPage(ctx));
|
|
151
157
|
}
|
|
158
|
+
if (privacyStatements) {
|
|
159
|
+
await mkdir(join(target.out, lang, 'privacy'), { recursive: true });
|
|
160
|
+
await writeFile(join(target.out, lang, 'privacy', 'index.html'), renderPrivacyPage(ctx, privacyStatements[lang]));
|
|
161
|
+
}
|
|
162
|
+
if (legalNoticeStatements) {
|
|
163
|
+
await mkdir(join(target.out, lang, 'legal-notice'), { recursive: true });
|
|
164
|
+
await writeFile(join(target.out, lang, 'legal-notice', 'index.html'), renderLegalNoticePage(ctx, legalNoticeStatements[lang]));
|
|
165
|
+
}
|
|
152
166
|
}
|
|
153
167
|
const manifest = buildAppManifest(data, iconUrl ? absolute(data, iconUrl) : undefined);
|
|
154
168
|
await writeFile(join(target.out, APP_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
155
169
|
await writeFile(join(target.out, 'index.html'), renderRoot(data, assets, iconUrl));
|
|
156
170
|
const address = (build) => Object.fromEntries(data.config.languages.map((lang) => [lang, absolute(data, build(data, lang))]));
|
|
157
|
-
const pages = [
|
|
171
|
+
const pages = [
|
|
172
|
+
address(pageUrl),
|
|
173
|
+
...(hasSupport ? [address(supportUrl)] : []),
|
|
174
|
+
...(privacyStatements ? [address(privacyUrl)] : []),
|
|
175
|
+
...(legalNoticeStatements ? [address(legalNoticeUrl)] : []),
|
|
176
|
+
];
|
|
158
177
|
await writeFile(join(target.out, 'sitemap.xml'), sitemapXml(pages, data.config.defaultLanguage));
|
|
159
178
|
// Only a site served from the host root can own `/robots.txt`; below a path the host does.
|
|
160
179
|
if (data.config.base === '/') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/site",
|
|
3
|
-
"version": "0.64.
|
|
3
|
+
"version": "0.64.7",
|
|
4
4
|
"description": "Static landing page generator for a GNOME-style app, rendered from its AppStream metainfo — downloads, screenshots, release notes, support links, no JavaScript required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -54,21 +54,21 @@
|
|
|
54
54
|
"license": "MIT",
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@girs/gdkpixbuf-2.0": "5.4.0",
|
|
57
|
-
"@gjsify/console": "^0.64.
|
|
58
|
-
"@gjsify/crypto": "^0.64.
|
|
59
|
-
"@gjsify/fs": "^0.64.
|
|
60
|
-
"@gjsify/module": "^0.64.
|
|
61
|
-
"@gjsify/os": "^0.64.
|
|
62
|
-
"@gjsify/path": "^0.64.
|
|
63
|
-
"@gjsify/process": "^0.64.
|
|
64
|
-
"@gjsify/url": "^0.64.
|
|
57
|
+
"@gjsify/console": "^0.64.7",
|
|
58
|
+
"@gjsify/crypto": "^0.64.7",
|
|
59
|
+
"@gjsify/fs": "^0.64.7",
|
|
60
|
+
"@gjsify/module": "^0.64.7",
|
|
61
|
+
"@gjsify/os": "^0.64.7",
|
|
62
|
+
"@gjsify/path": "^0.64.7",
|
|
63
|
+
"@gjsify/process": "^0.64.7",
|
|
64
|
+
"@gjsify/url": "^0.64.7",
|
|
65
65
|
"fast-xml-parser": "^5.11.2",
|
|
66
66
|
"qrcode-generator": "^2.0.4"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@gjsify/cli": "^0.64.
|
|
69
|
+
"@gjsify/cli": "^0.64.7",
|
|
70
70
|
"@gjsify/node-gi": "file:../../node-gi/node-gi",
|
|
71
|
-
"@gjsify/unit": "^0.64.
|
|
71
|
+
"@gjsify/unit": "^0.64.7",
|
|
72
72
|
"@types/node": "^25.9.2",
|
|
73
73
|
"typescript": "^6.0.3"
|
|
74
74
|
},
|
package/po/de.po
CHANGED
|
@@ -210,6 +210,12 @@ msgstr "Architekturen"
|
|
|
210
210
|
msgid "by {developer}"
|
|
211
211
|
msgstr "von {developer}"
|
|
212
212
|
|
|
213
|
+
msgid "Legal Notice"
|
|
214
|
+
msgstr "Impressum"
|
|
215
|
+
|
|
216
|
+
msgid "Privacy"
|
|
217
|
+
msgstr "Datenschutz"
|
|
218
|
+
|
|
213
219
|
msgid "{app} is licensed under {license}."
|
|
214
220
|
msgstr "{app} steht unter der Lizenz {license}."
|
|
215
221
|
|
|
@@ -222,9 +228,24 @@ msgstr "Gemacht mit {project}"
|
|
|
222
228
|
msgid "{app} is free software. Support its development with a transfer or a service."
|
|
223
229
|
msgstr "{app} ist freie Software. Unterstütze die Entwicklung per Überweisung oder über einen Dienst."
|
|
224
230
|
|
|
231
|
+
msgid "What {app} does with data."
|
|
232
|
+
msgstr "Was {app} mit Daten tut."
|
|
233
|
+
|
|
234
|
+
msgid "Who publishes {app}, and how to reach them."
|
|
235
|
+
msgstr "Wer {app} veröffentlicht, und wie man diese Person erreicht."
|
|
236
|
+
|
|
225
237
|
msgid "Back to {app}"
|
|
226
238
|
msgstr "Zurück zu {app}"
|
|
227
239
|
|
|
240
|
+
msgid "This page is a static file: no cookie, no analytics, nothing from a third party."
|
|
241
|
+
msgstr "Diese Seite ist eine statische Datei: keine Cookies, keine Analysewerkzeuge, nichts von Drittanbietern."
|
|
242
|
+
|
|
243
|
+
msgid "Whoever serves it may keep ordinary web-server access logs of the request."
|
|
244
|
+
msgstr "Wer sie bereitstellt, kann dabei die üblichen Zugriffsprotokolle eines Webservers führen."
|
|
245
|
+
|
|
246
|
+
msgid "See the hosting provider's notice"
|
|
247
|
+
msgstr "Hinweis des Hosting-Anbieters"
|
|
248
|
+
|
|
228
249
|
msgid "Flathub"
|
|
229
250
|
msgstr "Flathub"
|
|
230
251
|
|
package/po/site.pot
CHANGED
|
@@ -209,6 +209,12 @@ msgstr ""
|
|
|
209
209
|
msgid "by {developer}"
|
|
210
210
|
msgstr ""
|
|
211
211
|
|
|
212
|
+
msgid "Legal Notice"
|
|
213
|
+
msgstr ""
|
|
214
|
+
|
|
215
|
+
msgid "Privacy"
|
|
216
|
+
msgstr ""
|
|
217
|
+
|
|
212
218
|
msgid "{app} is licensed under {license}."
|
|
213
219
|
msgstr ""
|
|
214
220
|
|
|
@@ -221,9 +227,24 @@ msgstr ""
|
|
|
221
227
|
msgid "{app} is free software. Support its development with a transfer or a service."
|
|
222
228
|
msgstr ""
|
|
223
229
|
|
|
230
|
+
msgid "What {app} does with data."
|
|
231
|
+
msgstr ""
|
|
232
|
+
|
|
233
|
+
msgid "Who publishes {app}, and how to reach them."
|
|
234
|
+
msgstr ""
|
|
235
|
+
|
|
224
236
|
msgid "Back to {app}"
|
|
225
237
|
msgstr ""
|
|
226
238
|
|
|
239
|
+
msgid "This page is a static file: no cookie, no analytics, nothing from a third party."
|
|
240
|
+
msgstr ""
|
|
241
|
+
|
|
242
|
+
msgid "Whoever serves it may keep ordinary web-server access logs of the request."
|
|
243
|
+
msgstr ""
|
|
244
|
+
|
|
245
|
+
msgid "See the hosting provider's notice"
|
|
246
|
+
msgstr ""
|
|
247
|
+
|
|
227
248
|
msgid "Flathub"
|
|
228
249
|
msgstr ""
|
|
229
250
|
|