@gjsify/site 0.63.1-next.236 → 0.63.1-next.237

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
@@ -39,6 +39,7 @@ gjsify site build --runtime node # build and run on a specific runtime
39
39
 
40
40
  | Option | Meaning |
41
41
  | --- | --- |
42
+ | `--overview <file>` | `build`: render the support overview listed in this JSON file instead of one app (see below). |
42
43
  | `--root <path>` | Repository to work on — a checkout whose root `package.json` has `gjsify.site`. Default: the working directory. |
43
44
  | `--out <dir>` | Output directory, emptied first. Default: `<root>/dist`. |
44
45
  | `--offline` | Skip every network call. The page then links to the releases page and omits the counts. |
@@ -47,6 +48,36 @@ gjsify site build --runtime node # build and run on a specific runtime
47
48
  | `--check` | `i18n`: report what is out of date instead of writing it. |
48
49
  | `--runtime <r>` | `gjs` \| `node` \| `bun` \| `deno`. Default: the runtime the CLI itself is on. |
49
50
 
51
+ ## Support pages and SEO
52
+
53
+ With `gjsify.site.support` set, a build also writes `/<base><lang>/support/index.html` (title
54
+ "Support" / "Unterstützen"; the landing page's support section links to it). It needs no script:
55
+ the GiroCode, the bank details and the link cards (GitHub Sponsors, Liberapay, PayPal) are plain
56
+ markup. The transfer reference defaults to `Support <app name>`, so every app's income is
57
+ recognisable on the statement; `reference` in the `sepa` entry overrides it.
58
+
59
+ Every build also writes a 1200×630 link-preview card per language (`og-<lang>.png`, drawn as SVG
60
+ and rasterised by GdkPixbuf), `SoftwareApplication` JSON-LD, `hreflang` alternates with an
61
+ `x-default` that points at the default-language page, a valid `og:locale` (`de_DE`), and a
62
+ `sitemap.xml`. `robots.txt` is written only when `gjsify.site.base` is `/`; below a path the host
63
+ root owns it and must list this sitemap. A page title or meta description that is too short
64
+ (or only the app name) is reported as a build warning.
65
+
66
+ `gjsify site build --overview <file> --out <dir>` renders one page that lists several apps, each
67
+ linking to its own support page, for a host such as `jumplink.eu/support/`:
68
+
69
+ ```json
70
+ {
71
+ "url": "https://jumplink.eu/",
72
+ "base": "/support/",
73
+ "languages": ["en", "de"],
74
+ "apps": ["../learn6502", "../beifahrer"]
75
+ }
76
+ ```
77
+
78
+ Each `apps` entry is a repository root (relative to the file) with its own `gjsify.site` block;
79
+ its name and published address are read from there.
80
+
50
81
  `GITHUB_TOKEN` is optional and only raises the API rate limit. When a lookup fails the build
51
82
  warns rather than failing: a release API outage is not a reason to have no page.
52
83
 
package/lib/cli.js CHANGED
@@ -5,13 +5,15 @@
5
5
  // the CLI itself is on, and runs it — so there is no `gjsify-site` bin bound to
6
6
  // one runtime. See `packages/infra/cli/src/commands/site.ts`.
7
7
  import { join, resolve } from 'node:path';
8
- import { buildSite, syncSiteI18n } from './site.js';
8
+ import { buildOverview, buildSite, syncSiteI18n } from './site.js';
9
9
  const USAGE = `Usage: gjsify site <command> [options]
10
10
 
11
11
  build [options] Render the static pages of a repository
12
12
  i18n [options] Update the page catalogues from the strings the build can emit
13
13
 
14
14
  Options:
15
+ --overview <file> build: render the support overview listed in this JSON file
16
+ instead of one app (url, languages, apps)
15
17
  --root <path> Repository to work on (default: the working directory)
16
18
  --out <dir> Output directory, emptied first (default: <root>/dist)
17
19
  --offline Skip every network call
@@ -42,6 +44,16 @@ function cacheOption(flags) {
42
44
  return flagValue(flags, '--cache-dir');
43
45
  }
44
46
  async function build(flags) {
47
+ const overview = flagValue(flags, '--overview');
48
+ if (overview) {
49
+ const result = await buildOverview({
50
+ config: overview,
51
+ out: resolve(flagValue(flags, '--out') ?? join(process.cwd(), 'dist')),
52
+ packageDir: flagValue(flags, '--package-dir'),
53
+ });
54
+ console.log(`Built ${result.languages.length} overview pages for ${result.apps} apps`);
55
+ return 0;
56
+ }
45
57
  const root = resolve(flagValue(flags, '--root') ?? process.cwd());
46
58
  const result = await buildSite({
47
59
  root,
@@ -517,11 +517,15 @@ h1 {
517
517
  /* `--shot-w` is the CSS width this screenshot's renditions were cut for: at that width a 2560px
518
518
  * source is a 2× image, and above it the browser would be upscaling. Width and max-height
519
519
  * together shrink-wrap the artwork — `object-fit: contain` would letterbox it instead, and the
520
- * shadow would then be drawn around the empty strip. */
520
+ * shadow would then be drawn around the empty strip. Both are maxima on an `auto` box, and the
521
+ * height cap is `--shot-h`, not `100%`: the figure is `fit-content`, and in that intrinsic pass a
522
+ * percentage max-height counts as none, so the figure took the full width while the image was
523
+ * capped in height — a portrait shot (a browser popup) came out squashed. */
521
524
  .slide img {
522
- width: min(100%, var(--shot-w, 100%));
525
+ width: auto;
526
+ max-width: min(100%, var(--shot-w, 100%));
523
527
  height: auto;
524
- max-height: 100%;
528
+ max-height: var(--shot-h);
525
529
  border-radius: var(--radius-lg);
526
530
  box-shadow: var(--shot-shadow);
527
531
  }
@@ -1045,6 +1049,18 @@ app-download:not([data-enhanced]) .download-toggle {
1045
1049
  margin-block: 2rem;
1046
1050
  }
1047
1051
 
1052
+ app-support {
1053
+ display: block;
1054
+ }
1055
+
1056
+ .support-intro {
1057
+ margin-block: 0 1.5rem;
1058
+ }
1059
+
1060
+ .support-back {
1061
+ margin-block: 1.5rem 0;
1062
+ }
1063
+
1048
1064
  /* Footer */
1049
1065
 
1050
1066
  .footer {
@@ -1 +1 @@
1
- function enhanceDialog(e,t){let n=e.querySelector(`.dialog-close`);n&&(n.hidden=!1,n.addEventListener(`click`,()=>e.close())),t?.addEventListener(`click`,t=>{t.preventDefault(),e.showModal()}),e.addEventListener(`click`,t=>{t.target===e&&e.close()})}const e={linux:`x86_64`,darwin:`arm64`,win32:`x86_64`,android:`arm64`,web:`noarch`};function detectOs(e){let t=`${e.platform??``} ${e.userAgent??``}`;if(/android/i.test(t))return`android`;if(/iphone|ipad|ipod|\bios\b/i.test(t))return`web`;if(/mac|darwin/i.test(t))return`darwin`;if(/win/i.test(t))return`win32`;if(/linux|x11|bsd|cros|chrome ?os/i.test(t))return`linux`}function detectArch(e){if(e.architecture)return e.bitness===`32`?void 0:/arm/i.test(e.architecture)?`arm64`:/x86|amd/i.test(e.architecture)?`x86_64`:void 0;let t=e.userAgent??``;if(/arm64|aarch64/i.test(t))return`arm64`;if(/x86_64|x86-64|amd64|win64|wow64|\bx64\b/i.test(t))return`x86_64`}function detectBrowser(e){let t=e.userAgent??``;if(/firefox|fxios/i.test(t))return`firefox`;if(e.brands?.some(e=>/chromium|chrome|edge|opera|brave/i.test(e))||/chrome|chromium|crios|edg(a|ios)?\/|opr\//i.test(t))return`chromium`}function detectVisitorPlatform(t){let n=detectOs(t),r=detectArch(t);return{os:n,browser:detectBrowser(t),arch:r??(n?e[n]:void 0),archAssumed:r===void 0}}const t=[`firefox`,`chromium`];function isBrowserList(e){return e.length>0&&e.every(e=>t.includes(e.os))}function inGroup(e,n){return t.includes(n)?{...e,browser:n}:{...e,os:n}}function recommendTarget(e,t){let n=isBrowserList(e)?t.browser:t.os;if(!n)return;let r=e.filter(e=>e.os===n&&e.available!==!1),fits=e=>e.arch===`noarch`||e.arch===t.arch;return r.find(e=>e.recommended&&fits(e))??r.find(fits)??r.find(e=>e.recommended)??r[0]}async function readHints(){let e=navigator.userAgentData,t={platform:e?.platform,userAgent:navigator.userAgent,brands:e?.brands?.map(e=>e.brand)};if(e?.getHighEntropyValues)try{let n=await e.getHighEntropyValues([`architecture`,`bitness`]);t.architecture=n.architecture,t.bitness=n.bitness}catch{}return t}function readRows(e){return[...e.querySelectorAll(`li.target`)].flatMap(e=>{let t=e.querySelector(`a`),n=e.dataset.os,r=e.dataset.arch;return!t||!n||!r?[]:[{os:n,arch:r,recommended:e.dataset.recommended!==void 0,url:t.href,cta:e.dataset.cta??t.textContent?.trim()??``,size:e.dataset.size??``}]})}var AppDownload=class extends HTMLElement{connectedCallback(){let e=this.querySelector(`dialog`),t=this.querySelector(`a.download-cta`);if(!e||!t)return;this.dataset.enhanced=`true`,enhanceDialog(e,this.querySelector(`.download-toggle`));let n=readRows(this),r=[...this.querySelectorAll(`.os-group`)],i=[...this.querySelectorAll(`.os-tab`)],a=this.querySelector(`.os-tabs`),o=i.length>1;a&&o&&(a.hidden=!1);let s={os:void 0,arch:void 0,browser:void 0,archAssumed:!0},show=e=>{for(let t of r)t.hidden=o&&t.dataset.os!==e;for(let t of i){let n=t.dataset.os===e;t.setAttribute(`aria-selected`,String(n)),t.tabIndex=n?0:-1}},offer=e=>{t.href=e.url,t.dataset.os=e.os,t.dataset.arch=e.arch;let n=t.querySelector(`.download-label`);n&&(n.textContent=e.cta);let r=document.querySelector(`.badge-size`),i=r?.querySelector(`.badge-value`);if(r&&i){i.textContent=e.size,r.hidden=e.size===``;let t=r.closest(`.badges`);if(t){let e=[...t.children].filter(e=>!e.hidden).length;t.style.setProperty(`--badge-count`,String(e)),t.toggleAttribute(`data-odd`,e%2==1)}}},select=e=>{show(e);let t=recommendTarget(n,inGroup(s,e));t&&offer(t)},c=i.find(e=>e.getAttribute(`aria-selected`)===`true`)?.dataset.os;c&&show(c);for(let e of i)e.addEventListener(`click`,()=>{let t=e.dataset.os;t&&select(t)});a?.addEventListener(`keydown`,e=>{let t=i.indexOf(document.activeElement);if(t===-1)return;let n={ArrowRight:t+1,ArrowLeft:t-1,Home:0,End:i.length-1}[e.key];if(n===void 0)return;e.preventDefault();let r=i[(n+i.length)%i.length];r?.focus(),r?.click()}),readHints().then(e=>{s=detectVisitorPlatform(e);let t=recommendTarget(n,s);t&&(show(t.os),offer(t))})}};customElements.define(`app-download`,AppDownload);function enhanceCopy(e){let t=e.querySelector(`span`),n=t?.textContent??``;navigator.clipboard&&(e.hidden=!1,e.addEventListener(`click`,async()=>{try{await navigator.clipboard.writeText(e.dataset.copy??``)}catch{return}t&&(t.textContent=e.dataset.done??n),setTimeout(()=>{t&&(t.textContent=n)},2e3)}))}var AppSupport=class extends HTMLElement{connectedCallback(){let e=this.querySelector(`dialog`);if(e){this.dataset.enhanced=`true`,enhanceDialog(e,this.querySelector(`.support-cta`));for(let e of this.querySelectorAll(`.sepa-copy`))enhanceCopy(e)}}};customElements.define(`app-support`,AppSupport);var ReleaseNotes=class extends HTMLElement{connectedCallback(){let e=this.querySelector(`.release-body`),t=this.querySelector(`.release-more`);if(!e||!t)return;this.dataset.enhanced=`true`;let n=this.dataset.more??t.textContent??``,r=this.dataset.less??n;if(e.classList.add(`clamped`),e.scrollHeight<=e.clientHeight){e.classList.remove(`clamped`);return}t.hidden=!1,t.textContent=n,t.setAttribute(`aria-expanded`,`false`),t.addEventListener(`click`,()=>{let i=e.classList.toggle(`clamped`);t.textContent=i?n:r,t.setAttribute(`aria-expanded`,String(!i))})}};customElements.define(`release-notes`,ReleaseNotes);var ScreenshotCarousel=class extends HTMLElement{connectedCallback(){let e=this.querySelector(`.carousel-track`),t=[...this.querySelectorAll(`.slide`)];if(!e||t.length===0)return;this.dataset.enhanced=`true`,e.tabIndex=0;let n=matchMedia(`(prefers-reduced-motion: reduce)`),r=[...this.querySelectorAll(`.carousel-dot`)],i=this.querySelector(`.carousel-prev`),a=this.querySelector(`.carousel-next`),o=[this.querySelector(`.carousel-dots`),i,a];if(t.length>1)for(let e of o)e&&(e.hidden=!1);let s=0,clamp=e=>Math.max(0,Math.min(e,t.length-1)),goTo=r=>{let i=t[clamp(r)];if(!i)return;let a=i.offsetLeft-e.offsetLeft-(e.clientWidth-i.clientWidth)/2;e.scrollTo({left:a,behavior:n.matches?`auto`:`smooth`})},mark=e=>{s=e;for(let[t,n]of r.entries())n.setAttribute(`aria-current`,String(t===e));i&&(i.disabled=e===0),a&&(a.disabled=e===t.length-1)};mark(0),i?.addEventListener(`click`,()=>goTo(s-1)),a?.addEventListener(`click`,()=>goTo(s+1));for(let[e,t]of r.entries())t.addEventListener(`click`,()=>goTo(e));let arrowStep=e=>e===`ArrowRight`?1:e===`ArrowLeft`?-1:0;e.addEventListener(`keydown`,e=>{let t=arrowStep(e.key);t!==0&&(e.preventDefault(),goTo(s+t))});let c=new IntersectionObserver(e=>{for(let n of e)n.isIntersecting&&mark(t.indexOf(n.target))},{root:e,threshold:.6});for(let e of t)c.observe(e);let l=this.querySelector(`.lightbox`),u=l?.querySelector(`figure`);if(!l||!u)return;let open=e=>{let n=t[clamp(e)]?.querySelector(`figure`);if(!n)return;s=clamp(e);let r=[...n.children].filter(e=>!e.classList.contains(`slide-expand`));u.replaceChildren(...r.map(e=>e.cloneNode(!0))),l.open||l.showModal()};for(let[e,n]of t.entries()){let t=n.querySelector(`.slide-expand`);t&&(t.hidden=!1,t.addEventListener(`click`,()=>open(e)))}enhanceDialog(l,null),l.addEventListener(`keydown`,e=>{let t=arrowStep(e.key);t!==0&&(e.preventDefault(),open(s+t),goTo(s))})}};customElements.define(`screenshot-carousel`,ScreenshotCarousel);
1
+ function enhanceDialog(e,t){let n=e.querySelector(`.dialog-close`);n&&(n.hidden=!1,n.addEventListener(`click`,()=>e.close())),t?.addEventListener(`click`,t=>{t.preventDefault(),e.showModal()}),e.addEventListener(`click`,t=>{t.target===e&&e.close()})}const e={linux:`x86_64`,darwin:`arm64`,win32:`x86_64`,android:`arm64`,web:`noarch`};function detectOs(e){let t=`${e.platform??``} ${e.userAgent??``}`;if(/android/i.test(t))return`android`;if(/iphone|ipad|ipod|\bios\b/i.test(t))return`web`;if(/mac|darwin/i.test(t))return`darwin`;if(/win/i.test(t))return`win32`;if(/linux|x11|bsd|cros|chrome ?os/i.test(t))return`linux`}function detectArch(e){if(e.architecture)return e.bitness===`32`?void 0:/arm/i.test(e.architecture)?`arm64`:/x86|amd/i.test(e.architecture)?`x86_64`:void 0;let t=e.userAgent??``;if(/arm64|aarch64/i.test(t))return`arm64`;if(/x86_64|x86-64|amd64|win64|wow64|\bx64\b/i.test(t))return`x86_64`}function detectBrowser(e){let t=e.userAgent??``;if(/firefox|fxios/i.test(t))return`firefox`;if(e.brands?.some(e=>/chromium|chrome|edge|opera|brave/i.test(e))||/chrome|chromium|crios|edg(a|ios)?\/|opr\//i.test(t))return`chromium`}function detectVisitorPlatform(t){let n=detectOs(t),r=detectArch(t);return{os:n,browser:detectBrowser(t),arch:r??(n?e[n]:void 0),archAssumed:r===void 0}}const t=[`firefox`,`chromium`];function isBrowserList(e){return e.length>0&&e.every(e=>t.includes(e.os))}function inGroup(e,n){return t.includes(n)?{...e,browser:n}:{...e,os:n}}function recommendTarget(e,t){let n=isBrowserList(e)?t.browser:t.os;if(!n)return;let r=e.filter(e=>e.os===n&&e.available!==!1),fits=e=>e.arch===`noarch`||e.arch===t.arch;return r.find(e=>e.recommended&&fits(e))??r.find(fits)??r.find(e=>e.recommended)??r[0]}async function readHints(){let e=navigator.userAgentData,t={platform:e?.platform,userAgent:navigator.userAgent,brands:e?.brands?.map(e=>e.brand)};if(e?.getHighEntropyValues)try{let n=await e.getHighEntropyValues([`architecture`,`bitness`]);t.architecture=n.architecture,t.bitness=n.bitness}catch{}return t}function readRows(e){return[...e.querySelectorAll(`li.target`)].flatMap(e=>{let t=e.querySelector(`a`),n=e.dataset.os,r=e.dataset.arch;return!t||!n||!r?[]:[{os:n,arch:r,recommended:e.dataset.recommended!==void 0,url:t.href,cta:e.dataset.cta??t.textContent?.trim()??``,size:e.dataset.size??``}]})}var AppDownload=class extends HTMLElement{connectedCallback(){let e=this.querySelector(`dialog`),t=this.querySelector(`a.download-cta`);if(!e||!t)return;this.dataset.enhanced=`true`,enhanceDialog(e,this.querySelector(`.download-toggle`));let n=readRows(this),r=[...this.querySelectorAll(`.os-group`)],i=[...this.querySelectorAll(`.os-tab`)],a=this.querySelector(`.os-tabs`),o=i.length>1;a&&o&&(a.hidden=!1);let s={os:void 0,arch:void 0,browser:void 0,archAssumed:!0},show=e=>{for(let t of r)t.hidden=o&&t.dataset.os!==e;for(let t of i){let n=t.dataset.os===e;t.setAttribute(`aria-selected`,String(n)),t.tabIndex=n?0:-1}},offer=e=>{t.href=e.url,t.dataset.os=e.os,t.dataset.arch=e.arch;let n=t.querySelector(`.download-label`);n&&(n.textContent=e.cta);let r=document.querySelector(`.badge-size`),i=r?.querySelector(`.badge-value`);if(r&&i){i.textContent=e.size,r.hidden=e.size===``;let t=r.closest(`.badges`);if(t){let e=[...t.children].filter(e=>!e.hidden).length;t.style.setProperty(`--badge-count`,String(e)),t.toggleAttribute(`data-odd`,e%2==1)}}},select=e=>{show(e);let t=recommendTarget(n,inGroup(s,e));t&&offer(t)},c=i.find(e=>e.getAttribute(`aria-selected`)===`true`)?.dataset.os;c&&show(c);for(let e of i)e.addEventListener(`click`,()=>{let t=e.dataset.os;t&&select(t)});a?.addEventListener(`keydown`,e=>{let t=i.indexOf(document.activeElement);if(t===-1)return;let n={ArrowRight:t+1,ArrowLeft:t-1,Home:0,End:i.length-1}[e.key];if(n===void 0)return;e.preventDefault();let r=i[(n+i.length)%i.length];r?.focus(),r?.click()}),readHints().then(e=>{s=detectVisitorPlatform(e);let t=recommendTarget(n,s);t&&(show(t.os),offer(t))})}};customElements.define(`app-download`,AppDownload);function enhanceCopy(e){let t=e.querySelector(`span`),n=t?.textContent??``;navigator.clipboard&&(e.hidden=!1,e.addEventListener(`click`,async()=>{try{await navigator.clipboard.writeText(e.dataset.copy??``)}catch{return}t&&(t.textContent=e.dataset.done??n),setTimeout(()=>{t&&(t.textContent=n)},2e3)}))}var AppSupport=class extends HTMLElement{connectedCallback(){this.dataset.enhanced=`true`;let e=this.querySelector(`dialog`);e&&enhanceDialog(e,this.querySelector(`.support-cta`));for(let e of this.querySelectorAll(`.sepa-copy`))enhanceCopy(e)}};customElements.define(`app-support`,AppSupport);var ReleaseNotes=class extends HTMLElement{connectedCallback(){let e=this.querySelector(`.release-body`),t=this.querySelector(`.release-more`);if(!e||!t)return;this.dataset.enhanced=`true`;let n=this.dataset.more??t.textContent??``,r=this.dataset.less??n;if(e.classList.add(`clamped`),e.scrollHeight<=e.clientHeight){e.classList.remove(`clamped`);return}t.hidden=!1,t.textContent=n,t.setAttribute(`aria-expanded`,`false`),t.addEventListener(`click`,()=>{let i=e.classList.toggle(`clamped`);t.textContent=i?n:r,t.setAttribute(`aria-expanded`,String(!i))})}};customElements.define(`release-notes`,ReleaseNotes);var ScreenshotCarousel=class extends HTMLElement{connectedCallback(){let e=this.querySelector(`.carousel-track`),t=[...this.querySelectorAll(`.slide`)];if(!e||t.length===0)return;this.dataset.enhanced=`true`,e.tabIndex=0;let n=matchMedia(`(prefers-reduced-motion: reduce)`),r=[...this.querySelectorAll(`.carousel-dot`)],i=this.querySelector(`.carousel-prev`),a=this.querySelector(`.carousel-next`),o=[this.querySelector(`.carousel-dots`),i,a];if(t.length>1)for(let e of o)e&&(e.hidden=!1);let s=0,clamp=e=>Math.max(0,Math.min(e,t.length-1)),goTo=r=>{let i=t[clamp(r)];if(!i)return;let a=i.offsetLeft-e.offsetLeft-(e.clientWidth-i.clientWidth)/2;e.scrollTo({left:a,behavior:n.matches?`auto`:`smooth`})},mark=e=>{s=e;for(let[t,n]of r.entries())n.setAttribute(`aria-current`,String(t===e));i&&(i.disabled=e===0),a&&(a.disabled=e===t.length-1)};mark(0),i?.addEventListener(`click`,()=>goTo(s-1)),a?.addEventListener(`click`,()=>goTo(s+1));for(let[e,t]of r.entries())t.addEventListener(`click`,()=>goTo(e));let arrowStep=e=>e===`ArrowRight`?1:e===`ArrowLeft`?-1:0;e.addEventListener(`keydown`,e=>{let t=arrowStep(e.key);t!==0&&(e.preventDefault(),goTo(s+t))});let c=new IntersectionObserver(e=>{for(let n of e)n.isIntersecting&&mark(t.indexOf(n.target))},{root:e,threshold:.6});for(let e of t)c.observe(e);let l=this.querySelector(`.lightbox`),u=l?.querySelector(`figure`);if(!l||!u)return;let open=e=>{let n=t[clamp(e)]?.querySelector(`figure`);if(!n)return;s=clamp(e);let r=[...n.children].filter(e=>!e.classList.contains(`slide-expand`));u.replaceChildren(...r.map(e=>e.cloneNode(!0))),l.open||l.showModal()};for(let[e,n]of t.entries()){let t=n.querySelector(`.slide-expand`);t&&(t.hidden=!1,t.addEventListener(`click`,()=>open(e)))}enhanceDialog(l,null),l.addEventListener(`keydown`,e=>{let t=arrowStep(e.key);t!==0&&(e.preventDefault(),open(s+t),goTo(s))})}};customElements.define(`screenshot-carousel`,ScreenshotCarousel);
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readFile, readdir, writeFile } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
- import { DOWNLOAD_LABELS, FORMAT_LABELS, UI_SOURCE_LANGUAGE, languageCandidates, parseCatalog, parsePo, quotePo, } from '../site-data/index.js';
4
+ import { DOWNLOAD_LABELS, FORMAT_LABELS, SUPPORT_KIND_LABELS, UI_SOURCE_LANGUAGE, languageCandidates, parseCatalog, parsePo, quotePo, } from '../site-data/index.js';
5
5
  // EITHER quote style, because the formatter picks one and this is not its decision. The renderers
6
6
  // were written with double quotes, and the first `gjsify format` that could see them — .gitignore
7
7
  // had hidden the directory — rewrote all 66 translator calls to single quotes; a double-quote-only
@@ -33,7 +33,7 @@ export async function rendererMsgids(renderDir) {
33
33
  if (fromSources === 0) {
34
34
  throw new Error(`${renderDir}: no translator call found in any renderer — the extractor is broken, not the sources`);
35
35
  }
36
- for (const label of [...Object.values(DOWNLOAD_LABELS), ...Object.values(FORMAT_LABELS)])
36
+ for (const label of [...Object.values(DOWNLOAD_LABELS), ...Object.values(FORMAT_LABELS), ...SUPPORT_KIND_LABELS])
37
37
  msgids.add(label);
38
38
  return [...msgids];
39
39
  }
@@ -24,3 +24,5 @@ export interface RasterImage {
24
24
  }
25
25
  export declare function decodeImageFile(path: string): RasterImage;
26
26
  export declare function decodeImageBytes(bytes: Uint8Array): RasterImage;
27
+ /** Rasterises an SVG document at its own size, through the same loader set as the screenshots. */
28
+ export declare function svgToPng(svg: string): Uint8Array;
@@ -128,3 +128,7 @@ export function decodeImageBytes(bytes) {
128
128
  throw new Error('Could not decode an image from memory');
129
129
  return wrap(pixbuf);
130
130
  }
131
+ /** Rasterises an SVG document at its own size, through the same loader set as the screenshots. */
132
+ export function svgToPng(svg) {
133
+ return decodeImageBytes(new TextEncoder().encode(svg)).encode('png');
134
+ }
@@ -0,0 +1,13 @@
1
+ export declare const OG_WIDTH = 1200;
2
+ export declare const OG_HEIGHT = 630;
3
+ export interface OgCardInput {
4
+ name: string;
5
+ summary: string;
6
+ background: string;
7
+ foreground: string;
8
+ /** The app icon as a data URI; the card has no text column offset without one. */
9
+ iconHref?: string;
10
+ }
11
+ /** Greedy word wrap on character count; what does not fit in `maxLines` is cut with an ellipsis. */
12
+ export declare function wrapText(text: string, maxChars: number, maxLines: number): string[];
13
+ export declare function ogCardSvg(input: OgCardInput): string;
@@ -0,0 +1,50 @@
1
+ // The 1200×630 link-preview card, drawn as SVG and rasterised by GdkPixbuf's SVG loader
2
+ // (`renderCardPng` in `images.ts`) — the same backend as the screenshots, no extra dependency.
3
+ import { esc } from './html.js';
4
+ export const OG_WIDTH = 1200;
5
+ export const OG_HEIGHT = 630;
6
+ /** Greedy word wrap on character count; what does not fit in `maxLines` is cut with an ellipsis. */
7
+ export function wrapText(text, maxChars, maxLines) {
8
+ const lines = [];
9
+ let current = '';
10
+ let rest = text.split(/\s+/).filter(Boolean);
11
+ while (rest.length > 0) {
12
+ const [word, ...others] = rest;
13
+ const next = current ? `${current} ${word}` : word;
14
+ if (next.length <= maxChars || !current) {
15
+ current = next;
16
+ rest = others;
17
+ continue;
18
+ }
19
+ lines.push(current);
20
+ current = '';
21
+ }
22
+ if (current)
23
+ lines.push(current);
24
+ if (lines.length <= maxLines)
25
+ return lines;
26
+ const kept = lines.slice(0, maxLines);
27
+ kept[maxLines - 1] = `${kept[maxLines - 1].slice(0, Math.max(1, maxChars - 1)).replace(/[\s,;:.]+$/, '')}…`;
28
+ return kept;
29
+ }
30
+ const PADDING = 80;
31
+ const ICON = 256;
32
+ export function ogCardSvg(input) {
33
+ const textX = input.iconHref ? PADDING + ICON + 64 : PADDING;
34
+ const textWidth = OG_WIDTH - textX - PADDING;
35
+ // Average glyph advance of a bold sans face is about 0.58 em, a regular one 0.5 em.
36
+ const nameSize = Math.max(48, Math.min(88, Math.floor(textWidth / (Math.max(input.name.length, 1) * 0.58))));
37
+ const summarySize = 40;
38
+ const summaryLines = wrapText(input.summary, Math.floor(textWidth / (summarySize * 0.5)), 3);
39
+ const lineHeight = 54;
40
+ const height = nameSize * 1.1 + (summaryLines.length > 0 ? 28 + summaryLines.length * lineHeight : 0);
41
+ const top = (OG_HEIGHT - height) / 2;
42
+ const nameY = top + nameSize;
43
+ const summary = summaryLines
44
+ .map((line, index) => `<text x="${textX}" y="${Math.round(nameY + 28 + (index + 1) * lineHeight - 12)}" font-size="${summarySize}" opacity="0.9">${esc(line)}</text>`)
45
+ .join('');
46
+ const icon = input.iconHref
47
+ ? `<image x="${PADDING}" y="${(OG_HEIGHT - ICON) / 2}" width="${ICON}" height="${ICON}" xlink:href="${esc(input.iconHref)}"/>`
48
+ : '';
49
+ return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${OG_WIDTH}" height="${OG_HEIGHT}" viewBox="0 0 ${OG_WIDTH} ${OG_HEIGHT}"><rect width="${OG_WIDTH}" height="${OG_HEIGHT}" fill="${esc(input.background)}"/>${icon}<g font-family="Cantarell, Inter, sans-serif" fill="${esc(input.foreground)}"><text x="${textX}" y="${Math.round(nameY)}" font-size="${nameSize}" font-weight="700">${esc(input.name)}</text>${summary}</g></svg>`;
50
+ }
@@ -0,0 +1,19 @@
1
+ import type { ClientAssets } from './pages.js';
2
+ export interface OverviewApp {
3
+ name: string;
4
+ /** Absolute address of this app's support page in the rendered language. */
5
+ url: string;
6
+ }
7
+ export interface OverviewPage {
8
+ lang: string;
9
+ languages: readonly string[];
10
+ defaultLanguage: string;
11
+ /** Absolute address of the overview per language. */
12
+ urls: Readonly<Record<string, string>>;
13
+ /** Root-relative address per language, for the language switch. */
14
+ paths: Readonly<Record<string, string>>;
15
+ apps: readonly OverviewApp[];
16
+ assets: ClientAssets;
17
+ t: (msgid: string) => string;
18
+ }
19
+ export declare function renderOverview(page: OverviewPage): string;
@@ -0,0 +1,53 @@
1
+ // The page that lists several apps, each linking to its own support page. It reads no app's data
2
+ // beyond a name and an icon, so it needs none of the per-app page machinery.
3
+ import { esc } from './html.js';
4
+ import { languageName, linkCard } from './pages.js';
5
+ import { ogLocale } from './seo.js';
6
+ export function renderOverview(page) {
7
+ const { lang, t } = page;
8
+ const title = t('Support');
9
+ const intro = t('These apps are free software. Pick one to see how to support its development.');
10
+ const alternates = page.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(page.urls[code])}">`);
11
+ const switcher = page.languages
12
+ .map((code) => code === lang
13
+ ? `<li><span aria-current="true">${esc(languageName(code))}</span></li>`
14
+ : `<li><a href="${esc(page.paths[code])}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(languageName(code))}</a></li>`)
15
+ .join('');
16
+ return `<!doctype html>
17
+ <html lang="${esc(lang)}">
18
+ <head>
19
+ <meta charset="utf-8">
20
+ <meta name="viewport" content="width=device-width, initial-scale=1">
21
+ <title>${esc(title)}</title>
22
+ <meta name="description" content="${esc(intro)}">
23
+ <link rel="canonical" href="${esc(page.urls[lang])}">
24
+ ${alternates.join('\n')}
25
+ <link rel="alternate" hreflang="x-default" href="${esc(page.urls[page.defaultLanguage])}">
26
+ <meta property="og:type" content="website">
27
+ <meta property="og:title" content="${esc(title)}">
28
+ <meta property="og:description" content="${esc(intro)}">
29
+ <meta property="og:url" content="${esc(page.urls[lang])}">
30
+ <meta property="og:locale" content="${esc(ogLocale(lang))}">
31
+ ${page.assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
32
+ </head>
33
+ <body>
34
+ <header class="masthead">
35
+ <div class="hero-inner">
36
+ <div class="hero-text">
37
+ <h1>${esc(title)}</h1>
38
+ </div>
39
+ </div>
40
+ </header>
41
+ <main>
42
+ <div class="column">
43
+ <p class="support-intro">${esc(intro)}</p>
44
+ <ul class="card-list">
45
+ ${page.apps.map((app) => linkCard(app.url, app.name, 'heart')).join('\n')}
46
+ </ul>
47
+ </div>
48
+ </main>
49
+ <footer class="footer"><div class="footer-inner"><nav class="footer-languages" aria-label="${esc(t('Language'))}"><ul>${switcher}</ul></nav></div></footer>
50
+ </body>
51
+ </html>
52
+ `;
53
+ }
@@ -1,4 +1,5 @@
1
1
  import { type DownloadTarget, type Localized, type Screenshot, type SiteData, type SiteSupportSepa } from '../site-data/index.js';
2
+ import { type IconName } from './icons.js';
2
3
  import type { ProcessedImage } from './images.js';
3
4
  export interface ClientAssets {
4
5
  js: string;
@@ -13,10 +14,19 @@ export interface RenderContext {
13
14
  images: ReadonlyMap<string, ProcessedImage>;
14
15
  /** Published URL of the app icon, when there is one. */
15
16
  iconUrl?: string;
17
+ /** Which page of the app this renders; the language switch and the head follow it. Default `app`. */
18
+ view?: 'app' | 'support';
19
+ /** Root-relative URL of the link-preview card for this language. */
20
+ ogImage?: string;
16
21
  }
17
22
  type Translate = (msgid: string) => string;
18
23
  export declare function localized(text: Localized, lang: string): string;
19
24
  export declare function pageUrl(data: SiteData, lang: string): string;
25
+ export declare function absolute(data: SiteData, rootRelative: string): string;
26
+ export declare function supportUrl(data: SiteData, lang: string): string;
27
+ /** The transfer reference when the config sets none: one per app, so the bank statement says what it was for. */
28
+ export declare function supportReference(data: SiteData): string;
29
+ export declare function languageName(code: string): string;
20
30
  /**
21
31
  * What the primary button says. Installing from a store, opening the web build and downloading a
22
32
  * file are three different promises, so each gets its own wording.
@@ -24,9 +34,18 @@ export declare function pageUrl(data: SiteData, lang: string): string;
24
34
  export declare function ctaLabel(target: DownloadTarget, t: Translate): string;
25
35
  /** A window shot brings its own shadow; ours would draw a second one around it. */
26
36
  export declare function frameAttribute(ctx: Pick<RenderContext, 'images'>, screenshot: Screenshot): 'none' | undefined;
37
+ export declare function linkCard(href: string, title: string, glyph: IconName): string;
27
38
  /** A bank transfer as a card: the details to read or copy, and an EPC QR code that fills them into a banking app. */
28
39
  export declare function sepaCard(entry: SiteSupportSepa, t: Translate): string;
40
+ /** The title and description of one page; also what the build checks for weak ones. */
41
+ export declare function pageMeta(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>, view?: 'app' | 'support'): {
42
+ name: string;
43
+ title: string;
44
+ description: string;
45
+ };
29
46
  export declare function renderPage(ctx: RenderContext): string;
47
+ /** `/<lang>/support/`: the ways to support the app, as plain markup that needs no script. */
48
+ export declare function renderSupportPage(page: RenderContext): string;
30
49
  /** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
31
50
  export declare function renderRoot(data: SiteData, assets: ClientAssets, iconUrl: string | undefined): string;
32
51
  export {};
@@ -2,7 +2,9 @@ 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 { OG_HEIGHT, OG_WIDTH } from './og-card.js';
5
6
  import { qrSvg } from './qr.js';
7
+ import { metaDescription, ogLocale, pageTitle, softwareApplicationJsonLd } from './seo.js';
6
8
  const FORMAT_TYPES = { avif: 'image/avif', webp: 'image/webp', png: 'image/png' };
7
9
  const LINKS = [
8
10
  ['homepage', (t) => t('Website'), 'web'],
@@ -13,7 +15,7 @@ const LINKS = [
13
15
  ['contribute', (t) => t('Contribute'), 'people'],
14
16
  ['translate', (t) => t('Help translate'), 'translate'],
15
17
  ['contact', (t) => t('Contact'), 'mail'],
16
- ['donation', (t) => t('Donate'), 'heart'],
18
+ ['donation', (t) => t('Support'), 'heart'],
17
19
  ];
18
20
  const PLATFORM_ICONS = {
19
21
  linux: 'linux',
@@ -30,10 +32,17 @@ export function localized(text, lang) {
30
32
  export function pageUrl(data, lang) {
31
33
  return `${data.config.base}${lang}/`;
32
34
  }
33
- function absolute(data, rootRelative) {
35
+ export function absolute(data, rootRelative) {
34
36
  return new URL(rootRelative.replace(/^\//, ''), `${new URL(data.config.url).origin}/`).href;
35
37
  }
36
- function languageName(code) {
38
+ export function supportUrl(data, lang) {
39
+ return `${pageUrl(data, lang)}support/`;
40
+ }
41
+ /** The transfer reference when the config sets none: one per app, so the bank statement says what it was for. */
42
+ export function supportReference(data) {
43
+ return `Support ${data.app.name.source}`;
44
+ }
45
+ export function languageName(code) {
37
46
  return new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code;
38
47
  }
39
48
  function blocks(content, lang) {
@@ -351,7 +360,7 @@ ${blocks(release.description, lang)}
351
360
  : ''}
352
361
  </section>`;
353
362
  }
354
- function linkCard(href, title, glyph) {
363
+ export function linkCard(href, title, glyph) {
355
364
  return `<li>
356
365
  <a href="${esc(href)}">
357
366
  <span class="link-icon">${icon(glyph)}</span>
@@ -405,32 +414,18 @@ ${qrSvg(epcPayload(entry), t('QR code for a bank transfer'))}
405
414
  </figure>
406
415
  </li>`;
407
416
  }
408
- function supportItem(entry, t) {
417
+ function supportItem(entry, t, reference) {
409
418
  return entry.kind === 'sepa'
410
- ? sepaCard(entry, t)
419
+ ? sepaCard({ ...entry, reference: entry.reference ?? reference }, t)
411
420
  : linkCard(entry.url, t(entry.kind), 'heart');
412
421
  }
413
- /**
414
- * Ways to support the project, if the site configures any. Same no-JavaScript shape as the
415
- * download dialog: an anchor to a dialog that stays an open panel until the component takes over.
416
- */
422
+ /** The landing page only points at the support page: the details live there, readable without a dialog. */
417
423
  function support(ctx) {
418
- const { data, t } = ctx;
424
+ const { data, lang, t } = ctx;
419
425
  if (data.config.support.length === 0)
420
426
  return '';
421
427
  return `<section class="support">
422
- <app-support class="support-widget">
423
- <a class="button support-cta" href="#support-ways">${icon('heart')}<span>${esc(t('Support this project'))}</span></a>
424
- <dialog id="support-ways" class="support-dialog" aria-labelledby="support-ways-title">
425
- <div class="dialog-head">
426
- <h2 id="support-ways-title">${esc(t('Support this project'))}</h2>
427
- <button class="dialog-close" type="button" aria-label="${esc(t('Close'))}" hidden>${icon('close')}</button>
428
- </div>
429
- <ul class="card-list">
430
- ${data.config.support.map((entry) => supportItem(entry, t)).join('\n')}
431
- </ul>
432
- </dialog>
433
- </app-support>
428
+ <a class="button support-cta" href="${esc(supportUrl(data, lang))}">${icon('heart')}<span>${esc(t('Support this project'))}</span></a>
434
429
  </section>`;
435
430
  }
436
431
  function tags(ctx) {
@@ -455,7 +450,7 @@ function languageSwitch(ctx, className, style) {
455
450
  const label = style === 'code' ? code.toUpperCase() : languageName(code);
456
451
  return code === lang
457
452
  ? `<li><span aria-current="true">${esc(label)}</span></li>`
458
- : `<li><a href="${esc(pageUrl(data, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(label)}</a></li>`;
453
+ : `<li><a href="${esc(ctx.view === 'support' ? supportUrl(data, code) : pageUrl(data, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(label)}</a></li>`;
459
454
  });
460
455
  return `<nav class="${className}" aria-label="${esc(t('Language'))}"><ul>${items.join('')}</ul></nav>`;
461
456
  }
@@ -525,15 +520,58 @@ ${legal ? `<ul class="footer-legal">${legal}</ul>` : ''}
525
520
  </div>
526
521
  </footer>`;
527
522
  }
528
- function head(ctx) {
529
- const { data, lang } = ctx;
523
+ /** The title and description of one page; also what the build checks for weak ones. */
524
+ export function pageMeta(ctx, view = 'app') {
525
+ const { data, lang, t } = ctx;
530
526
  const name = localized(data.app.name, lang);
527
+ if (view === 'support') {
528
+ return {
529
+ name,
530
+ title: `${t('Support')} – ${name}`,
531
+ description: plain(t('{app} is free software. Support its development with a transfer or a service.'), {
532
+ app: name,
533
+ }),
534
+ };
535
+ }
531
536
  const summary = localized(data.app.summary, lang);
532
- const title = `${name} – ${summary}`;
533
- const url = absolute(data, pageUrl(data, lang));
534
- const first = data.screenshots[0];
535
- const og = first ? ctx.images.get(first.slug) : undefined;
536
- const ogImage = og ? absolute(data, og.formats.png.at(-1).url) : undefined;
537
+ const first = data.app.description.find((block) => block.type === 'paragraph');
538
+ const paragraph = first?.type === 'paragraph' ? localized(first.text, lang) : undefined;
539
+ return { name, title: pageTitle(name, summary), description: metaDescription(summary, paragraph) };
540
+ }
541
+ const SCHEMA_OS = {
542
+ linux: 'Linux',
543
+ darwin: 'macOS',
544
+ win32: 'Windows',
545
+ android: 'Android',
546
+ web: 'Web browser',
547
+ firefox: 'Web browser',
548
+ chromium: 'Web browser',
549
+ };
550
+ function structuredData(ctx, title, description) {
551
+ const { data, lang } = ctx;
552
+ const systems = [
553
+ ...new Set(data.downloads.targets.filter((target) => target.available).map((target) => SCHEMA_OS[target.os])),
554
+ ];
555
+ return softwareApplicationJsonLd({
556
+ name: localized(data.app.name, lang),
557
+ description,
558
+ url: absolute(data, pageUrl(data, lang)),
559
+ image: ctx.iconUrl ? absolute(data, ctx.iconUrl) : undefined,
560
+ version: data.version,
561
+ operatingSystems: systems,
562
+ license: data.app.license,
563
+ downloadUrl: data.downloads.releasesUrl,
564
+ repository: repositoryUrl(data),
565
+ languages: data.config.languages,
566
+ });
567
+ }
568
+ function head(ctx) {
569
+ const { data, lang } = ctx;
570
+ const view = ctx.view ?? 'app';
571
+ const viewPath = (code) => (view === 'support' ? supportUrl(data, code) : pageUrl(data, code));
572
+ const { name, title, description } = pageMeta(ctx, view);
573
+ const url = absolute(data, viewPath(lang));
574
+ const ogImage = ctx.ogImage ? absolute(data, ctx.ogImage) : undefined;
537
575
  const { light, dark } = data.app.branding;
538
576
  // An app without `<branding>` keeps the stylesheet's own palette: the custom property is left
539
577
  // unset so its `var()` fallback wins, rather than being set to a stand-in colour here.
@@ -543,29 +581,37 @@ function head(ctx) {
543
581
  ]
544
582
  .filter(Boolean)
545
583
  .join(';');
546
- const alternates = data.config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, pageUrl(data, code)))}">`);
584
+ const alternates = data.config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, viewPath(code)))}">`);
547
585
  const meta = [
548
586
  ['og:type', 'website'],
549
587
  ['og:title', title],
550
- ['og:description', summary],
588
+ ['og:description', description],
551
589
  ['og:url', url],
552
590
  ['og:site_name', name],
553
- ['og:locale', lang.replace(/-/g, '_')],
591
+ ['og:locale', ogLocale(lang)],
592
+ ...data.config.languages
593
+ .filter((code) => code !== lang)
594
+ .map((code) => ['og:locale:alternate', ogLocale(code)]),
554
595
  ];
555
- if (ogImage)
556
- meta.push(['og:image', ogImage]);
596
+ if (ogImage) {
597
+ meta.push(['og:image', ogImage], ['og:image:width', String(OG_WIDTH)], ['og:image:height', String(OG_HEIGHT)], ['og:image:alt', name]);
598
+ }
557
599
  return `<meta charset="utf-8">
558
600
  <meta name="viewport" content="width=device-width, initial-scale=1">
559
601
  <title>${esc(title)}</title>
560
- <meta name="description" content="${esc(summary)}">
602
+ <meta name="description" content="${esc(description)}">
561
603
  <link rel="canonical" href="${esc(url)}">
562
604
  ${alternates.join('\n')}
563
- <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, data.config.base))}">
605
+ <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, viewPath(data.config.defaultLanguage)))}">
564
606
  ${meta.map(([property, content]) => `<meta property="${property}" content="${esc(content)}">`).join('\n')}
565
607
  <meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
608
+ <meta name="twitter:title" content="${esc(title)}">
609
+ <meta name="twitter:description" content="${esc(description)}">
610
+ ${ogImage ? `<meta name="twitter:image" content="${esc(ogImage)}">` : ''}
566
611
  <meta name="theme-color" content="${esc(light ?? '#ffffff')}" media="(prefers-color-scheme: light)">
567
612
  <meta name="theme-color" content="${esc(dark ?? '#1e1e1e')}" media="(prefers-color-scheme: dark)">
568
613
  ${ctx.iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(ctx.iconUrl)}">` : ''}
614
+ ${view === 'app' ? structuredData(ctx, title, description) : ''}
569
615
  ${brandVars ? `<style>:root{${brandVars}}</style>` : ''}
570
616
  ${ctx.assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
571
617
  <script type="module" src="${esc(ctx.assets.js)}"></script>`;
@@ -597,6 +643,44 @@ ${footer(ctx)}
597
643
  </html>
598
644
  `;
599
645
  }
646
+ /** `/<lang>/support/`: the ways to support the app, as plain markup that needs no script. */
647
+ export function renderSupportPage(page) {
648
+ const ctx = { ...page, view: 'support' };
649
+ const { data, lang, t } = ctx;
650
+ const meta = pageMeta(ctx, 'support');
651
+ const reference = supportReference(data);
652
+ return `<!doctype html>
653
+ <html lang="${esc(lang)}">
654
+ <head>
655
+ ${head(ctx)}
656
+ </head>
657
+ <body>
658
+ ${topBar(ctx)}
659
+ <header class="masthead">
660
+ <div class="hero-inner">
661
+ ${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
662
+ <div class="hero-text">
663
+ <h1>${esc(t('Support'))}</h1>
664
+ <p class="hero-by">${esc(meta.name)}</p>
665
+ </div>
666
+ </div>
667
+ </header>
668
+ <main>
669
+ <div class="column">
670
+ <p class="support-intro">${esc(meta.description)}</p>
671
+ <app-support>
672
+ <ul class="card-list">
673
+ ${data.config.support.map((entry) => supportItem(entry, t, reference)).join('\n')}
674
+ </ul>
675
+ </app-support>
676
+ <p class="support-back"><a href="${esc(pageUrl(data, lang))}">${fmt(t('Back to {app}'), { app: meta.name })}</a></p>
677
+ </div>
678
+ </main>
679
+ ${footer(ctx)}
680
+ </body>
681
+ </html>
682
+ `;
683
+ }
600
684
  /** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
601
685
  export function renderRoot(data, assets, iconUrl) {
602
686
  const { config } = data;
@@ -612,7 +696,7 @@ export function renderRoot(data, assets, iconUrl) {
612
696
  <title>${esc(name)}</title>
613
697
  <link rel="canonical" href="${esc(absolute(data, pageUrl(data, config.defaultLanguage)))}">
614
698
  ${alternates.join('\n')}
615
- <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, config.base))}">
699
+ <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, pageUrl(data, config.defaultLanguage)))}">
616
700
  ${iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(iconUrl)}">` : ''}
617
701
  ${assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
618
702
  <script>${choose}</script>
@@ -0,0 +1,41 @@
1
+ /** `de` → `de_DE`, `pt-BR` → `pt_BR`; a language with no known default territory is passed through. */
2
+ export declare function ogLocale(lang: string): string;
3
+ /** Search results cut a title near 60 characters and a description near 160. */
4
+ export declare const TITLE_MIN = 20;
5
+ export declare const DESCRIPTION_MIN = 70;
6
+ export declare const DESCRIPTION_MAX = 160;
7
+ /** `Name – summary`, or the bare name when the summary adds nothing to it. */
8
+ export declare function pageTitle(name: string, summary: string): string;
9
+ /**
10
+ * The description for search results and link previews. A metainfo summary is a few words by
11
+ * design (Flathub caps it), so the first paragraph of the description carries more.
12
+ */
13
+ export declare function metaDescription(summary: string, firstParagraph: string | undefined): string;
14
+ /** What is wrong with a page's title or description, one sentence each; empty when both are fine. */
15
+ export declare function seoWarnings(page: {
16
+ lang: string;
17
+ name: string;
18
+ title: string;
19
+ description: string;
20
+ }): string[];
21
+ export interface SoftwareApplicationInput {
22
+ name: string;
23
+ description: string;
24
+ url: string;
25
+ image?: string;
26
+ version?: string;
27
+ category?: string;
28
+ operatingSystems: string[];
29
+ /** An SPDX identifier; only a single id has a page of its own. */
30
+ license?: string;
31
+ downloadUrl?: string;
32
+ repository?: string;
33
+ languages: string[];
34
+ }
35
+ /** schema.org `SoftwareApplication` as a `<script type="application/ld+json">`. Nothing is invented: absent facts are left out. */
36
+ export declare function softwareApplicationJsonLd(input: SoftwareApplicationInput): string;
37
+ /** One logical page, as its absolute address per language. */
38
+ export type SitemapPage = Readonly<Record<string, string>>;
39
+ /** `sitemap.xml` with a self-including `xhtml:link` alternate set per page, and `x-default` on the default language. */
40
+ export declare function sitemapXml(pages: readonly SitemapPage[], defaultLanguage: string): string;
41
+ export declare function robotsTxt(sitemapUrl: string): string;
@@ -0,0 +1,113 @@
1
+ // Everything a crawler or a link preview reads that is not the visible page: locale tags, the
2
+ // title and description, structured data, the sitemap. Pure string work, so it runs on every runtime.
3
+ import { esc } from './html.js';
4
+ /** Facebook's Open Graph wants `language_TERRITORY`; a bare language code is not a valid `og:locale`. */
5
+ const DEFAULT_TERRITORY = {
6
+ cs: 'CZ',
7
+ da: 'DK',
8
+ de: 'DE',
9
+ en: 'US',
10
+ es: 'ES',
11
+ fi: 'FI',
12
+ fr: 'FR',
13
+ it: 'IT',
14
+ ja: 'JP',
15
+ ko: 'KR',
16
+ nb: 'NO',
17
+ nl: 'NL',
18
+ pl: 'PL',
19
+ pt: 'PT',
20
+ ru: 'RU',
21
+ sv: 'SE',
22
+ tr: 'TR',
23
+ uk: 'UA',
24
+ zh: 'CN',
25
+ };
26
+ /** `de` → `de_DE`, `pt-BR` → `pt_BR`; a language with no known default territory is passed through. */
27
+ export function ogLocale(lang) {
28
+ const [primary = lang, ...rest] = lang.split(/[-_]/);
29
+ const language = primary.toLowerCase();
30
+ const territory = rest.find((part) => /^[A-Za-z]{2}$/.test(part))?.toUpperCase() ?? DEFAULT_TERRITORY[language];
31
+ return territory ? `${language}_${territory}` : language;
32
+ }
33
+ /** Search results cut a title near 60 characters and a description near 160. */
34
+ export const TITLE_MIN = 20;
35
+ export const DESCRIPTION_MIN = 70;
36
+ export const DESCRIPTION_MAX = 160;
37
+ /** `Name – summary`, or the bare name when the summary adds nothing to it. */
38
+ export function pageTitle(name, summary) {
39
+ const trimmed = summary.trim();
40
+ return trimmed === '' || trimmed.toLowerCase() === name.trim().toLowerCase() ? name : `${name} – ${trimmed}`;
41
+ }
42
+ /**
43
+ * The description for search results and link previews. A metainfo summary is a few words by
44
+ * design (Flathub caps it), so the first paragraph of the description carries more.
45
+ */
46
+ export function metaDescription(summary, firstParagraph) {
47
+ const text = firstParagraph?.replace(/\s+/g, ' ').trim();
48
+ if (!text || text.length <= summary.length)
49
+ return summary;
50
+ if (text.length <= DESCRIPTION_MAX)
51
+ return text;
52
+ const cut = text.slice(0, DESCRIPTION_MAX - 1);
53
+ const space = cut.lastIndexOf(' ');
54
+ return `${(space > DESCRIPTION_MAX / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.–-]+$/, '')}…`;
55
+ }
56
+ /** What is wrong with a page's title or description, one sentence each; empty when both are fine. */
57
+ export function seoWarnings(page) {
58
+ const { lang, name, title, description } = page;
59
+ const warnings = [];
60
+ if (title === name) {
61
+ warnings.push(`${lang}: the page title is only the app name "${name}" — give the metainfo a distinct summary`);
62
+ }
63
+ else if (title.length < TITLE_MIN) {
64
+ warnings.push(`${lang}: the page title "${title}" is shorter than ${TITLE_MIN} characters`);
65
+ }
66
+ if (description.trim() === name.trim() || description.length < DESCRIPTION_MIN) {
67
+ warnings.push(`${lang}: the meta description is ${description.length} characters (under ${DESCRIPTION_MIN}) — ` +
68
+ 'write a longer first paragraph in the metainfo description');
69
+ }
70
+ return warnings;
71
+ }
72
+ /** schema.org `SoftwareApplication` as a `<script type="application/ld+json">`. Nothing is invented: absent facts are left out. */
73
+ export function softwareApplicationJsonLd(input) {
74
+ const spdx = input.license && /^[A-Za-z0-9.+-]+$/.test(input.license) ? input.license : undefined;
75
+ const data = {
76
+ '@context': 'https://schema.org',
77
+ '@type': 'SoftwareApplication',
78
+ name: input.name,
79
+ description: input.description,
80
+ url: input.url,
81
+ image: input.image,
82
+ softwareVersion: input.version || undefined,
83
+ applicationCategory: input.category,
84
+ operatingSystem: input.operatingSystems.length > 0 ? input.operatingSystems.join(', ') : undefined,
85
+ license: spdx ? `https://spdx.org/licenses/${spdx}.html` : undefined,
86
+ downloadUrl: input.downloadUrl,
87
+ sameAs: input.repository ? [input.repository] : undefined,
88
+ inLanguage: input.languages,
89
+ };
90
+ const json = JSON.stringify(data).replace(/</g, '\\u003c');
91
+ return `<script type="application/ld+json">${json}</script>`;
92
+ }
93
+ /** `sitemap.xml` with a self-including `xhtml:link` alternate set per page, and `x-default` on the default language. */
94
+ export function sitemapXml(pages, defaultLanguage) {
95
+ const entries = pages.flatMap((page) => {
96
+ const languages = Object.keys(page);
97
+ const alternates = [
98
+ ...languages.map((code) => ` <xhtml:link rel="alternate" hreflang="${esc(code)}" href="${esc(page[code])}"/>`),
99
+ ...(page[defaultLanguage]
100
+ ? [` <xhtml:link rel="alternate" hreflang="x-default" href="${esc(page[defaultLanguage])}"/>`]
101
+ : []),
102
+ ].join('\n');
103
+ return languages.map((code) => ` <url>\n <loc>${esc(page[code])}</loc>\n${alternates}\n </url>`);
104
+ });
105
+ return `<?xml version="1.0" encoding="UTF-8"?>
106
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
107
+ ${entries.join('\n')}
108
+ </urlset>
109
+ `;
110
+ }
111
+ export function robotsTxt(sitemapUrl) {
112
+ return `User-agent: *\nAllow: /\n\nSitemap: ${sitemapUrl}\n`;
113
+ }
@@ -21,3 +21,5 @@ export interface RootManifest {
21
21
  export declare function normalizeBase(base: string): string;
22
22
  /** Validates `gjsify.site` and applies defaults; the error names the offending key. */
23
23
  export declare function parseSiteConfig(manifest: RootManifest): SiteConfig;
24
+ /** The `kind` labels the support page knows by name; the page catalogues translate them. */
25
+ export declare const SUPPORT_KIND_LABELS: readonly ["GitHub Sponsors", "Liberapay", "PayPal"];
@@ -140,3 +140,5 @@ export function parseSiteConfig(manifest) {
140
140
  })),
141
141
  };
142
142
  }
143
+ /** The `kind` labels the support page knows by name; the page catalogues translate them. */
144
+ export const SUPPORT_KIND_LABELS = ['GitHub Sponsors', 'Liberapay', 'PayPal'];
package/lib/site.d.ts CHANGED
@@ -47,3 +47,29 @@ export interface SiteI18nOptions {
47
47
  * own `po/` when called without a root, otherwise the consumer's `pageTranslations`.
48
48
  */
49
49
  export declare function syncSiteI18n(options?: SiteI18nOptions): Promise<I18nResult>;
50
+ /**
51
+ * The `--overview` input: one JSON file naming the host and the apps whose support pages it lists.
52
+ * Each app is a repository root (relative to the file) with its own `gjsify.site` block, which is
53
+ * where its published address, languages and metainfo already live.
54
+ */
55
+ export interface OverviewConfig {
56
+ /** Origin the overview is published on, e.g. `https://jumplink.eu/`. */
57
+ url: string;
58
+ /** Path the overview lives under; default `/support/`. */
59
+ base?: string;
60
+ languages: string[];
61
+ defaultLanguage?: string;
62
+ apps: string[];
63
+ }
64
+ export interface OverviewBuildOptions {
65
+ /** The overview JSON file. */
66
+ config: string;
67
+ out: string;
68
+ log?: (message: string) => void;
69
+ warn?: (message: string) => void;
70
+ packageDir?: string;
71
+ }
72
+ export declare function buildOverview(options: OverviewBuildOptions): Promise<{
73
+ languages: string[];
74
+ apps: number;
75
+ }>;
package/lib/site.js CHANGED
@@ -3,12 +3,17 @@
3
3
  import { createHash } from 'node:crypto';
4
4
  import { existsSync } from 'node:fs';
5
5
  import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
6
- import { basename, dirname, join, resolve } from 'node:path';
6
+ import { basename, dirname, extname, join, resolve } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { catalogMsgids, configMsgids, rendererMsgids, syncCatalogs } from './render/i18n.js';
9
+ import { readableText } from './render/color.js';
10
+ import { svgToPng } from './render/image-backend.js';
9
11
  import { processImage } from './render/images.js';
10
- import { renderPage, renderRoot } from './render/pages.js';
11
- import { UI_SOURCE_LANGUAGE, loadCatalogs, loadSiteData, mergeCatalogs, parseSiteConfig, translator, } from './site-data/index.js';
12
+ import { ogCardSvg } from './render/og-card.js';
13
+ import { absolute, localized, pageMeta, pageUrl, renderPage, renderRoot, renderSupportPage, supportUrl, } from './render/pages.js';
14
+ import { renderOverview } from './render/overview.js';
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';
12
17
  /**
13
18
  * This package's own directory: `lib/` holds the client bundle, `po/` the page catalogues.
14
19
  *
@@ -60,6 +65,17 @@ async function processScreenshots(data, { out }, cacheDir) {
60
65
  const entries = await Promise.all(data.screenshots.map(async (screenshot) => [screenshot.slug, await processImage(screenshot.file, screenshot.slug, options)]));
61
66
  return new Map(entries);
62
67
  }
68
+ const ICON_TYPES = { '.svg': 'image/svg+xml', '.png': 'image/png' };
69
+ /** The icon inlined, so the card's SVG is self-contained when the pixbuf loader rasterises it. */
70
+ async function iconDataUri(data, { root }) {
71
+ if (!data.app.icon)
72
+ return undefined;
73
+ const type = ICON_TYPES[extname(data.app.icon).toLowerCase()];
74
+ if (!type)
75
+ return undefined;
76
+ const bytes = await readFile(resolve(root, data.app.icon));
77
+ return `data:${type};base64,${bytes.toString('base64')}`;
78
+ }
63
79
  async function publishIcon(data, { root, out }) {
64
80
  if (!data.app.icon)
65
81
  return undefined;
@@ -110,12 +126,38 @@ export async function buildSite(options) {
110
126
  });
111
127
  const images = await processScreenshots(data, target, cacheDir ?? defaultCacheDir(target.root));
112
128
  const iconUrl = await publishIcon(data, target);
129
+ const iconHref = await iconDataUri(data, target);
130
+ const { light = '#84bbfd' } = data.app.branding;
131
+ const hasSupport = data.config.support.length > 0;
113
132
  for (const lang of data.config.languages) {
114
- const html = renderPage({ data, lang, t: translator(catalogs[lang]), assets, images, iconUrl });
133
+ const t = translator(catalogs[lang]);
134
+ const ctx = { data, lang, t, assets, images, iconUrl, ogImage: `${data.config.base}og-${lang}.png` };
135
+ const meta = pageMeta(ctx);
136
+ for (const warning of seoWarnings({ lang, ...meta }))
137
+ options.warn?.(warning);
138
+ const card = ogCardSvg({
139
+ name: meta.name,
140
+ summary: localized(data.app.summary, lang),
141
+ background: light,
142
+ foreground: readableText(light),
143
+ iconHref,
144
+ });
145
+ await writeFile(join(target.out, `og-${lang}.png`), svgToPng(card));
115
146
  await mkdir(join(target.out, lang), { recursive: true });
116
- await writeFile(join(target.out, lang, 'index.html'), html);
147
+ await writeFile(join(target.out, lang, 'index.html'), renderPage(ctx));
148
+ if (hasSupport) {
149
+ await mkdir(join(target.out, lang, 'support'), { recursive: true });
150
+ await writeFile(join(target.out, lang, 'support', 'index.html'), renderSupportPage(ctx));
151
+ }
117
152
  }
118
153
  await writeFile(join(target.out, 'index.html'), renderRoot(data, assets, iconUrl));
154
+ const address = (build) => Object.fromEntries(data.config.languages.map((lang) => [lang, absolute(data, build(data, lang))]));
155
+ const pages = [address(pageUrl), ...(hasSupport ? [address(supportUrl)] : [])];
156
+ await writeFile(join(target.out, 'sitemap.xml'), sitemapXml(pages, data.config.defaultLanguage));
157
+ // Only a site served from the host root can own `/robots.txt`; below a path the host does.
158
+ if (data.config.base === '/') {
159
+ await writeFile(join(target.out, 'robots.txt'), robotsTxt(absolute(data, `${data.config.base}sitemap.xml`)));
160
+ }
119
161
  return { languages: [...data.config.languages], screenshots: images.size };
120
162
  }
121
163
  /** The languages this package keeps a catalogue for: whichever `po/<lang>.po` files exist. */
@@ -159,3 +201,52 @@ export async function syncSiteI18n(options = {}) {
159
201
  await mkdir(dir, { recursive: true });
160
202
  return syncCatalogs({ dir, languages: config.languages, msgids, check, base: root });
161
203
  }
204
+ export async function buildOverview(options) {
205
+ const file = resolve(options.config);
206
+ const overview = JSON.parse(await readFile(file, 'utf8'));
207
+ if (!overview.url || !Array.isArray(overview.apps) || !Array.isArray(overview.languages))
208
+ throw new Error(`${file} needs "url", "languages" and "apps"`);
209
+ const out = resolve(options.out);
210
+ const base = overview.base ?? '/support/';
211
+ const defaultLanguage = overview.defaultLanguage ?? overview.languages[0];
212
+ const self = selfDir(options.packageDir);
213
+ await rm(out, { recursive: true, force: true });
214
+ await mkdir(out, { recursive: true });
215
+ const assets = await stageClientAssets(base, out, self);
216
+ const catalogs = await loadCatalogs(poDir(self), overview.languages);
217
+ const apps = await Promise.all(overview.apps.map(async (entry) => {
218
+ const root = resolve(dirname(file), entry);
219
+ const config = await readConfig(root);
220
+ const metainfo = parseMetainfo(await readFile(resolve(root, config.metainfo), 'utf8'));
221
+ return { config, metainfo };
222
+ }));
223
+ const origin = new URL(overview.url).origin;
224
+ const pathFor = (lang) => `${base}${lang}/`;
225
+ const urls = Object.fromEntries(overview.languages.map((lang) => [lang, `${origin}${pathFor(lang)}`]));
226
+ const paths = Object.fromEntries(overview.languages.map((lang) => [lang, pathFor(lang)]));
227
+ for (const lang of overview.languages) {
228
+ const html = renderOverview({
229
+ lang,
230
+ languages: overview.languages,
231
+ defaultLanguage,
232
+ urls,
233
+ paths,
234
+ assets,
235
+ t: translator(catalogs[lang]),
236
+ apps: apps.map(({ config, metainfo }) => ({
237
+ name: pickByLanguage(metainfo.name.byLanguage, lang) ?? metainfo.name.source,
238
+ url: `${new URL(config.url).origin}${config.base}${config.languages.includes(lang) ? lang : config.defaultLanguage}/support/`,
239
+ })),
240
+ });
241
+ await mkdir(join(out, lang), { recursive: true });
242
+ await writeFile(join(out, lang, 'index.html'), html);
243
+ }
244
+ const root = `<!doctype html>
245
+ <html lang="${defaultLanguage}"><head><meta charset="utf-8"><title>Support</title>
246
+ <link rel="canonical" href="${urls[defaultLanguage]}"><meta http-equiv="refresh" content="0; url=${pathFor(defaultLanguage)}"></head>
247
+ <body><a href="${pathFor(defaultLanguage)}">Support</a></body></html>
248
+ `;
249
+ await writeFile(join(out, 'index.html'), root);
250
+ await writeFile(join(out, 'sitemap.xml'), sitemapXml([urls], defaultLanguage));
251
+ return { languages: overview.languages, apps: apps.length };
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/site",
3
- "version": "0.63.1-next.236",
3
+ "version": "0.63.1-next.237",
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.63.1-next.236",
58
- "@gjsify/crypto": "0.63.1-next.236",
59
- "@gjsify/fs": "0.63.1-next.236",
60
- "@gjsify/module": "0.63.1-next.236",
61
- "@gjsify/os": "0.63.1-next.236",
62
- "@gjsify/path": "0.63.1-next.236",
63
- "@gjsify/process": "0.63.1-next.236",
64
- "@gjsify/url": "0.63.1-next.236",
57
+ "@gjsify/console": "0.63.1-next.237",
58
+ "@gjsify/crypto": "0.63.1-next.237",
59
+ "@gjsify/fs": "0.63.1-next.237",
60
+ "@gjsify/module": "0.63.1-next.237",
61
+ "@gjsify/os": "0.63.1-next.237",
62
+ "@gjsify/path": "0.63.1-next.237",
63
+ "@gjsify/process": "0.63.1-next.237",
64
+ "@gjsify/url": "0.63.1-next.237",
65
65
  "fast-xml-parser": "^5.11.2",
66
66
  "qrcode-generator": "^2.0.4"
67
67
  },
68
68
  "devDependencies": {
69
- "@gjsify/cli": "0.63.1-next.236",
69
+ "@gjsify/cli": "0.63.1-next.237",
70
70
  "@gjsify/node-gi": "file:../../node-gi/node-gi",
71
- "@gjsify/unit": "0.63.1-next.236",
71
+ "@gjsify/unit": "0.63.1-next.237",
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": "d4ae412daabae9cb89f4fa11ee7279a308c9e4be"
78
+ "gitHead": "a349db400a7e68befc11ba6381ddb0e4caff09b6"
79
79
  }
package/po/de.po CHANGED
@@ -6,6 +6,15 @@ msgstr ""
6
6
  "Content-Type: text/plain; charset=UTF-8\n"
7
7
  "Content-Transfer-Encoding: 8bit\n"
8
8
 
9
+ msgid "Support"
10
+ msgstr "Unterstützen"
11
+
12
+ msgid "These apps are free software. Pick one to see how to support its development."
13
+ msgstr "Diese Apps sind freie Software. Wähle eine aus, um zu sehen, wie du ihre Entwicklung unterstützen kannst."
14
+
15
+ msgid "Language"
16
+ msgstr "Sprache"
17
+
9
18
  msgid "Website"
10
19
  msgstr "Webseite"
11
20
 
@@ -30,9 +39,6 @@ msgstr "Beim Übersetzen helfen"
30
39
  msgid "Contact"
31
40
  msgstr "Kontakt"
32
41
 
33
- msgid "Donate"
34
- msgstr "Spenden"
35
-
36
42
  msgid "Linux"
37
43
  msgstr "Linux"
38
44
 
@@ -192,9 +198,6 @@ msgstr "Stichwörter"
192
198
  msgid "Architectures"
193
199
  msgstr "Architekturen"
194
200
 
195
- msgid "Language"
196
- msgstr "Sprache"
197
-
198
201
  msgid "by {developer}"
199
202
  msgstr "von {developer}"
200
203
 
@@ -207,6 +210,12 @@ msgstr "Diese Seite gibt Texte unter der Lizenz {license} wieder."
207
210
  msgid "Made with {project}"
208
211
  msgstr "Gemacht mit {project}"
209
212
 
213
+ msgid "{app} is free software. Support its development with a transfer or a service."
214
+ msgstr "{app} ist freie Software. Unterstütze die Entwicklung per Überweisung oder über einen Dienst."
215
+
216
+ msgid "Back to {app}"
217
+ msgstr "Zurück zu {app}"
218
+
210
219
  msgid "Flathub"
211
220
  msgstr "Flathub"
212
221
 
@@ -263,3 +272,12 @@ msgstr "Store"
263
272
 
264
273
  msgid "Firefox add-on"
265
274
  msgstr "Firefox-Add-on"
275
+
276
+ msgid "GitHub Sponsors"
277
+ msgstr "GitHub Sponsors"
278
+
279
+ msgid "Liberapay"
280
+ msgstr "Liberapay"
281
+
282
+ msgid "PayPal"
283
+ msgstr "PayPal"
package/po/site.pot CHANGED
@@ -5,6 +5,15 @@ msgstr ""
5
5
  "Content-Type: text/plain; charset=UTF-8\n"
6
6
  "Content-Transfer-Encoding: 8bit\n"
7
7
 
8
+ msgid "Support"
9
+ msgstr ""
10
+
11
+ msgid "These apps are free software. Pick one to see how to support its development."
12
+ msgstr ""
13
+
14
+ msgid "Language"
15
+ msgstr ""
16
+
8
17
  msgid "Website"
9
18
  msgstr ""
10
19
 
@@ -29,9 +38,6 @@ msgstr ""
29
38
  msgid "Contact"
30
39
  msgstr ""
31
40
 
32
- msgid "Donate"
33
- msgstr ""
34
-
35
41
  msgid "Linux"
36
42
  msgstr ""
37
43
 
@@ -191,9 +197,6 @@ msgstr ""
191
197
  msgid "Architectures"
192
198
  msgstr ""
193
199
 
194
- msgid "Language"
195
- msgstr ""
196
-
197
200
  msgid "by {developer}"
198
201
  msgstr ""
199
202
 
@@ -206,6 +209,12 @@ msgstr ""
206
209
  msgid "Made with {project}"
207
210
  msgstr ""
208
211
 
212
+ msgid "{app} is free software. Support its development with a transfer or a service."
213
+ msgstr ""
214
+
215
+ msgid "Back to {app}"
216
+ msgstr ""
217
+
209
218
  msgid "Flathub"
210
219
  msgstr ""
211
220
 
@@ -262,3 +271,12 @@ msgstr ""
262
271
 
263
272
  msgid "Firefox add-on"
264
273
  msgstr ""
274
+
275
+ msgid "GitHub Sponsors"
276
+ msgstr ""
277
+
278
+ msgid "Liberapay"
279
+ msgstr ""
280
+
281
+ msgid "PayPal"
282
+ msgstr ""