@gjsify/site 0.64.6 → 0.64.8

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 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
+ }
@@ -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 {};
@@ -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.view === 'support' ? supportUrl(data, code) : pageUrl(data, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(label)}</a></li>`;
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
- const legal = data.config.legal
604
- .map((link) => `<li><a href="${esc(link.url)}">${esc(link.label)}</a></li>`)
605
- .join('');
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) => (view === 'support' ? supportUrl(data, code) : pageUrl(data, 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;
@@ -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
- /** Best format first, per OS. The first target in this order is the recommended one. */
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', 'appimage', 'flatpak', 'deb', 'rpm'],
24
+ linux: ['flathub', 'flatpak', 'appimage', 'deb', 'rpm'],
18
25
  darwin: ['dmg', 'zip'],
19
26
  win32: ['msi', 'zip'],
20
27
  android: ['apk'],
@@ -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';
@@ -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
+ }
@@ -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 = [address(pageUrl), ...(hasSupport ? [address(supportUrl)] : [])];
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.6",
3
+ "version": "0.64.8",
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.6",
58
- "@gjsify/crypto": "^0.64.6",
59
- "@gjsify/fs": "^0.64.6",
60
- "@gjsify/module": "^0.64.6",
61
- "@gjsify/os": "^0.64.6",
62
- "@gjsify/path": "^0.64.6",
63
- "@gjsify/process": "^0.64.6",
64
- "@gjsify/url": "^0.64.6",
57
+ "@gjsify/console": "^0.64.8",
58
+ "@gjsify/crypto": "^0.64.8",
59
+ "@gjsify/fs": "^0.64.8",
60
+ "@gjsify/module": "^0.64.8",
61
+ "@gjsify/os": "^0.64.8",
62
+ "@gjsify/path": "^0.64.8",
63
+ "@gjsify/process": "^0.64.8",
64
+ "@gjsify/url": "^0.64.8",
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.6",
69
+ "@gjsify/cli": "^0.64.8",
70
70
  "@gjsify/node-gi": "file:../../node-gi/node-gi",
71
- "@gjsify/unit": "^0.64.6",
71
+ "@gjsify/unit": "^0.64.8",
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