@gjsify/site 0.63.1-next.236 → 0.64.0

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,
@@ -362,16 +362,20 @@ h1 {
362
362
  border-block-start: 1px solid var(--band-line);
363
363
  }
364
364
 
365
+ /* Three fixed rows — pill, value, label — so a badge whose pill is taller (the platform glyphs) or
366
+ * that carries no value at all still lands its label on the neighbours' line. */
365
367
  .badge {
366
- display: flex;
367
- flex-direction: column;
368
+ display: grid;
369
+ grid-template-rows: 2.75rem minmax(1.5rem, auto) auto;
368
370
  align-items: center;
371
+ justify-items: center;
369
372
  gap: 0.3rem;
370
373
  min-width: 0;
371
374
  text-align: center;
372
375
  }
373
376
 
374
377
  .badge-pill {
378
+ grid-row: 1;
375
379
  display: flex;
376
380
  align-items: center;
377
381
  justify-content: center;
@@ -451,10 +455,10 @@ h1 {
451
455
  --platform-color: #4285f4;
452
456
  }
453
457
 
454
- /* Kept in the flow even when a badge has no value, so every label sits on the same line. */
458
+ /* The row is reserved even when a badge has no value, so every label sits on the same line. */
455
459
  .badge-value {
460
+ grid-row: 2;
456
461
  line-height: 1.5;
457
- min-height: 1.5em;
458
462
  font-weight: 700;
459
463
  font-size: 0.95rem;
460
464
  /* A long licence id must wrap inside the badge instead of widening the row. */
@@ -462,6 +466,7 @@ h1 {
462
466
  }
463
467
 
464
468
  .badge-label {
469
+ grid-row: 3;
465
470
  color: var(--muted);
466
471
  font-size: 0.78rem;
467
472
  }
@@ -517,11 +522,15 @@ h1 {
517
522
  /* `--shot-w` is the CSS width this screenshot's renditions were cut for: at that width a 2560px
518
523
  * source is a 2× image, and above it the browser would be upscaling. Width and max-height
519
524
  * together shrink-wrap the artwork — `object-fit: contain` would letterbox it instead, and the
520
- * shadow would then be drawn around the empty strip. */
525
+ * shadow would then be drawn around the empty strip. Both are maxima on an `auto` box, and the
526
+ * height cap is `--shot-h`, not `100%`: the figure is `fit-content`, and in that intrinsic pass a
527
+ * percentage max-height counts as none, so the figure took the full width while the image was
528
+ * capped in height — a portrait shot (a browser popup) came out squashed. */
521
529
  .slide img {
522
- width: min(100%, var(--shot-w, 100%));
530
+ width: auto;
531
+ max-width: min(100%, var(--shot-w, 100%));
523
532
  height: auto;
524
- max-height: 100%;
533
+ max-height: var(--shot-h);
525
534
  border-radius: var(--radius-lg);
526
535
  box-shadow: var(--shot-shadow);
527
536
  }
@@ -1045,6 +1054,18 @@ app-download:not([data-enhanced]) .download-toggle {
1045
1054
  margin-block: 2rem;
1046
1055
  }
1047
1056
 
1057
+ app-support {
1058
+ display: block;
1059
+ }
1060
+
1061
+ .support-intro {
1062
+ margin-block: 0 1.5rem;
1063
+ }
1064
+
1065
+ .support-back {
1066
+ margin-block: 1.5rem 0;
1067
+ }
1068
+
1048
1069
  /* Footer */
1049
1070
 
1050
1071
  .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,23 @@ 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
+ /**
38
+ * The facts row under the hero. A badge whose fact is missing is left out, not faked, and the row
39
+ * tells CSS how many it ended up with so the columns divide the width evenly.
40
+ */
41
+ export declare function badges(ctx: Pick<RenderContext, 'data' | 't' | 'lang'>): string;
42
+ export declare function linkCard(href: string, title: string, glyph: IconName): string;
27
43
  /** 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
44
  export declare function sepaCard(entry: SiteSupportSepa, t: Translate): string;
45
+ /** The title and description of one page; also what the build checks for weak ones. */
46
+ export declare function pageMeta(ctx: Pick<RenderContext, 'data' | 'lang' | 't'>, view?: 'app' | 'support'): {
47
+ name: string;
48
+ title: string;
49
+ description: string;
50
+ };
29
51
  export declare function renderPage(ctx: RenderContext): string;
52
+ /** `/<lang>/support/`: the ways to support the app, as plain markup that needs no script. */
53
+ export declare function renderSupportPage(page: RenderContext): string;
30
54
  /** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
31
55
  export declare function renderRoot(data: SiteData, assets: ClientAssets, iconUrl: string | undefined): string;
32
56
  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) {
@@ -245,9 +254,12 @@ ${data.downloads.hasReleases ? `<p class="dialog-foot"><a href="${esc(data.downl
245
254
  </app-download>`;
246
255
  }
247
256
  function badge(spec) {
257
+ // An absent value is not an empty element: the row reserves the line, the DOM stays clean.
258
+ const value = spec.value
259
+ ? `\n<span${attrs({ class: 'badge-value', title: spec.title })}>${esc(spec.value)}</span>`
260
+ : '';
248
261
  return `<li${attrs({ class: spec.class ? `badge ${spec.class}` : 'badge', hidden: spec.hidden })}>
249
- <span class="badge-pill">${spec.pill ?? icon(spec.glyph)}</span>
250
- <span${attrs({ class: 'badge-value', title: spec.title })}>${esc(spec.value)}</span>
262
+ <span class="badge-pill">${spec.pill ?? icon(spec.glyph)}</span>${value}
251
263
  <span class="badge-label">${esc(spec.label)}</span>
252
264
  </li>`;
253
265
  }
@@ -259,7 +271,7 @@ function shortLicense(id) {
259
271
  * The facts row under the hero. A badge whose fact is missing is left out, not faked, and the row
260
272
  * tells CSS how many it ended up with so the columns divide the width evenly.
261
273
  */
262
- function badges(ctx) {
274
+ export function badges(ctx) {
263
275
  const { data, t, lang } = ctx;
264
276
  const number = new Intl.NumberFormat(lang, { notation: 'compact', maximumFractionDigits: 1 });
265
277
  const targets = data.downloads.targets;
@@ -286,7 +298,7 @@ function badges(ctx) {
286
298
  .map((os) => icon(PLATFORM_ICONS[os], { title: osName(os, t), class: `platform platform-${os}` }))
287
299
  .join('');
288
300
  const label = oses.every(isBrowserPlatform) ? t('Browsers') : t('Platforms');
289
- specs.push({ pill: `<span class="badge-platforms">${glyphs}</span>`, value: '', label });
301
+ specs.push({ pill: `<span class="badge-platforms">${glyphs}</span>`, label });
290
302
  }
291
303
  if (data.app.ageRating) {
292
304
  const { minAge } = data.app.ageRating;
@@ -351,7 +363,7 @@ ${blocks(release.description, lang)}
351
363
  : ''}
352
364
  </section>`;
353
365
  }
354
- function linkCard(href, title, glyph) {
366
+ export function linkCard(href, title, glyph) {
355
367
  return `<li>
356
368
  <a href="${esc(href)}">
357
369
  <span class="link-icon">${icon(glyph)}</span>
@@ -405,32 +417,18 @@ ${qrSvg(epcPayload(entry), t('QR code for a bank transfer'))}
405
417
  </figure>
406
418
  </li>`;
407
419
  }
408
- function supportItem(entry, t) {
420
+ function supportItem(entry, t, reference) {
409
421
  return entry.kind === 'sepa'
410
- ? sepaCard(entry, t)
422
+ ? sepaCard({ ...entry, reference: entry.reference ?? reference }, t)
411
423
  : linkCard(entry.url, t(entry.kind), 'heart');
412
424
  }
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
- */
425
+ /** The landing page only points at the support page: the details live there, readable without a dialog. */
417
426
  function support(ctx) {
418
- const { data, t } = ctx;
427
+ const { data, lang, t } = ctx;
419
428
  if (data.config.support.length === 0)
420
429
  return '';
421
430
  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>
431
+ <a class="button support-cta" href="${esc(supportUrl(data, lang))}">${icon('heart')}<span>${esc(t('Support this project'))}</span></a>
434
432
  </section>`;
435
433
  }
436
434
  function tags(ctx) {
@@ -455,7 +453,7 @@ function languageSwitch(ctx, className, style) {
455
453
  const label = style === 'code' ? code.toUpperCase() : languageName(code);
456
454
  return code === lang
457
455
  ? `<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>`;
456
+ : `<li><a href="${esc(ctx.view === 'support' ? supportUrl(data, code) : pageUrl(data, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(label)}</a></li>`;
459
457
  });
460
458
  return `<nav class="${className}" aria-label="${esc(t('Language'))}"><ul>${items.join('')}</ul></nav>`;
461
459
  }
@@ -525,15 +523,58 @@ ${legal ? `<ul class="footer-legal">${legal}</ul>` : ''}
525
523
  </div>
526
524
  </footer>`;
527
525
  }
528
- function head(ctx) {
529
- const { data, lang } = ctx;
526
+ /** The title and description of one page; also what the build checks for weak ones. */
527
+ export function pageMeta(ctx, view = 'app') {
528
+ const { data, lang, t } = ctx;
530
529
  const name = localized(data.app.name, lang);
530
+ if (view === 'support') {
531
+ return {
532
+ name,
533
+ title: `${t('Support')} – ${name}`,
534
+ description: plain(t('{app} is free software. Support its development with a transfer or a service.'), {
535
+ app: name,
536
+ }),
537
+ };
538
+ }
531
539
  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;
540
+ const first = data.app.description.find((block) => block.type === 'paragraph');
541
+ const paragraph = first?.type === 'paragraph' ? localized(first.text, lang) : undefined;
542
+ return { name, title: pageTitle(name, summary), description: metaDescription(summary, paragraph) };
543
+ }
544
+ const SCHEMA_OS = {
545
+ linux: 'Linux',
546
+ darwin: 'macOS',
547
+ win32: 'Windows',
548
+ android: 'Android',
549
+ web: 'Web browser',
550
+ firefox: 'Web browser',
551
+ chromium: 'Web browser',
552
+ };
553
+ function structuredData(ctx, title, description) {
554
+ const { data, lang } = ctx;
555
+ const systems = [
556
+ ...new Set(data.downloads.targets.filter((target) => target.available).map((target) => SCHEMA_OS[target.os])),
557
+ ];
558
+ return softwareApplicationJsonLd({
559
+ name: localized(data.app.name, lang),
560
+ description,
561
+ url: absolute(data, pageUrl(data, lang)),
562
+ image: ctx.iconUrl ? absolute(data, ctx.iconUrl) : undefined,
563
+ version: data.version,
564
+ operatingSystems: systems,
565
+ license: data.app.license,
566
+ downloadUrl: data.downloads.releasesUrl,
567
+ repository: repositoryUrl(data),
568
+ languages: data.config.languages,
569
+ });
570
+ }
571
+ function head(ctx) {
572
+ const { data, lang } = ctx;
573
+ const view = ctx.view ?? 'app';
574
+ const viewPath = (code) => (view === 'support' ? supportUrl(data, code) : pageUrl(data, code));
575
+ const { name, title, description } = pageMeta(ctx, view);
576
+ const url = absolute(data, viewPath(lang));
577
+ const ogImage = ctx.ogImage ? absolute(data, ctx.ogImage) : undefined;
537
578
  const { light, dark } = data.app.branding;
538
579
  // An app without `<branding>` keeps the stylesheet's own palette: the custom property is left
539
580
  // unset so its `var()` fallback wins, rather than being set to a stand-in colour here.
@@ -543,29 +584,37 @@ function head(ctx) {
543
584
  ]
544
585
  .filter(Boolean)
545
586
  .join(';');
546
- const alternates = data.config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, pageUrl(data, code)))}">`);
587
+ const alternates = data.config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, viewPath(code)))}">`);
547
588
  const meta = [
548
589
  ['og:type', 'website'],
549
590
  ['og:title', title],
550
- ['og:description', summary],
591
+ ['og:description', description],
551
592
  ['og:url', url],
552
593
  ['og:site_name', name],
553
- ['og:locale', lang.replace(/-/g, '_')],
594
+ ['og:locale', ogLocale(lang)],
595
+ ...data.config.languages
596
+ .filter((code) => code !== lang)
597
+ .map((code) => ['og:locale:alternate', ogLocale(code)]),
554
598
  ];
555
- if (ogImage)
556
- meta.push(['og:image', ogImage]);
599
+ if (ogImage) {
600
+ meta.push(['og:image', ogImage], ['og:image:width', String(OG_WIDTH)], ['og:image:height', String(OG_HEIGHT)], ['og:image:alt', name]);
601
+ }
557
602
  return `<meta charset="utf-8">
558
603
  <meta name="viewport" content="width=device-width, initial-scale=1">
559
604
  <title>${esc(title)}</title>
560
- <meta name="description" content="${esc(summary)}">
605
+ <meta name="description" content="${esc(description)}">
561
606
  <link rel="canonical" href="${esc(url)}">
562
607
  ${alternates.join('\n')}
563
- <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, data.config.base))}">
608
+ <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, viewPath(data.config.defaultLanguage)))}">
564
609
  ${meta.map(([property, content]) => `<meta property="${property}" content="${esc(content)}">`).join('\n')}
565
610
  <meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
611
+ <meta name="twitter:title" content="${esc(title)}">
612
+ <meta name="twitter:description" content="${esc(description)}">
613
+ ${ogImage ? `<meta name="twitter:image" content="${esc(ogImage)}">` : ''}
566
614
  <meta name="theme-color" content="${esc(light ?? '#ffffff')}" media="(prefers-color-scheme: light)">
567
615
  <meta name="theme-color" content="${esc(dark ?? '#1e1e1e')}" media="(prefers-color-scheme: dark)">
568
616
  ${ctx.iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(ctx.iconUrl)}">` : ''}
617
+ ${view === 'app' ? structuredData(ctx, title, description) : ''}
569
618
  ${brandVars ? `<style>:root{${brandVars}}</style>` : ''}
570
619
  ${ctx.assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
571
620
  <script type="module" src="${esc(ctx.assets.js)}"></script>`;
@@ -597,6 +646,44 @@ ${footer(ctx)}
597
646
  </html>
598
647
  `;
599
648
  }
649
+ /** `/<lang>/support/`: the ways to support the app, as plain markup that needs no script. */
650
+ export function renderSupportPage(page) {
651
+ const ctx = { ...page, view: 'support' };
652
+ const { data, lang, t } = ctx;
653
+ const meta = pageMeta(ctx, 'support');
654
+ const reference = supportReference(data);
655
+ return `<!doctype html>
656
+ <html lang="${esc(lang)}">
657
+ <head>
658
+ ${head(ctx)}
659
+ </head>
660
+ <body>
661
+ ${topBar(ctx)}
662
+ <header class="masthead">
663
+ <div class="hero-inner">
664
+ ${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
665
+ <div class="hero-text">
666
+ <h1>${esc(t('Support'))}</h1>
667
+ <p class="hero-by">${esc(meta.name)}</p>
668
+ </div>
669
+ </div>
670
+ </header>
671
+ <main>
672
+ <div class="column">
673
+ <p class="support-intro">${esc(meta.description)}</p>
674
+ <app-support>
675
+ <ul class="card-list">
676
+ ${data.config.support.map((entry) => supportItem(entry, t, reference)).join('\n')}
677
+ </ul>
678
+ </app-support>
679
+ <p class="support-back"><a href="${esc(pageUrl(data, lang))}">${fmt(t('Back to {app}'), { app: meta.name })}</a></p>
680
+ </div>
681
+ </main>
682
+ ${footer(ctx)}
683
+ </body>
684
+ </html>
685
+ `;
686
+ }
600
687
  /** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
601
688
  export function renderRoot(data, assets, iconUrl) {
602
689
  const { config } = data;
@@ -612,7 +699,7 @@ export function renderRoot(data, assets, iconUrl) {
612
699
  <title>${esc(name)}</title>
613
700
  <link rel="canonical" href="${esc(absolute(data, pageUrl(data, config.defaultLanguage)))}">
614
701
  ${alternates.join('\n')}
615
- <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, config.base))}">
702
+ <link rel="alternate" hreflang="x-default" href="${esc(absolute(data, pageUrl(data, config.defaultLanguage)))}">
616
703
  ${iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(iconUrl)}">` : ''}
617
704
  ${assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
618
705
  <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.64.0",
4
4
  "description": "Static landing page generator for a GNOME-style app, rendered from its AppStream metainfo — downloads, screenshots, release notes, support links, no JavaScript required.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -54,26 +54,25 @@
54
54
  "license": "MIT",
55
55
  "dependencies": {
56
56
  "@girs/gdkpixbuf-2.0": "5.4.0",
57
- "@gjsify/console": "0.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.64.0",
58
+ "@gjsify/crypto": "^0.64.0",
59
+ "@gjsify/fs": "^0.64.0",
60
+ "@gjsify/module": "^0.64.0",
61
+ "@gjsify/os": "^0.64.0",
62
+ "@gjsify/path": "^0.64.0",
63
+ "@gjsify/process": "^0.64.0",
64
+ "@gjsify/url": "^0.64.0",
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.64.0",
70
70
  "@gjsify/node-gi": "file:../../node-gi/node-gi",
71
- "@gjsify/unit": "0.63.1-next.236",
71
+ "@gjsify/unit": "^0.64.0",
72
72
  "@types/node": "^25.9.2",
73
73
  "typescript": "^6.0.3"
74
74
  },
75
75
  "gjsify": {
76
76
  "tier": 2
77
- },
78
- "gitHead": "d4ae412daabae9cb89f4fa11ee7279a308c9e4be"
77
+ }
79
78
  }
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 ""