@gjsify/site 0.64.1-next.245 → 0.64.1-next.248
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 +29 -1
- package/lib/client/site.css +41 -2
- package/lib/client/site.js +1 -1
- package/lib/render/i18n.js +5 -0
- package/lib/render/pages.d.ts +11 -0
- package/lib/render/pages.js +88 -18
- package/lib/site-data/config.js +32 -3
- package/lib/site-data/downloads.d.ts +15 -2
- package/lib/site-data/downloads.js +28 -4
- package/lib/site-data/load.js +1 -0
- package/lib/site-data/types.d.ts +28 -0
- package/package.json +12 -12
- package/po/de.po +8 -5
- package/po/site.pot +7 -4
package/README.md
CHANGED
|
@@ -104,7 +104,8 @@ Lives in the root `package.json`, next to `ship` and `flatpak`.
|
|
|
104
104
|
| `translations` | Directory of the app's `<lang>.po` catalogues, used when the metainfo lacks a translation. Optional. |
|
|
105
105
|
| `pageTranslations` | Directory of `<lang>.po` catalogues for the *page's own* strings, overlaying this package's `po/`. Optional, see below. |
|
|
106
106
|
| `icon` | Icon (SVG or PNG) for the header, hero and favicon; overrides the Flatpak/ship icon. Optional. |
|
|
107
|
-
| `
|
|
107
|
+
| `components` | `[{id, name, summary?, match?}]`: the separately installed parts of the product, in install order, see below. Optional — without it the dialog is one undivided list. |
|
|
108
|
+
| `downloads` | `[{platform, format, url?, label?, available?, component?}]`: targets the release assets do not cover, e.g. browser stores or a source install. `platform` is an OS or `firefox` / `chromium`; `format` adds `store`, `xpi`, `source`. `url` is required unless `available` is `false`, which lists the target as "Not yet available" and never recommends it. `component` is a `components[].id`. |
|
|
108
109
|
| `releaseAssets` | `false` ignores the GitHub release assets, so `downloads` is the whole list. Default `true`. |
|
|
109
110
|
| `support`, `legal` | `support`: `{kind, url}` links, or `{kind: "sepa", name, iban, bic?, reference?}`, a bank transfer shown as IBAN plus an EPC QR code. `legal`: `{label, url}` links. Both empty by default: nothing is invented. |
|
|
110
111
|
|
|
@@ -112,6 +113,33 @@ A `sepa` entry carries no amount, and its IBAN is checked when the config loads
|
|
|
112
113
|
country, alphabet, and the mod-97 checksum — so a typo fails the build instead of printing a QR
|
|
113
114
|
code that moves money nowhere.
|
|
114
115
|
|
|
116
|
+
### Components
|
|
117
|
+
|
|
118
|
+
Some products are not one download. A desktop app plus the browser extension that talks to it are
|
|
119
|
+
two things a person installs one after the other, and a single flat list of files does not say
|
|
120
|
+
which goes first. `components` names those parts:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
"components": [
|
|
124
|
+
{ "id": "app", "name": "App", "summary": "Install and start it once.", "match": "^beifahrer[-_]" },
|
|
125
|
+
{ "id": "extension", "name": "Browser extension", "summary": "Then add it to your browser." }
|
|
126
|
+
]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each part becomes its own block in the download dialog, in declared order, with its own operating
|
|
130
|
+
system tabs — numbered "Step 1", "Step 2", … as soon as there is more than one. The tab strip is
|
|
131
|
+
labelled `Browser` when every target in that block is a browser, otherwise `Operating system`.
|
|
132
|
+
|
|
133
|
+
`match` is a regular expression on the asset's file name, matched case-insensitively; the first
|
|
134
|
+
component whose `match` hits owns the asset. A component without `match` is the catch-all and
|
|
135
|
+
takes everything left over, which is what a release without a naming scheme for every file needs.
|
|
136
|
+
Entries in `downloads` pick their block by `component` instead, naming a `components[].id` — an
|
|
137
|
+
unknown id fails the build with the known ids listed.
|
|
138
|
+
|
|
139
|
+
The recommendation ("Recommended for your system") is made once per part, so each block
|
|
140
|
+
highlights its own best match, and the big button at the top of the dialog offers the first
|
|
141
|
+
declared component: the thing to install first.
|
|
142
|
+
|
|
115
143
|
### Screenshots
|
|
116
144
|
|
|
117
145
|
Nothing to configure: the page shows the metainfo `<screenshots>`, in document order, with their
|
package/lib/client/site.css
CHANGED
|
@@ -953,6 +953,45 @@ h1 {
|
|
|
953
953
|
color: var(--fg);
|
|
954
954
|
}
|
|
955
955
|
|
|
956
|
+
/* One block per part of the product, in the order the parts have to be installed in. */
|
|
957
|
+
.component + .component {
|
|
958
|
+
margin-top: 1.25rem;
|
|
959
|
+
border-top: 1px solid var(--line);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
.component-head {
|
|
963
|
+
padding: 1rem 1.25rem 0;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
.component-title {
|
|
967
|
+
display: flex;
|
|
968
|
+
align-items: center;
|
|
969
|
+
gap: 0.5rem;
|
|
970
|
+
margin: 0;
|
|
971
|
+
font-size: 1rem;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
.component-step {
|
|
975
|
+
display: inline-flex;
|
|
976
|
+
flex: none;
|
|
977
|
+
align-items: center;
|
|
978
|
+
justify-content: center;
|
|
979
|
+
width: 1.5rem;
|
|
980
|
+
height: 1.5rem;
|
|
981
|
+
border-radius: 999px;
|
|
982
|
+
background: var(--accent);
|
|
983
|
+
color: var(--accent-fg);
|
|
984
|
+
font-size: 0.8rem;
|
|
985
|
+
font-variant-numeric: tabular-nums;
|
|
986
|
+
font-weight: 600;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
.component-summary {
|
|
990
|
+
margin: 0.4rem 0 0;
|
|
991
|
+
color: var(--muted);
|
|
992
|
+
font-size: 0.85rem;
|
|
993
|
+
}
|
|
994
|
+
|
|
956
995
|
.os-tabs {
|
|
957
996
|
display: flex;
|
|
958
997
|
gap: 0.35rem;
|
|
@@ -989,7 +1028,7 @@ h1 {
|
|
|
989
1028
|
padding: 0 1.25rem;
|
|
990
1029
|
}
|
|
991
1030
|
|
|
992
|
-
.os-
|
|
1031
|
+
.os-title {
|
|
993
1032
|
display: flex;
|
|
994
1033
|
align-items: center;
|
|
995
1034
|
gap: 0.5rem;
|
|
@@ -998,7 +1037,7 @@ h1 {
|
|
|
998
1037
|
}
|
|
999
1038
|
|
|
1000
1039
|
/* With tabs visible the group heading only repeats the selected tab. */
|
|
1001
|
-
.os-tabs:not([hidden]) ~ .os-groups
|
|
1040
|
+
.os-tabs:not([hidden]) ~ .os-groups .os-title {
|
|
1002
1041
|
display: none;
|
|
1003
1042
|
}
|
|
1004
1043
|
|
package/lib/client/site.js
CHANGED
|
@@ -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??``}]})}
|
|
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??``}]})}function showSize(e){let t=document.querySelector(`.badge-size`),n=t?.querySelector(`.badge-value`);if(!t||!n)return;n.textContent=e,t.hidden=e===``;let r=t.closest(`.badges`);if(!r)return;let i=[...r.children].filter(e=>!e.hidden).length;r.style.setProperty(`--badge-count`,String(i)),r.toggleAttribute(`data-odd`,i%2==1)}var ComponentSection=class{onSelect;rows;groups;tabs;tabBar;grouped;constructor(e,t){this.onSelect=t,this.rows=readRows(e),this.groups=[...e.querySelectorAll(`.os-group`)],this.tabs=[...e.querySelectorAll(`.os-tab`)],this.tabBar=e.querySelector(`.os-tabs`),this.grouped=this.tabs.length>1,this.tabBar&&this.grouped&&(this.tabBar.hidden=!1);let n=this.tabs.find(e=>e.getAttribute(`aria-selected`)===`true`)?.dataset.os;n&&this.show(n);for(let e of this.tabs)e.addEventListener(`click`,()=>{let t=e.dataset.os;t&&this.select(t,this.platform)});this.tabBar?.addEventListener(`keydown`,e=>{let t=this.tabs.indexOf(document.activeElement);if(t===-1)return;let n=this.tabs.length-1,r={ArrowRight:t+1,ArrowLeft:t-1,Home:0,End:n}[e.key];if(r===void 0)return;e.preventDefault();let i=this.tabs[(r+this.tabs.length)%this.tabs.length];i?.focus(),i?.click()})}platform={os:void 0,arch:void 0,browser:void 0,archAssumed:!0};show(e){for(let t of this.groups)t.hidden=this.grouped&&t.dataset.os!==e;for(let t of this.tabs){let n=t.dataset.os===e;t.setAttribute(`aria-selected`,String(n)),t.tabIndex=n?0:-1}}select(e,t){this.show(e);let n=recommendTarget(this.rows,inGroup(t,e));n&&this.onSelect(n)}detected(e){this.platform=e;let t=recommendTarget(this.rows,e);t&&(this.show(t.os),this.onSelect(t))}},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 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),showSize(e.size)},n=[...this.querySelectorAll(`section.component`)],r=(n.length>0?n:[this]).map((e,t)=>new ComponentSection(e,t===0?offer:()=>{}));readHints().then(e=>{let t=detectVisitorPlatform(e);for(let e of r)e.detected(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);
|
package/lib/render/i18n.js
CHANGED
|
@@ -47,6 +47,11 @@ export function configMsgids(config) {
|
|
|
47
47
|
for (const download of config.downloads)
|
|
48
48
|
if (download.label)
|
|
49
49
|
msgids.add(download.label);
|
|
50
|
+
for (const component of config.components) {
|
|
51
|
+
msgids.add(component.name);
|
|
52
|
+
if (component.summary)
|
|
53
|
+
msgids.add(component.summary);
|
|
54
|
+
}
|
|
50
55
|
return [...msgids];
|
|
51
56
|
}
|
|
52
57
|
/** Every msgid in a `.po`/`.pot` file, translated or not, header excluded. */
|
package/lib/render/pages.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export interface RenderContext {
|
|
|
20
20
|
ogImage?: string;
|
|
21
21
|
}
|
|
22
22
|
type Translate = (msgid: string) => string;
|
|
23
|
+
/** What the download dialog reads: the targets, the catalogue and the locale it formats sizes in. */
|
|
24
|
+
export type DownloadContext = Pick<RenderContext, 'data' | 't' | 'lang'>;
|
|
23
25
|
export declare function localized(text: Localized, lang: string): string;
|
|
24
26
|
export declare function pageUrl(data: SiteData, lang: string): string;
|
|
25
27
|
export declare function absolute(data: SiteData, rootRelative: string): string;
|
|
@@ -34,6 +36,15 @@ export declare function languageName(code: string): string;
|
|
|
34
36
|
export declare function ctaLabel(target: DownloadTarget, t: Translate): string;
|
|
35
37
|
/** A window shot brings its own shadow; ours would draw a second one around it. */
|
|
36
38
|
export declare function frameAttribute(ctx: Pick<RenderContext, 'images'>, screenshot: Screenshot): 'none' | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* The split button plus the dialog holding every target. Both are server-rendered: without
|
|
41
|
+
* JavaScript the dialog stays an open panel below the button (see `site.css`), with it the
|
|
42
|
+
* component turns the same markup into a modal and swaps the button for the detected platform.
|
|
43
|
+
*
|
|
44
|
+
* The button offers the part that is installed first; the dialog lists every part in that order,
|
|
45
|
+
* so a product made of an app and the extension it pairs with reads as the two steps it is.
|
|
46
|
+
*/
|
|
47
|
+
export declare function downloadWidget(ctx: DownloadContext): string;
|
|
37
48
|
/**
|
|
38
49
|
* The facts row under the hero. A badge whose fact is missing is left out, not faked, and the row
|
|
39
50
|
* tells CSS how many it ended up with so the columns divide the width evenly.
|
package/lib/render/pages.js
CHANGED
|
@@ -176,6 +176,33 @@ function defaultTarget(targets) {
|
|
|
176
176
|
available.find((target) => target.recommended) ??
|
|
177
177
|
available[0]);
|
|
178
178
|
}
|
|
179
|
+
/**
|
|
180
|
+
* The targets of the part a person installs FIRST, which is the one the primary button offers. A
|
|
181
|
+
* product declaring no components has exactly one part, so the whole list is that part.
|
|
182
|
+
*/
|
|
183
|
+
function primaryTargets(data) {
|
|
184
|
+
const first = data.config.components[0];
|
|
185
|
+
if (!first)
|
|
186
|
+
return data.downloads.targets;
|
|
187
|
+
const own = data.downloads.targets.filter((target) => target.component === first.id);
|
|
188
|
+
return own.length > 0 ? own : data.downloads.targets;
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The dialog's blocks, in install order. A component nobody built a file for is left out rather
|
|
192
|
+
* than shown empty, so a release that is missing an artefact says so by absence.
|
|
193
|
+
*/
|
|
194
|
+
function componentBlocks(data) {
|
|
195
|
+
const targets = data.downloads.targets;
|
|
196
|
+
const declared = data.config.components;
|
|
197
|
+
if (declared.length === 0)
|
|
198
|
+
return [{ id: '', targets }];
|
|
199
|
+
const blocks = declared.flatMap((part) => {
|
|
200
|
+
const own = targets.filter((target) => target.component === part.id);
|
|
201
|
+
return own.length > 0 ? [{ id: part.id, name: part.name, summary: part.summary, targets: own }] : [];
|
|
202
|
+
});
|
|
203
|
+
const rest = targets.filter((target) => !declared.some((part) => part.id === target.component));
|
|
204
|
+
return rest.length > 0 ? [...blocks, { id: '', targets: rest }] : blocks;
|
|
205
|
+
}
|
|
179
206
|
/**
|
|
180
207
|
* One row of the download dialog. The data attributes carry everything the client needs to put a
|
|
181
208
|
* row on the primary button, so it never has to build a translated string itself.
|
|
@@ -205,15 +232,66 @@ ${size ? `<span class="target-size">${esc(size)}</span>` : ''}`;
|
|
|
205
232
|
${target.available ? `<a href="${esc(target.url)}">\n${inner}\n</a>` : `<span class="target-link">\n${inner}\n</span>`}
|
|
206
233
|
</li>`;
|
|
207
234
|
}
|
|
235
|
+
/** A component id as an HTML id fragment; a configuration's own ids are not guaranteed to be safe. */
|
|
236
|
+
function blockSlug(block, index) {
|
|
237
|
+
const slug = block.id
|
|
238
|
+
.toLowerCase()
|
|
239
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
240
|
+
.replace(/^-|-$/g, '');
|
|
241
|
+
return slug || `part-${index + 1}`;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* One part of the product in the dialog: its heading, then one tab per platform it is built for.
|
|
245
|
+
* The tabs live inside the block, so the extension's tabs are browsers while the app's are
|
|
246
|
+
* operating systems — the two parts are picked by different facts about the visitor.
|
|
247
|
+
*
|
|
248
|
+
* `step` numbers the part when there is more than one, because the order they are listed in is the
|
|
249
|
+
* order they have to be installed in.
|
|
250
|
+
*/
|
|
251
|
+
function componentSection(block, slug, step, ctx) {
|
|
252
|
+
const { t } = ctx;
|
|
253
|
+
const selected = defaultTarget(block.targets)?.os;
|
|
254
|
+
const groups = PLATFORM_ORDER.flatMap((os) => {
|
|
255
|
+
const own = block.targets.filter((target) => target.os === os);
|
|
256
|
+
return own.length > 0 ? [[os, own]] : [];
|
|
257
|
+
});
|
|
258
|
+
// A nested block already has a heading of its own, so its platforms are one level deeper.
|
|
259
|
+
const level = block.name ? 'h4' : 'h3';
|
|
260
|
+
const tabs = groups.map(([os]) => `<button class="os-tab" type="button" role="tab" id="os-tab-${esc(slug)}-${esc(os)}" aria-controls="os-group-${esc(slug)}-${esc(os)}" data-os="${esc(os)}" aria-selected="${os === selected}">${icon(PLATFORM_ICONS[os])}<span>${esc(osName(os, t))}</span></button>`);
|
|
261
|
+
const sections = groups.map(([os, own,]) => `<section class="os-group" id="os-group-${esc(slug)}-${esc(os)}" role="tabpanel" aria-labelledby="os-tab-${esc(slug)}-${esc(os)}" data-os="${esc(os)}">
|
|
262
|
+
<${level} class="os-title">${icon(PLATFORM_ICONS[os])}<span>${esc(osName(os, t))}</span></${level}>
|
|
263
|
+
<ul>
|
|
264
|
+
${own.map((target) => targetItem(target, ctx)).join('\n')}
|
|
265
|
+
</ul>
|
|
266
|
+
${os === 'darwin' ? `<p class="os-note">${esc(t('Apple Silicon is assumed. Pick the Intel build if your Mac has an Intel processor.'))}</p>` : ''}
|
|
267
|
+
</section>`);
|
|
268
|
+
const head = block.name
|
|
269
|
+
? `<div class="component-head">
|
|
270
|
+
<h3 class="component-title">${step === undefined ? '' : `<span class="component-step">${esc(String(step))}</span>`}<span>${esc(t(block.name))}</span></h3>
|
|
271
|
+
${block.summary ? `<p class="component-summary">${esc(t(block.summary))}</p>` : ''}
|
|
272
|
+
</div>`
|
|
273
|
+
: '';
|
|
274
|
+
// An extension's tabs are browsers; naming them "Operating system" would mislead a screen reader.
|
|
275
|
+
const tabsLabel = groups.every(([os]) => isBrowserPlatform(os)) ? t('Browser') : t('Operating system');
|
|
276
|
+
return `<section class="component"${attrs({ 'data-component': block.id || undefined })}>
|
|
277
|
+
${head}
|
|
278
|
+
<div class="os-tabs" role="tablist" aria-label="${esc(tabsLabel)}" hidden>${tabs.join('')}</div>
|
|
279
|
+
<div class="os-groups">
|
|
280
|
+
${sections.join('\n')}
|
|
281
|
+
</div>
|
|
282
|
+
</section>`;
|
|
283
|
+
}
|
|
208
284
|
/**
|
|
209
285
|
* The split button plus the dialog holding every target. Both are server-rendered: without
|
|
210
286
|
* JavaScript the dialog stays an open panel below the button (see `site.css`), with it the
|
|
211
287
|
* component turns the same markup into a modal and swaps the button for the detected platform.
|
|
288
|
+
*
|
|
289
|
+
* The button offers the part that is installed first; the dialog lists every part in that order,
|
|
290
|
+
* so a product made of an app and the extension it pairs with reads as the two steps it is.
|
|
212
291
|
*/
|
|
213
|
-
function downloadWidget(ctx) {
|
|
292
|
+
export function downloadWidget(ctx) {
|
|
214
293
|
const { data, t } = ctx;
|
|
215
|
-
const
|
|
216
|
-
const primary = defaultTarget(targets);
|
|
294
|
+
const primary = defaultTarget(primaryTargets(data));
|
|
217
295
|
if (!primary) {
|
|
218
296
|
// Nothing downloadable yet: point at the releases, or at the source when there are none.
|
|
219
297
|
const [href, label] = data.downloads.hasReleases
|
|
@@ -223,18 +301,10 @@ function downloadWidget(ctx) {
|
|
|
223
301
|
<a class="button download-cta" href="${esc(href)}">${icon('download')}<span class="download-label">${esc(label)}</span></a>
|
|
224
302
|
</div>`;
|
|
225
303
|
}
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const tabs = groups.map(([os]) => `<button class="os-tab" type="button" role="tab" id="os-tab-${esc(os)}" aria-controls="os-group-${esc(os)}" data-os="${esc(os)}" aria-selected="${os === primary.os}">${icon(PLATFORM_ICONS[os])}<span>${esc(osName(os, t))}</span></button>`);
|
|
231
|
-
const sections = groups.map(([os, own,]) => `<section class="os-group" id="os-group-${esc(os)}" role="tabpanel" aria-labelledby="os-tab-${esc(os)}" data-os="${esc(os)}">
|
|
232
|
-
<h3>${icon(PLATFORM_ICONS[os])}<span>${esc(osName(os, t))}</span></h3>
|
|
233
|
-
<ul>
|
|
234
|
-
${own.map((target) => targetItem(target, ctx)).join('\n')}
|
|
235
|
-
</ul>
|
|
236
|
-
${os === 'darwin' ? `<p class="os-note">${esc(t('Apple Silicon is assumed. Pick the Intel build if your Mac has an Intel processor.'))}</p>` : ''}
|
|
237
|
-
</section>`);
|
|
304
|
+
const blocks = componentBlocks(data);
|
|
305
|
+
const named = blocks.filter((block) => block.name).length;
|
|
306
|
+
let step = 0;
|
|
307
|
+
const sections = blocks.map((block, index) => componentSection(block, blockSlug(block, index), block.name && named > 1 ? ++step : undefined, ctx));
|
|
238
308
|
return `<app-download class="download">
|
|
239
309
|
<div class="download-split">
|
|
240
310
|
<a class="button download-cta" href="${esc(primary.url)}"${attrs({ 'data-os': primary.os, 'data-arch': primary.arch })}>${icon('download')}<span class="download-label">${esc(ctaLabel(primary, t))}</span></a>
|
|
@@ -245,8 +315,7 @@ ${os === 'darwin' ? `<p class="os-note">${esc(t('Apple Silicon is assumed. Pick
|
|
|
245
315
|
<h2 id="all-downloads-title">${esc(t('All download options'))}</h2>
|
|
246
316
|
<button class="dialog-close" type="button" aria-label="${esc(t('Close'))}" hidden>${icon('close')}</button>
|
|
247
317
|
</div>
|
|
248
|
-
<div class="
|
|
249
|
-
<div class="os-groups">
|
|
318
|
+
<div class="components">
|
|
250
319
|
${sections.join('\n')}
|
|
251
320
|
</div>
|
|
252
321
|
${data.downloads.hasReleases ? `<p class="dialog-foot"><a href="${esc(data.downloads.releasesUrl)}">${esc(t('All releases on GitHub'))}${icon('external', { class: 'link-external' })}</a></p>` : ''}
|
|
@@ -276,7 +345,8 @@ export function badges(ctx) {
|
|
|
276
345
|
const number = new Intl.NumberFormat(lang, { notation: 'compact', maximumFractionDigits: 1 });
|
|
277
346
|
const targets = data.downloads.targets;
|
|
278
347
|
const specs = [];
|
|
279
|
-
|
|
348
|
+
// The badge belongs to the primary button, so it reports the part that button offers.
|
|
349
|
+
const primary = defaultTarget(primaryTargets(data));
|
|
280
350
|
if (primary) {
|
|
281
351
|
// Not every target reports a size; the client swaps this one as the reader picks another.
|
|
282
352
|
const size = sizeLabel(primary.size, lang);
|
package/lib/site-data/config.js
CHANGED
|
@@ -70,7 +70,29 @@ const FORMATS = [
|
|
|
70
70
|
'xpi',
|
|
71
71
|
'source',
|
|
72
72
|
];
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* `gjsify.site.components`: the parts of the product a person installs one after another. `match`
|
|
75
|
+
* arrives as a string because this is JSON, and is compiled case-insensitively — release asset
|
|
76
|
+
* names differ in case between the formats that build them.
|
|
77
|
+
*/
|
|
78
|
+
function parseComponents(raw) {
|
|
79
|
+
return objectList(raw, 'components').map((entry, index) => {
|
|
80
|
+
const where = `gjsify.site.components[${index}]`;
|
|
81
|
+
const id = stringField(entry, 'id', where);
|
|
82
|
+
const name = stringField(entry, 'name', where);
|
|
83
|
+
const summary = entry.summary === undefined ? undefined : stringField(entry, 'summary', where);
|
|
84
|
+
if (entry.match === undefined)
|
|
85
|
+
return { id, name, summary };
|
|
86
|
+
const pattern = stringField(entry, 'match', where);
|
|
87
|
+
try {
|
|
88
|
+
return { id, name, summary, match: new RegExp(pattern, 'i') };
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
throw new Error(`${where}.match is not a valid regular expression: ${pattern}`, { cause });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function parseDownloads(raw, components) {
|
|
74
96
|
return objectList(raw, 'downloads').map((entry, index) => {
|
|
75
97
|
const where = `gjsify.site.downloads[${index}]`;
|
|
76
98
|
const platform = stringField(entry, 'platform', where);
|
|
@@ -92,7 +114,12 @@ function parseDownloads(raw) {
|
|
|
92
114
|
const label = entry.label;
|
|
93
115
|
if (label !== undefined && (typeof label !== 'string' || label === ''))
|
|
94
116
|
throw new Error(`${where}.label must be a non-empty string`);
|
|
95
|
-
|
|
117
|
+
const component = entry.component === undefined ? undefined : stringField(entry, 'component', where);
|
|
118
|
+
// A typo here would silently drop the row into the unnamed rest of the dialog.
|
|
119
|
+
if (component !== undefined && !components.some((part) => part.id === component)) {
|
|
120
|
+
throw new Error(`${where}.component "${component}" is not one of gjsify.site.components: ${components.map((part) => part.id).join(', ') || '(none declared)'}`);
|
|
121
|
+
}
|
|
122
|
+
return { platform, format, url, label, available, component };
|
|
96
123
|
});
|
|
97
124
|
}
|
|
98
125
|
function parseReleaseAssets(raw) {
|
|
@@ -118,6 +145,7 @@ export function parseSiteConfig(manifest) {
|
|
|
118
145
|
if (!languages.includes(defaultLanguage))
|
|
119
146
|
throw new Error(`gjsify.site.defaultLanguage "${defaultLanguage}" is not in languages`);
|
|
120
147
|
const url = requireString(raw, 'url');
|
|
148
|
+
const components = parseComponents(raw);
|
|
121
149
|
return {
|
|
122
150
|
metainfo: requireString(raw, 'metainfo'),
|
|
123
151
|
base: normalizeBase(requireString(raw, 'base')),
|
|
@@ -131,7 +159,8 @@ export function parseSiteConfig(manifest) {
|
|
|
131
159
|
translations: optionalString(raw, 'translations'),
|
|
132
160
|
pageTranslations: optionalString(raw, 'pageTranslations'),
|
|
133
161
|
icon: optionalString(raw, 'icon'),
|
|
134
|
-
|
|
162
|
+
components,
|
|
163
|
+
downloads: parseDownloads(raw, components),
|
|
135
164
|
releaseAssets: parseReleaseAssets(raw),
|
|
136
165
|
support: objectList(raw, 'support').map((entry, index) => parseSupport(entry, `gjsify.site.support[${index}]`)),
|
|
137
166
|
legal: objectList(raw, 'legal').map((entry, index) => ({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DownloadTarget, ReleaseAsset, SiteDownloadConfig, TargetArch, TargetFormat, TargetPlatform } from './types.js';
|
|
1
|
+
import type { DownloadTarget, ReleaseAsset, SiteComponent, SiteDownloadConfig, TargetArch, TargetFormat, TargetPlatform } from './types.js';
|
|
2
2
|
/** Browser extensions are listed after the OS builds; a page usually has only one kind. */
|
|
3
3
|
export declare const PLATFORM_ORDER: readonly TargetPlatform[];
|
|
4
4
|
/** Platforms that are browsers: their targets are picked by the visitor's browser, not OS. */
|
|
@@ -14,6 +14,13 @@ export declare function classifyAsset(asset: ReleaseAsset): {
|
|
|
14
14
|
format: TargetFormat;
|
|
15
15
|
arch: TargetArch;
|
|
16
16
|
} | null;
|
|
17
|
+
/**
|
|
18
|
+
* Which part of the product a release asset installs. The first component whose `match` accepts
|
|
19
|
+
* the file name wins, so declaration order is the tie-breaker; a component without a `match` is
|
|
20
|
+
* the catch-all for everything the earlier ones left. Undefined when no component claims the file,
|
|
21
|
+
* which is also the single-component case where the dialog shows no headings at all.
|
|
22
|
+
*/
|
|
23
|
+
export declare function componentOf(name: string, components: readonly SiteComponent[]): string | undefined;
|
|
17
24
|
export interface DownloadSources {
|
|
18
25
|
assets: readonly ReleaseAsset[];
|
|
19
26
|
/** Flathub application id, when the app is published there. */
|
|
@@ -24,6 +31,12 @@ export interface DownloadSources {
|
|
|
24
31
|
webAppUrl?: string;
|
|
25
32
|
/** `gjsify.site.downloads`: targets no release asset supplies, e.g. a store page. */
|
|
26
33
|
configured?: readonly SiteDownloadConfig[];
|
|
34
|
+
/** `gjsify.site.components`, in install order; empty when the product is one thing to download. */
|
|
35
|
+
components?: readonly SiteComponent[];
|
|
27
36
|
}
|
|
28
|
-
/**
|
|
37
|
+
/**
|
|
38
|
+
* Builds the sorted target list and marks the first available target of every platform as
|
|
39
|
+
* recommended — once per component, because a person installs each part of the product in turn and
|
|
40
|
+
* every part needs its own first choice.
|
|
41
|
+
*/
|
|
29
42
|
export declare function buildDownloadTargets(sources: DownloadSources): DownloadTarget[];
|
|
@@ -118,6 +118,15 @@ function rank(order, value) {
|
|
|
118
118
|
const index = order.indexOf(value);
|
|
119
119
|
return index === -1 ? order.length : index;
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* Which part of the product a release asset installs. The first component whose `match` accepts
|
|
123
|
+
* the file name wins, so declaration order is the tie-breaker; a component without a `match` is
|
|
124
|
+
* the catch-all for everything the earlier ones left. Undefined when no component claims the file,
|
|
125
|
+
* which is also the single-component case where the dialog shows no headings at all.
|
|
126
|
+
*/
|
|
127
|
+
export function componentOf(name, components) {
|
|
128
|
+
return components.find((part) => part.match === undefined || part.match.test(name))?.id;
|
|
129
|
+
}
|
|
121
130
|
/**
|
|
122
131
|
* What the store install costs to download. Flathub's own number is the honest one; the Flatpak
|
|
123
132
|
* bundle of the same release is close enough to stand in when the API did not answer.
|
|
@@ -128,14 +137,24 @@ function storeSize(sources) {
|
|
|
128
137
|
const bundle = sources.assets.find((asset) => classifyAsset(asset)?.format === 'flatpak');
|
|
129
138
|
return bundle?.size;
|
|
130
139
|
}
|
|
131
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Builds the sorted target list and marks the first available target of every platform as
|
|
142
|
+
* recommended — once per component, because a person installs each part of the product in turn and
|
|
143
|
+
* every part needs its own first choice.
|
|
144
|
+
*/
|
|
132
145
|
export function buildDownloadTargets(sources) {
|
|
133
146
|
const targets = [];
|
|
147
|
+
const components = sources.components ?? [];
|
|
148
|
+
const order = components.map((part) => part.id);
|
|
149
|
+
// A store install and the live web build have no file name to match on; they are the product
|
|
150
|
+
// itself, which is the first component in install order.
|
|
151
|
+
const primaryComponent = components[0]?.id;
|
|
134
152
|
if (sources.flathub) {
|
|
135
153
|
targets.push({
|
|
136
154
|
os: 'linux',
|
|
137
155
|
arch: 'noarch',
|
|
138
156
|
format: 'flathub',
|
|
157
|
+
component: primaryComponent,
|
|
139
158
|
label: defaultLabel('linux', 'flathub'),
|
|
140
159
|
url: `https://flathub.org/apps/${sources.flathub}`,
|
|
141
160
|
available: true,
|
|
@@ -149,6 +168,7 @@ export function buildDownloadTargets(sources) {
|
|
|
149
168
|
continue;
|
|
150
169
|
targets.push({
|
|
151
170
|
...kind,
|
|
171
|
+
component: componentOf(asset.name, components),
|
|
152
172
|
label: defaultLabel(kind.os, kind.format),
|
|
153
173
|
url: asset.downloadUrl,
|
|
154
174
|
available: true,
|
|
@@ -162,6 +182,7 @@ export function buildDownloadTargets(sources) {
|
|
|
162
182
|
os: 'web',
|
|
163
183
|
arch: 'noarch',
|
|
164
184
|
format: 'web',
|
|
185
|
+
component: primaryComponent,
|
|
165
186
|
label: defaultLabel('web', 'web'),
|
|
166
187
|
url: sources.webAppUrl,
|
|
167
188
|
available: true,
|
|
@@ -173,20 +194,23 @@ export function buildDownloadTargets(sources) {
|
|
|
173
194
|
os: entry.platform,
|
|
174
195
|
arch: 'noarch',
|
|
175
196
|
format: entry.format,
|
|
197
|
+
component: entry.component,
|
|
176
198
|
label: entry.label ?? defaultLabel(entry.platform, entry.format),
|
|
177
199
|
url: entry.available ? (entry.url ?? '') : '',
|
|
178
200
|
available: entry.available,
|
|
179
201
|
recommended: false,
|
|
180
202
|
});
|
|
181
203
|
}
|
|
182
|
-
targets.sort((a, b) => rank(
|
|
204
|
+
targets.sort((a, b) => rank(order, a.component ?? '') - rank(order, b.component ?? '') ||
|
|
205
|
+
rank(PLATFORM_ORDER, a.os) - rank(PLATFORM_ORDER, b.os) ||
|
|
183
206
|
rank(FORMAT_RANK[a.os], a.format) - rank(FORMAT_RANK[b.os], b.format) ||
|
|
184
207
|
rank(ARCH_RANK[a.os], a.arch) - rank(ARCH_RANK[b.os], b.arch));
|
|
185
208
|
const seen = new Set();
|
|
186
209
|
for (const target of targets) {
|
|
187
|
-
|
|
210
|
+
const key = `${target.component ?? ''}:${target.os}`;
|
|
211
|
+
if (!target.available || seen.has(key))
|
|
188
212
|
continue;
|
|
189
|
-
seen.add(
|
|
213
|
+
seen.add(key);
|
|
190
214
|
target.recommended = true;
|
|
191
215
|
}
|
|
192
216
|
return targets;
|
package/lib/site-data/load.js
CHANGED
|
@@ -151,6 +151,7 @@ export async function loadSiteData(options) {
|
|
|
151
151
|
targets: buildDownloadTargets({
|
|
152
152
|
assets: config.releaseAssets ? (latestRelease?.assets ?? []) : [],
|
|
153
153
|
configured: config.downloads,
|
|
154
|
+
components: config.components,
|
|
154
155
|
flathub: config.flathub,
|
|
155
156
|
flathubSize,
|
|
156
157
|
webAppUrl: config.webApp ? `${config.base}${config.webApp.replace(/^\//, '')}` : undefined,
|
package/lib/site-data/types.d.ts
CHANGED
|
@@ -41,6 +41,25 @@ export interface SiteLegalLink {
|
|
|
41
41
|
label: string;
|
|
42
42
|
url: string;
|
|
43
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* One part of the product a person installs separately — a desktop app, the browser extension it
|
|
46
|
+
* pairs with, a command line build. Declared in `gjsify.site.components`, in the order they are
|
|
47
|
+
* installed, which is the order the download dialog explains them in.
|
|
48
|
+
*/
|
|
49
|
+
export interface SiteComponent {
|
|
50
|
+
/** Referenced by `gjsify.site.downloads[].component`; also the `data-component` attribute. */
|
|
51
|
+
id: string;
|
|
52
|
+
/** English heading, translated through the website catalogue. */
|
|
53
|
+
name: string;
|
|
54
|
+
/** One sentence saying what this part is for and why it is needed. */
|
|
55
|
+
summary?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Release assets whose file name matches belong to this component. Anchor it, because the
|
|
58
|
+
* first matching component in declaration order wins; a component without one takes whatever
|
|
59
|
+
* is left, so the catch-all goes last.
|
|
60
|
+
*/
|
|
61
|
+
match?: RegExp;
|
|
62
|
+
}
|
|
44
63
|
/** One entry of `gjsify.site.downloads`: a target the release assets cannot supply (a store page, the install docs). */
|
|
45
64
|
export interface SiteDownloadConfig {
|
|
46
65
|
platform: TargetPlatform;
|
|
@@ -51,6 +70,8 @@ export interface SiteDownloadConfig {
|
|
|
51
70
|
label?: string;
|
|
52
71
|
/** False lists the target as "not yet available" without a link. Default true. */
|
|
53
72
|
available: boolean;
|
|
73
|
+
/** Id of the `gjsify.site.components` entry this target installs. */
|
|
74
|
+
component?: string;
|
|
54
75
|
}
|
|
55
76
|
/** The `gjsify.site` block of the root package.json, with defaults applied. */
|
|
56
77
|
export interface SiteConfig {
|
|
@@ -75,6 +96,11 @@ export interface SiteConfig {
|
|
|
75
96
|
pageTranslations?: string;
|
|
76
97
|
/** Repo-relative app icon; defaults to the Flatpak or `ship` icon of the root package.json. */
|
|
77
98
|
icon?: string;
|
|
99
|
+
/**
|
|
100
|
+
* The separately installed parts of the product, in install order. Empty for the common case of
|
|
101
|
+
* one thing to download, which the dialog then lists without any component heading.
|
|
102
|
+
*/
|
|
103
|
+
components: SiteComponent[];
|
|
78
104
|
/** Extra download targets, added to the ones derived from the latest release. */
|
|
79
105
|
downloads: SiteDownloadConfig[];
|
|
80
106
|
/** False ignores the latest release's assets, so `downloads` is the whole list. Default true. */
|
|
@@ -107,6 +133,8 @@ export interface DownloadTarget {
|
|
|
107
133
|
os: TargetPlatform;
|
|
108
134
|
arch: TargetArch;
|
|
109
135
|
format: TargetFormat;
|
|
136
|
+
/** Id of the `SiteComponent` this target installs; empty when the product has only one part. */
|
|
137
|
+
component?: string;
|
|
110
138
|
/** English label; the page translates it through the website catalogue. */
|
|
111
139
|
label: string;
|
|
112
140
|
/** Empty when the target is not available yet. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/site",
|
|
3
|
-
"version": "0.64.1-next.
|
|
3
|
+
"version": "0.64.1-next.248",
|
|
4
4
|
"description": "Static landing page generator for a GNOME-style app, rendered from its AppStream metainfo — downloads, screenshots, release notes, support links, no JavaScript required.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -54,26 +54,26 @@
|
|
|
54
54
|
"license": "MIT",
|
|
55
55
|
"dependencies": {
|
|
56
56
|
"@girs/gdkpixbuf-2.0": "5.4.0",
|
|
57
|
-
"@gjsify/console": "0.64.1-next.
|
|
58
|
-
"@gjsify/crypto": "0.64.1-next.
|
|
59
|
-
"@gjsify/fs": "0.64.1-next.
|
|
60
|
-
"@gjsify/module": "0.64.1-next.
|
|
61
|
-
"@gjsify/os": "0.64.1-next.
|
|
62
|
-
"@gjsify/path": "0.64.1-next.
|
|
63
|
-
"@gjsify/process": "0.64.1-next.
|
|
64
|
-
"@gjsify/url": "0.64.1-next.
|
|
57
|
+
"@gjsify/console": "0.64.1-next.248",
|
|
58
|
+
"@gjsify/crypto": "0.64.1-next.248",
|
|
59
|
+
"@gjsify/fs": "0.64.1-next.248",
|
|
60
|
+
"@gjsify/module": "0.64.1-next.248",
|
|
61
|
+
"@gjsify/os": "0.64.1-next.248",
|
|
62
|
+
"@gjsify/path": "0.64.1-next.248",
|
|
63
|
+
"@gjsify/process": "0.64.1-next.248",
|
|
64
|
+
"@gjsify/url": "0.64.1-next.248",
|
|
65
65
|
"fast-xml-parser": "^5.11.2",
|
|
66
66
|
"qrcode-generator": "^2.0.4"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@gjsify/cli": "0.64.1-next.
|
|
69
|
+
"@gjsify/cli": "0.64.1-next.248",
|
|
70
70
|
"@gjsify/node-gi": "file:../../node-gi/node-gi",
|
|
71
|
-
"@gjsify/unit": "0.64.1-next.
|
|
71
|
+
"@gjsify/unit": "0.64.1-next.248",
|
|
72
72
|
"@types/node": "^25.9.2",
|
|
73
73
|
"typescript": "^6.0.3"
|
|
74
74
|
},
|
|
75
75
|
"gjsify": {
|
|
76
76
|
"tier": 2
|
|
77
77
|
},
|
|
78
|
-
"gitHead": "
|
|
78
|
+
"gitHead": "4f1f2d4d5330581f496a627fa33133d2769af1a2"
|
|
79
79
|
}
|
package/po/de.po
CHANGED
|
@@ -111,18 +111,21 @@ msgstr "Empfohlen"
|
|
|
111
111
|
msgid "Not yet available"
|
|
112
112
|
msgstr "Noch nicht verfügbar"
|
|
113
113
|
|
|
114
|
-
msgid "All releases"
|
|
115
|
-
msgstr "Alle Versionen"
|
|
116
|
-
|
|
117
114
|
msgid "Apple Silicon is assumed. Pick the Intel build if your Mac has an Intel processor."
|
|
118
115
|
msgstr "Apple Silicon wird angenommen. Für Macs mit Intel-Prozessor die Intel-Variante wählen."
|
|
119
116
|
|
|
120
|
-
msgid "
|
|
121
|
-
msgstr "
|
|
117
|
+
msgid "Browser"
|
|
118
|
+
msgstr "Browser"
|
|
122
119
|
|
|
123
120
|
msgid "Operating system"
|
|
124
121
|
msgstr "Betriebssystem"
|
|
125
122
|
|
|
123
|
+
msgid "All releases"
|
|
124
|
+
msgstr "Alle Versionen"
|
|
125
|
+
|
|
126
|
+
msgid "All download options"
|
|
127
|
+
msgstr "Alle Download-Optionen"
|
|
128
|
+
|
|
126
129
|
msgid "All releases on GitHub"
|
|
127
130
|
msgstr "Alle Versionen auf GitHub"
|
|
128
131
|
|
package/po/site.pot
CHANGED
|
@@ -110,18 +110,21 @@ msgstr ""
|
|
|
110
110
|
msgid "Not yet available"
|
|
111
111
|
msgstr ""
|
|
112
112
|
|
|
113
|
-
msgid "All releases"
|
|
114
|
-
msgstr ""
|
|
115
|
-
|
|
116
113
|
msgid "Apple Silicon is assumed. Pick the Intel build if your Mac has an Intel processor."
|
|
117
114
|
msgstr ""
|
|
118
115
|
|
|
119
|
-
msgid "
|
|
116
|
+
msgid "Browser"
|
|
120
117
|
msgstr ""
|
|
121
118
|
|
|
122
119
|
msgid "Operating system"
|
|
123
120
|
msgstr ""
|
|
124
121
|
|
|
122
|
+
msgid "All releases"
|
|
123
|
+
msgstr ""
|
|
124
|
+
|
|
125
|
+
msgid "All download options"
|
|
126
|
+
msgstr ""
|
|
127
|
+
|
|
125
128
|
msgid "All releases on GitHub"
|
|
126
129
|
msgstr ""
|
|
127
130
|
|