@gjsify/site 0.0.0-stage → 0.62.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 +165 -2
- package/lib/cli.d.ts +6 -0
- package/lib/cli.js +91 -0
- package/lib/client/site.css +1286 -0
- package/lib/client/site.js +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +9 -0
- package/lib/render/color.d.ts +12 -0
- package/lib/render/color.js +45 -0
- package/lib/render/html.d.ts +8 -0
- package/lib/render/html.js +20 -0
- package/lib/render/i18n.d.ts +38 -0
- package/lib/render/i18n.js +106 -0
- package/lib/render/icons.d.ts +14 -0
- package/lib/render/icons.js +42 -0
- package/lib/render/image-backend.d.ts +26 -0
- package/lib/render/image-backend.js +130 -0
- package/lib/render/images.d.ts +51 -0
- package/lib/render/images.js +97 -0
- package/lib/render/pages.d.ts +32 -0
- package/lib/render/pages.js +628 -0
- package/lib/render/qr.d.ts +8 -0
- package/lib/render/qr.js +25 -0
- package/lib/site-data/config.d.ts +23 -0
- package/lib/site-data/config.js +142 -0
- package/lib/site-data/downloads.d.ts +29 -0
- package/lib/site-data/downloads.js +193 -0
- package/lib/site-data/flathub.d.ts +24 -0
- package/lib/site-data/flathub.js +35 -0
- package/lib/site-data/github.d.ts +9 -0
- package/lib/site-data/github.js +45 -0
- package/lib/site-data/index.d.ts +12 -0
- package/lib/site-data/index.js +12 -0
- package/lib/site-data/load.d.ts +18 -0
- package/lib/site-data/load.js +164 -0
- package/lib/site-data/metainfo.d.ts +9 -0
- package/lib/site-data/metainfo.js +142 -0
- package/lib/site-data/network.d.ts +32 -0
- package/lib/site-data/network.js +67 -0
- package/lib/site-data/oars.d.ts +15 -0
- package/lib/site-data/oars.js +62 -0
- package/lib/site-data/po.d.ts +40 -0
- package/lib/site-data/po.js +148 -0
- package/lib/site-data/screenshots.d.ts +33 -0
- package/lib/site-data/screenshots.js +90 -0
- package/lib/site-data/sepa.d.ts +20 -0
- package/lib/site-data/sepa.js +45 -0
- package/lib/site-data/types.d.ts +224 -0
- package/lib/site-data/types.js +1 -0
- package/lib/site-data/xml.d.ts +11 -0
- package/lib/site-data/xml.js +51 -0
- package/lib/site.d.ts +49 -0
- package/lib/site.js +161 -0
- package/package.json +77 -5
- package/po/de.po +265 -0
- package/po/site.pot +264 -0
|
@@ -0,0 +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);
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './render/html.js';
|
|
2
|
+
export * from './cli.js';
|
|
3
|
+
export * from './render/i18n.js';
|
|
4
|
+
export * from './render/icons.js';
|
|
5
|
+
export * from './render/images.js';
|
|
6
|
+
export * from './render/pages.js';
|
|
7
|
+
export * from './render/qr.js';
|
|
8
|
+
export * from './site-data/index.js';
|
|
9
|
+
export * from './site.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from './render/html.js';
|
|
2
|
+
export * from './cli.js';
|
|
3
|
+
export * from './render/i18n.js';
|
|
4
|
+
export * from './render/icons.js';
|
|
5
|
+
export * from './render/images.js';
|
|
6
|
+
export * from './render/pages.js';
|
|
7
|
+
export * from './render/qr.js';
|
|
8
|
+
export * from './site-data/index.js';
|
|
9
|
+
export * from './site.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa` as 0–255 channels; alpha is ignored, the band is opaque. */
|
|
2
|
+
export declare function parseHex(hex: string): [number, number, number] | undefined;
|
|
3
|
+
/** WCAG 2 relative luminance, 0 (black) to 1 (white). */
|
|
4
|
+
export declare function relativeLuminance(rgb: readonly [number, number, number]): number;
|
|
5
|
+
/** WCAG 2 contrast ratio, 1 (identical) to 21 (black on white). */
|
|
6
|
+
export declare function contrastRatio(a: number, b: number): number;
|
|
7
|
+
/**
|
|
8
|
+
* The text colour to put on `background` — whichever of near-black and white the eye can read
|
|
9
|
+
* better. An unparseable colour answers white, which is what the stylesheet assumed before any of
|
|
10
|
+
* this was measured.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readableText(background: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The app's `<branding>` colour paints the masthead, so the text on it has to be chosen for that
|
|
2
|
+
// colour rather than hardcoded. CSS cannot measure a colour — `color-contrast()` is not shipped
|
|
3
|
+
// anywhere this page targets — so the generator measures it and writes the answer into a custom
|
|
4
|
+
// property.
|
|
5
|
+
/** `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa` as 0–255 channels; alpha is ignored, the band is opaque. */
|
|
6
|
+
export function parseHex(hex) {
|
|
7
|
+
const digits = hex.replace(/^#/, '');
|
|
8
|
+
const short = digits.length === 3 || digits.length === 4;
|
|
9
|
+
if (!short && digits.length !== 6 && digits.length !== 8)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (!/^[0-9a-f]+$/i.test(digits))
|
|
12
|
+
return undefined;
|
|
13
|
+
const channel = (index) => short ? parseInt(digits[index].repeat(2), 16) : parseInt(digits.slice(index * 2, index * 2 + 2), 16);
|
|
14
|
+
return [channel(0), channel(1), channel(2)];
|
|
15
|
+
}
|
|
16
|
+
/** WCAG 2 relative luminance, 0 (black) to 1 (white). */
|
|
17
|
+
export function relativeLuminance(rgb) {
|
|
18
|
+
const [r, g, b] = rgb.map((value) => {
|
|
19
|
+
const channel = value / 255;
|
|
20
|
+
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
21
|
+
});
|
|
22
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
23
|
+
}
|
|
24
|
+
/** WCAG 2 contrast ratio, 1 (identical) to 21 (black on white). */
|
|
25
|
+
export function contrastRatio(a, b) {
|
|
26
|
+
const [light, dark] = a >= b ? [a, b] : [b, a];
|
|
27
|
+
return (light + 0.05) / (dark + 0.05);
|
|
28
|
+
}
|
|
29
|
+
/** Near-black rather than black: a flat #000 on a saturated colour reads as a hole. */
|
|
30
|
+
const INK = '#10233b';
|
|
31
|
+
const PAPER = '#ffffff';
|
|
32
|
+
/**
|
|
33
|
+
* The text colour to put on `background` — whichever of near-black and white the eye can read
|
|
34
|
+
* better. An unparseable colour answers white, which is what the stylesheet assumed before any of
|
|
35
|
+
* this was measured.
|
|
36
|
+
*/
|
|
37
|
+
export function readableText(background) {
|
|
38
|
+
const rgb = parseHex(background);
|
|
39
|
+
if (!rgb)
|
|
40
|
+
return PAPER;
|
|
41
|
+
const luminance = relativeLuminance(rgb);
|
|
42
|
+
const onInk = contrastRatio(luminance, relativeLuminance(parseHex(INK)));
|
|
43
|
+
const onPaper = contrastRatio(luminance, relativeLuminance(parseHex(PAPER)));
|
|
44
|
+
return onInk >= onPaper ? INK : PAPER;
|
|
45
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Escapes text for element content and double-quoted attribute values. */
|
|
2
|
+
export declare function esc(value: string | number): string;
|
|
3
|
+
/** Replaces `{name}` placeholders in a translated string; values are escaped, the template is trusted. */
|
|
4
|
+
export declare function fmt(template: string, values: Record<string, string | number>): string;
|
|
5
|
+
/** Renders attributes, dropping the `undefined`/`false` ones. */
|
|
6
|
+
export declare function attrs(values: Record<string, string | number | boolean | undefined>): string;
|
|
7
|
+
/** Like `fmt`, for values that are escaped later (attribute values, `esc`-wrapped text). */
|
|
8
|
+
export declare function plain(template: string, values: Record<string, string | number>): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const ENTITIES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
2
|
+
/** Escapes text for element content and double-quoted attribute values. */
|
|
3
|
+
export function esc(value) {
|
|
4
|
+
return String(value).replace(/[&<>"']/g, (char) => ENTITIES[char] ?? char);
|
|
5
|
+
}
|
|
6
|
+
/** Replaces `{name}` placeholders in a translated string; values are escaped, the template is trusted. */
|
|
7
|
+
export function fmt(template, values) {
|
|
8
|
+
return esc(template).replace(/\{(\w+)\}/g, (_match, key) => key in values ? esc(values[key]) : `{${key}}`);
|
|
9
|
+
}
|
|
10
|
+
/** Renders attributes, dropping the `undefined`/`false` ones. */
|
|
11
|
+
export function attrs(values) {
|
|
12
|
+
return Object.entries(values)
|
|
13
|
+
.filter(([, value]) => value !== undefined && value !== false)
|
|
14
|
+
.map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${esc(value)}"`))
|
|
15
|
+
.join('');
|
|
16
|
+
}
|
|
17
|
+
/** Like `fmt`, for values that are escaped later (attribute values, `esc`-wrapped text). */
|
|
18
|
+
export function plain(template, values) {
|
|
19
|
+
return template.replace(/\{(\w+)\}/g, (_match, key) => (key in values ? String(values[key]) : `{${key}}`));
|
|
20
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Catalog, type SiteConfig } from '../site-data/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* The msgids the renderer itself can emit: every translator call in its sources, plus the default
|
|
4
|
+
* download labels. They belong to this package and are translated in its own `po/`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function rendererMsgids(renderDir: string): Promise<string[]>;
|
|
7
|
+
/**
|
|
8
|
+
* The msgids a configuration introduces: the download labels its author wrote. They belong to the
|
|
9
|
+
* consumer and are translated in the consumer's `pageTranslations`. Screenshot captions are not
|
|
10
|
+
* among them — they live in the metainfo and are translated there.
|
|
11
|
+
*/
|
|
12
|
+
export declare function configMsgids(config: SiteConfig): string[];
|
|
13
|
+
/** Every msgid in a `.po`/`.pot` file, translated or not, header excluded. */
|
|
14
|
+
export declare function catalogMsgids(file: string): Promise<Set<string>>;
|
|
15
|
+
/** Writes a catalogue: the translations in `known` are kept, everything else is left empty. */
|
|
16
|
+
export declare function renderPo(msgids: readonly string[], language: string | undefined, known?: Catalog): string;
|
|
17
|
+
export declare function poPath(poDir: string, language: string): string;
|
|
18
|
+
export interface I18nResult {
|
|
19
|
+
/** Files whose content differs from what the build would write (written unless `check`). */
|
|
20
|
+
stale: string[];
|
|
21
|
+
/** Languages with msgids that still lack a translation. */
|
|
22
|
+
untranslated: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface SyncOptions {
|
|
25
|
+
/** Directory the `<lang>.po` catalogues live in; created by the caller. */
|
|
26
|
+
dir: string;
|
|
27
|
+
/** The languages to keep a catalogue for; `en` is the msgid language and gets none. */
|
|
28
|
+
languages: readonly string[];
|
|
29
|
+
msgids: readonly string[];
|
|
30
|
+
/** Report what would change instead of writing it. */
|
|
31
|
+
check: boolean;
|
|
32
|
+
/** Write a `site.pot` template next to the catalogues. An overlay has no template of its own. */
|
|
33
|
+
pot?: boolean;
|
|
34
|
+
/** The paths in the result are reported relative to this directory. */
|
|
35
|
+
base: string;
|
|
36
|
+
}
|
|
37
|
+
/** Regenerates the catalogues of one directory, or with `check` only reports what would change. */
|
|
38
|
+
export declare function syncCatalogs(options: SyncOptions): Promise<I18nResult>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
|
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';
|
|
5
|
+
// EITHER quote style, because the formatter picks one and this is not its decision. The renderers
|
|
6
|
+
// were written with double quotes, and the first `gjsify format` that could see them — .gitignore
|
|
7
|
+
// had hidden the directory — rewrote all 66 translator calls to single quotes; a double-quote-only
|
|
8
|
+
// pattern then extracted nothing and the sync offered to delete every translated string in `po/`.
|
|
9
|
+
// Hence also the floor below: an extractor that matches nothing must fail, never quietly empty the
|
|
10
|
+
// catalogues. Note that this pattern reads THIS file too, so no prose here spells out a call.
|
|
11
|
+
const T_CALL = /\bt\(\s*(['"])((?:\\.|(?!\1)[^\\])*)\1\s*\)/g;
|
|
12
|
+
/** Decodes a JS string literal body, whichever quote style it was written in. */
|
|
13
|
+
function unquote(body, quote) {
|
|
14
|
+
const json = quote === '"' ? body : body.replace(/\\'/g, "'").replace(/"/g, '\\"');
|
|
15
|
+
return JSON.parse(`"${json}"`);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The msgids the renderer itself can emit: every translator call in its sources, plus the default
|
|
19
|
+
* download labels. They belong to this package and are translated in its own `po/`.
|
|
20
|
+
*/
|
|
21
|
+
export async function rendererMsgids(renderDir) {
|
|
22
|
+
const msgids = new Set();
|
|
23
|
+
let fromSources = 0;
|
|
24
|
+
for (const file of (await readdir(renderDir)).sort()) {
|
|
25
|
+
if (!file.endsWith('.ts') || file.endsWith('.spec.ts'))
|
|
26
|
+
continue;
|
|
27
|
+
const source = await readFile(join(renderDir, file), 'utf8');
|
|
28
|
+
for (const match of source.matchAll(T_CALL)) {
|
|
29
|
+
msgids.add(unquote(match[2], match[1]));
|
|
30
|
+
fromSources++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (fromSources === 0) {
|
|
34
|
+
throw new Error(`${renderDir}: no translator call found in any renderer — the extractor is broken, not the sources`);
|
|
35
|
+
}
|
|
36
|
+
for (const label of [...Object.values(DOWNLOAD_LABELS), ...Object.values(FORMAT_LABELS)])
|
|
37
|
+
msgids.add(label);
|
|
38
|
+
return [...msgids];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The msgids a configuration introduces: the download labels its author wrote. They belong to the
|
|
42
|
+
* consumer and are translated in the consumer's `pageTranslations`. Screenshot captions are not
|
|
43
|
+
* among them — they live in the metainfo and are translated there.
|
|
44
|
+
*/
|
|
45
|
+
export function configMsgids(config) {
|
|
46
|
+
const msgids = new Set();
|
|
47
|
+
for (const download of config.downloads)
|
|
48
|
+
if (download.label)
|
|
49
|
+
msgids.add(download.label);
|
|
50
|
+
return [...msgids];
|
|
51
|
+
}
|
|
52
|
+
/** Every msgid in a `.po`/`.pot` file, translated or not, header excluded. */
|
|
53
|
+
export async function catalogMsgids(file) {
|
|
54
|
+
if (!existsSync(file))
|
|
55
|
+
return new Set();
|
|
56
|
+
const entries = parsePo(await readFile(file, 'utf8'));
|
|
57
|
+
return new Set(entries.filter((entry) => entry.msgid !== '').map((entry) => entry.msgid));
|
|
58
|
+
}
|
|
59
|
+
const HEADER = (language) => [
|
|
60
|
+
'msgid ""',
|
|
61
|
+
'msgstr ""',
|
|
62
|
+
'"Project-Id-Version: gjsify-site\\n"',
|
|
63
|
+
...(language ? [`"Language: ${language}\\n"`] : []),
|
|
64
|
+
'"MIME-Version: 1.0\\n"',
|
|
65
|
+
'"Content-Type: text/plain; charset=UTF-8\\n"',
|
|
66
|
+
'"Content-Transfer-Encoding: 8bit\\n"',
|
|
67
|
+
].join('\n');
|
|
68
|
+
/** Writes a catalogue: the translations in `known` are kept, everything else is left empty. */
|
|
69
|
+
export function renderPo(msgids, language, known = new Map()) {
|
|
70
|
+
const entries = msgids.map((msgid) => `msgid ${quotePo(msgid)}\nmsgstr ${quotePo(known.get(msgid) ?? '')}`);
|
|
71
|
+
return `${[HEADER(language), ...entries].join('\n\n')}\n`;
|
|
72
|
+
}
|
|
73
|
+
export function poPath(poDir, language) {
|
|
74
|
+
const existing = languageCandidates(language)
|
|
75
|
+
.map((candidate) => join(poDir, `${candidate}.po`))
|
|
76
|
+
.find((file) => existsSync(file));
|
|
77
|
+
return existing ?? join(poDir, `${language.replace(/-/g, '_')}.po`);
|
|
78
|
+
}
|
|
79
|
+
/** Regenerates the catalogues of one directory, or with `check` only reports what would change. */
|
|
80
|
+
export async function syncCatalogs(options) {
|
|
81
|
+
const { dir, languages, msgids, check, base } = options;
|
|
82
|
+
const stale = [];
|
|
83
|
+
const untranslated = [];
|
|
84
|
+
const write = async (file, content) => {
|
|
85
|
+
const current = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
86
|
+
if (current === content)
|
|
87
|
+
return;
|
|
88
|
+
stale.push(relative(base, file));
|
|
89
|
+
if (!check)
|
|
90
|
+
await writeFile(file, content);
|
|
91
|
+
};
|
|
92
|
+
if (options.pot)
|
|
93
|
+
await write(join(dir, 'site.pot'), renderPo(msgids, undefined));
|
|
94
|
+
for (const language of languages) {
|
|
95
|
+
if (language === UI_SOURCE_LANGUAGE)
|
|
96
|
+
continue;
|
|
97
|
+
const file = poPath(dir, language);
|
|
98
|
+
const known = existsSync(file) ? parseCatalog(await readFile(file, 'utf8')) : new Map();
|
|
99
|
+
await write(file, renderPo(msgids, language, known));
|
|
100
|
+
const missing = msgids.filter((msgid) => !known.get(msgid));
|
|
101
|
+
if (missing.length > 0) {
|
|
102
|
+
untranslated.push(`${relative(base, file)} has ${missing.length} untranslated string(s), e.g. "${missing[0]}"`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { stale, untranslated };
|
|
106
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page needs about twenty small glyphs. They are drawn here instead of pulled from an icon
|
|
3
|
+
* theme: inlining them costs no request and no licence notice, and a symbolic 16×16 grid is
|
|
4
|
+
* little enough geometry to keep in one file. Every path uses `currentColor` and is sized by CSS.
|
|
5
|
+
*/
|
|
6
|
+
export type IconName = 'linux' | 'macos' | 'windows' | 'android' | 'web' | 'firefox' | 'chromium' | 'package' | 'download' | 'license' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'close' | 'copy' | 'expand' | 'external' | 'code' | 'bug' | 'heart' | 'help' | 'mail' | 'people' | 'translate';
|
|
7
|
+
export interface IconOptions {
|
|
8
|
+
/** Added to the `<svg>`, on top of the shared `icon` class. */
|
|
9
|
+
class?: string;
|
|
10
|
+
/** Rendered as the accessible name; without it the icon is `aria-hidden`. */
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Renders one icon as an inline `<svg>`; decorative by default. */
|
|
14
|
+
export declare function icon(name: IconName, options?: IconOptions): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page needs about twenty small glyphs. They are drawn here instead of pulled from an icon
|
|
3
|
+
* theme: inlining them costs no request and no licence notice, and a symbolic 16×16 grid is
|
|
4
|
+
* little enough geometry to keep in one file. Every path uses `currentColor` and is sized by CSS.
|
|
5
|
+
*/
|
|
6
|
+
/** Shared attributes of the outline icons, so the strokes stay optically equal. */
|
|
7
|
+
const STROKE = 'fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"';
|
|
8
|
+
const PATHS = {
|
|
9
|
+
// Platform marks are filled silhouettes: at 18px an outline turns to mush.
|
|
10
|
+
linux: `<path fill-rule="evenodd" d="M8 1.1c-1.7 0-3.1 1.4-3.1 3.1v1.2c0 .5-.2.9-.5 1.3-1.2 1.3-1.8 2.9-1.8 4.7 0 .6-.2 1.1-.6 1.5-.6.7-.3 1.7.5 2 1 .4 2.2.7 3.3.8.2 0 .3 0 .5-.1.5.1 1.1.1 1.7.1s1.2 0 1.7-.1c.2.1.3.1.5.1 1.1-.1 2.3-.4 3.3-.8.8-.3 1.1-1.3.5-2-.4-.4-.6-.9-.6-1.5 0-1.8-.6-3.4-1.8-4.7-.3-.4-.5-.8-.5-1.3V4.2c0-1.7-1.4-3.1-3.1-3.1Zm-1.2 2.2a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4Zm2.4 0a.7.7 0 1 1 0 1.4.7.7 0 0 1 0-1.4ZM8 5.1l1 .9-1 .9-1-.9 1-.9Z"/>`,
|
|
11
|
+
macos: `<path d="M11.2 8.5c0-1.5 1.2-2.2 1.3-2.3-.7-1-1.8-1.1-2.1-1.1-.9-.1-1.7.5-2.2.5-.4 0-1.1-.5-1.9-.5-1 0-1.9.6-2.4 1.5-1 1.7-.3 4.3.7 5.7.5.7 1.1 1.5 1.8 1.4.7 0 1-.4 1.8-.4.9 0 1.1.4 1.8.4.8 0 1.2-.7 1.7-1.4.6-.8.8-1.5.8-1.6 0 0-1.3-.5-1.3-2.2Z"/><path d="M9.6 3.7c.4-.5.7-1.1.6-1.8-.6 0-1.3.4-1.7.9-.4.4-.7 1.1-.6 1.7.7.1 1.3-.3 1.7-.8Z"/>`,
|
|
12
|
+
windows: `<path d="M1.6 2.9 7.1 2.1v5.2H1.6V2.9Zm6.5-.9L14.4 1v6.3H8.1V2Zm-6.5 6.7h5.5V14L1.6 13.2V8.7Zm6.5 0h6.3V15L8.1 14.1V8.7Z"/>`,
|
|
13
|
+
android: `<path ${STROKE} d="m4.9 3 -.8-1.3M11.1 3l.8-1.3"/><path fill-rule="evenodd" d="M3.9 5.8C4.1 3.7 5.8 2 8 2s3.9 1.7 4.1 3.8H3.9Zm2.4-1.9a.6.6 0 1 1 0 1.2.6.6 0 0 1 0-1.2Zm3.4 0a.6.6 0 1 1 0 1.2.6.6 0 0 1 0-1.2Z"/><path d="M3.9 6.8h8.2v5.4c0 .6-.5 1.1-1.1 1.1H5c-.6 0-1.1-.5-1.1-1.1V6.8Zm-2.9.9a.9.9 0 0 1 1.8 0v3.5a.9.9 0 0 1-1.8 0V7.7Zm12.3 0a.9.9 0 0 1 1.8 0v3.5a.9.9 0 0 1-1.8 0V7.7ZM5.1 13.6a.9.9 0 0 1 1.8 0v1.5a.9.9 0 0 1-1.8 0v-1.5Zm4 0a.9.9 0 0 1 1.8 0v1.5a.9.9 0 0 1-1.8 0v-1.5Z"/>`,
|
|
14
|
+
web: `<g ${STROKE}><circle cx="8" cy="8" r="6.3"/><path d="M1.7 8h12.6"/><path d="M8 1.7c1.7 1.7 2.6 3.9 2.6 6.3S9.7 12.6 8 14.3C6.3 12.6 5.4 10.4 5.4 8s.9-4.6 2.6-6.3Z"/></g>`,
|
|
15
|
+
firefox: `<g ${STROKE}><circle cx="8" cy="8" r="6.3"/><path d="M4.9 7.4a3.3 3.3 0 1 0 5.7-2.5c.2 1-.1 1.8-.9 2.4"/></g>`,
|
|
16
|
+
chromium: `<g ${STROKE}><circle cx="8" cy="8" r="6.3"/><circle cx="8" cy="8" r="2.4"/><path d="M8 5.6h5.7M5.9 9.2 3.1 14M10.1 9.2 7.3 14.1"/></g>`,
|
|
17
|
+
package: `<g ${STROKE}><path d="M8 1.6 14 4.5v7L8 14.4l-6-2.9v-7L8 1.6Z"/><path d="m2 4.5 6 2.9 6-2.9M8 7.4v7"/></g>`,
|
|
18
|
+
download: `<g ${STROKE}><path d="M8 1.9v7.6M4.8 6.4 8 9.6l3.2-3.2M2.4 13.3h11.2"/></g>`,
|
|
19
|
+
license: `<g ${STROKE}><path d="M4 1.8h5l3.2 3.2v9.2H4V1.8Z"/><path d="M8.8 1.8V5h3.2M6.2 8.2h3.6M6.2 10.8h3.6"/></g>`,
|
|
20
|
+
'chevron-down': `<path ${STROKE} d="m4.2 6.2 3.8 3.8 3.8-3.8"/>`,
|
|
21
|
+
'chevron-left': `<path ${STROKE} d="M9.8 3 4.8 8l5 5"/>`,
|
|
22
|
+
'chevron-right': `<path ${STROKE} d="m6.2 3 5 5-5 5"/>`,
|
|
23
|
+
close: `<path ${STROKE} d="m3.8 3.8 8.4 8.4M12.2 3.8 3.8 12.2"/>`,
|
|
24
|
+
copy: `<g ${STROKE}><rect x="5.4" y="5.4" width="8.2" height="8.2" rx="1.6"/><path d="M10.6 5.4V4c0-.9-.7-1.6-1.6-1.6H4c-.9 0-1.6.7-1.6 1.6v5c0 .9.7 1.6 1.6 1.6h1.4"/></g>`,
|
|
25
|
+
expand: `<path ${STROKE} d="M6 1.9H1.9V6M10 1.9h4.1V6M10 14.1h4.1V10M6 14.1H1.9V10"/>`,
|
|
26
|
+
external: `<g ${STROKE}><path d="M9.2 2.2h4.6v4.6M13.4 2.6 7.6 8.4"/><path d="M12 9.6v2.8c0 .8-.6 1.4-1.4 1.4H4c-.8 0-1.4-.6-1.4-1.4V5.8c0-.8.6-1.4 1.4-1.4h2.8"/></g>`,
|
|
27
|
+
code: `<path ${STROKE} d="M5.6 11.8 2 8l3.6-3.8M10.4 4.2 14 8l-3.6 3.8M9.4 2.6 6.6 13.4"/>`,
|
|
28
|
+
bug: `<g ${STROKE}><path d="M8 5.2c1.7 0 3 1.4 3 3v1.6c0 1.7-1.3 3-3 3s-3-1.3-3-3V8.2c0-1.6 1.3-3 3-3Z"/><path d="M6.3 4.2 5.5 2.6M9.7 4.2l.8-1.6M5 7.6H2.5M11 7.6h2.5M5 10.8H2.7M11 10.8h2.3M8 5.4v7.4"/></g>`,
|
|
29
|
+
heart: `<path ${STROKE} d="M8 13.6 3.1 8.5a3.2 3.2 0 1 1 4.5-4.5l.4.4.4-.4a3.2 3.2 0 1 1 4.5 4.5L8 13.6Z"/>`,
|
|
30
|
+
help: `<g ${STROKE}><circle cx="8" cy="8" r="6.3"/><path d="M6.2 6.2a1.8 1.8 0 1 1 2.7 1.7c-.6.3-.9.8-.9 1.4v.3"/></g><circle cx="8" cy="12" r=".9"/>`,
|
|
31
|
+
mail: `<g ${STROKE}><rect x="1.8" y="3.4" width="12.4" height="9.2" rx="1.6"/><path d="m2.4 4.6 5.6 4.2 5.6-4.2"/></g>`,
|
|
32
|
+
people: `<g ${STROKE}><circle cx="6.2" cy="5.2" r="2.6"/><path d="M1.6 13.8c0-2.5 2.1-4.2 4.6-4.2s4.6 1.7 4.6 4.2"/><path d="M10.6 3.1a2.6 2.6 0 0 1 0 5.1M12.1 9.9c1.4.5 2.3 1.7 2.3 3.3"/></g>`,
|
|
33
|
+
translate: `<g ${STROKE}><path d="M2 4.4c0-.9.7-1.6 1.6-1.6h5.1c.9 0 1.6.7 1.6 1.6v2.3c0 .9-.7 1.6-1.6 1.6H6L3.6 10.4V8.3h-.1c-.8 0-1.5-.7-1.5-1.6V4.4Z"/><path d="M12.3 6h.1c.9 0 1.6.7 1.6 1.6v2.3c0 .9-.7 1.6-1.6 1.6h-.1v2.1L9.9 11.5H8"/></g>`,
|
|
34
|
+
};
|
|
35
|
+
/** Renders one icon as an inline `<svg>`; decorative by default. */
|
|
36
|
+
export function icon(name, options = {}) {
|
|
37
|
+
const classes = options.class ? `icon ${options.class}` : 'icon';
|
|
38
|
+
const label = options.title
|
|
39
|
+
? ` role="img" aria-label="${options.title.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`)}"`
|
|
40
|
+
: ' aria-hidden="true"';
|
|
41
|
+
return `<svg class="${classes}" viewBox="0 0 16 16" width="16" height="16" fill="currentColor"${label}>${PATHS[name]}</svg>`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type ImageFormat = 'avif' | 'webp' | 'png';
|
|
2
|
+
/** A rectangle in pixels, as `opaqueBounds` reports it and `cropped` takes it. */
|
|
3
|
+
export interface Rect {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
}
|
|
9
|
+
/** A decoded raster, immutable: `resizedToWidth` and `cropped` answer a new one. */
|
|
10
|
+
export interface RasterImage {
|
|
11
|
+
readonly width: number;
|
|
12
|
+
readonly height: number;
|
|
13
|
+
/** 0–255; an image without an alpha channel answers 255 everywhere. */
|
|
14
|
+
alphaAt(x: number, y: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* The smallest rectangle holding every pixel whose alpha reaches `threshold`, or `null` when no
|
|
17
|
+
* pixel does. An image without an alpha channel answers its whole area.
|
|
18
|
+
*/
|
|
19
|
+
opaqueBounds(threshold: number): Rect | null;
|
|
20
|
+
cropped(rect: Rect): RasterImage;
|
|
21
|
+
/** Keeps the aspect ratio. Never enlarges: a width at or above this one answers the same image. */
|
|
22
|
+
resizedToWidth(width: number): RasterImage;
|
|
23
|
+
encode(format: ImageFormat): Uint8Array;
|
|
24
|
+
}
|
|
25
|
+
export declare function decodeImageFile(path: string): RasterImage;
|
|
26
|
+
export declare function decodeImageBytes(bytes: Uint8Array): RasterImage;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// The ONE image backend: GdkPixbuf. Decode, alpha probe, downscale, encode —
|
|
2
|
+
// everything `images.ts` needs, behind a plain-object seam so the rest of the
|
|
3
|
+
// build never names a library.
|
|
4
|
+
//
|
|
5
|
+
// Why GdkPixbuf rather than a JS encoder: it is reachable from EVERY runtime
|
|
6
|
+
// gjsify builds for, and it is the only thing in that set that is. Under
|
|
7
|
+
// `--app gjs` the `gi://` import lowers to GJS's own GI loader; under `--app
|
|
8
|
+
// node` the bundler rewrites it to the `@gjsify/node-gi` reverse bridge (ADR
|
|
9
|
+
// 0030), which is also what bun and deno run. So a site build is not a Node
|
|
10
|
+
// site build or a GJS site build — it is the same code on whichever runtime
|
|
11
|
+
// `gjsify` itself runs on. A `sharp` dependency would have inverted that: a
|
|
12
|
+
// native Node-API addon, no GJS story at all.
|
|
13
|
+
//
|
|
14
|
+
// AVIF/WebP writing comes from gdk-pixbuf's loader set (glycin on current
|
|
15
|
+
// GNOME), not from this package, so `assertEncodable` reports a MISSING LOADER
|
|
16
|
+
// by name instead of writing a 0-byte file. One format is deliberately absent
|
|
17
|
+
// from `ImageFormat`: JPEG. `save_to_bufferv('jpeg', …)` fails on an RGBA
|
|
18
|
+
// pixbuf ("the encoder or decoder for Jpeg does not support the color type
|
|
19
|
+
// `Rgba8`"), and screenshots of a window are RGBA.
|
|
20
|
+
import GdkPixbuf from 'gi://GdkPixbuf?version=2.0';
|
|
21
|
+
/** Encoder options per format, matching what the renditions were tuned to. */
|
|
22
|
+
const ENCODER = {
|
|
23
|
+
avif: { keys: ['quality'], values: ['55'] },
|
|
24
|
+
webp: { keys: ['quality'], values: ['80'] },
|
|
25
|
+
png: { keys: ['compression'], values: ['9'] },
|
|
26
|
+
};
|
|
27
|
+
let writableFormats;
|
|
28
|
+
/** Throws naming the loader when gdk-pixbuf on this system cannot WRITE the format. */
|
|
29
|
+
function assertEncodable(format) {
|
|
30
|
+
const writable = (writableFormats ??= new Set(GdkPixbuf.Pixbuf.get_formats()
|
|
31
|
+
.filter((entry) => entry.is_writable())
|
|
32
|
+
.map((entry) => entry.get_name())
|
|
33
|
+
.filter((name) => name !== null)));
|
|
34
|
+
if (writable.has(format))
|
|
35
|
+
return;
|
|
36
|
+
throw new Error(`gdk-pixbuf on this system cannot write ${format.toUpperCase()} — install the gdk-pixbuf loader for it ` +
|
|
37
|
+
`(GNOME ships AVIF and WebP through glycin). Writable here: ${[...writable].sort().join(', ')}.`);
|
|
38
|
+
}
|
|
39
|
+
function wrap(pixbuf) {
|
|
40
|
+
const width = pixbuf.get_width();
|
|
41
|
+
const height = pixbuf.get_height();
|
|
42
|
+
return {
|
|
43
|
+
width,
|
|
44
|
+
height,
|
|
45
|
+
alphaAt(x, y) {
|
|
46
|
+
if (!pixbuf.get_has_alpha())
|
|
47
|
+
return 255;
|
|
48
|
+
const pixels = pixbuf.get_pixels();
|
|
49
|
+
return pixels[y * pixbuf.get_rowstride() + x * pixbuf.get_n_channels() + 3] ?? 255;
|
|
50
|
+
},
|
|
51
|
+
opaqueBounds(threshold) {
|
|
52
|
+
if (!pixbuf.get_has_alpha())
|
|
53
|
+
return { x: 0, y: 0, width, height };
|
|
54
|
+
// The whole buffer once, not a call per pixel: a 2560px screenshot is seven million
|
|
55
|
+
// samples, and `get_pixels` copies the buffer out of GI on every call.
|
|
56
|
+
const pixels = pixbuf.get_pixels();
|
|
57
|
+
const stride = pixbuf.get_rowstride();
|
|
58
|
+
const channels = pixbuf.get_n_channels();
|
|
59
|
+
let top = -1;
|
|
60
|
+
let bottom = -1;
|
|
61
|
+
let left = width;
|
|
62
|
+
let right = -1;
|
|
63
|
+
for (let y = 0; y < height; y++) {
|
|
64
|
+
const row = y * stride;
|
|
65
|
+
let first = -1;
|
|
66
|
+
let last = -1;
|
|
67
|
+
for (let x = 0; x < width; x++) {
|
|
68
|
+
if ((pixels[row + x * channels + 3] ?? 255) < threshold)
|
|
69
|
+
continue;
|
|
70
|
+
if (first === -1)
|
|
71
|
+
first = x;
|
|
72
|
+
last = x;
|
|
73
|
+
}
|
|
74
|
+
if (first === -1)
|
|
75
|
+
continue;
|
|
76
|
+
if (top === -1)
|
|
77
|
+
top = y;
|
|
78
|
+
bottom = y;
|
|
79
|
+
if (first < left)
|
|
80
|
+
left = first;
|
|
81
|
+
if (last > right)
|
|
82
|
+
right = last;
|
|
83
|
+
}
|
|
84
|
+
if (top === -1)
|
|
85
|
+
return null;
|
|
86
|
+
return { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
|
87
|
+
},
|
|
88
|
+
cropped(rect) {
|
|
89
|
+
const sub = pixbuf.new_subpixbuf(rect.x, rect.y, rect.width, rect.height);
|
|
90
|
+
if (!sub)
|
|
91
|
+
throw new Error(`Could not crop an image to ${rect.width}x${rect.height}`);
|
|
92
|
+
return wrap(sub);
|
|
93
|
+
},
|
|
94
|
+
resizedToWidth(target) {
|
|
95
|
+
if (target >= width)
|
|
96
|
+
return wrap(pixbuf);
|
|
97
|
+
const scaled = pixbuf.scale_simple(target, Math.max(1, Math.round((height * target) / width)),
|
|
98
|
+
// HYPER over BILINEAR: these are large screenshots scaled far
|
|
99
|
+
// down, where bilinear drops thin UI lines (1px borders, text
|
|
100
|
+
// stems) that the page is meant to show.
|
|
101
|
+
GdkPixbuf.InterpType.HYPER);
|
|
102
|
+
if (!scaled)
|
|
103
|
+
throw new Error(`Could not scale an image to ${target}px`);
|
|
104
|
+
return wrap(scaled);
|
|
105
|
+
},
|
|
106
|
+
encode(format) {
|
|
107
|
+
assertEncodable(format);
|
|
108
|
+
const { keys, values } = ENCODER[format];
|
|
109
|
+
const [ok, bytes] = pixbuf.save_to_bufferv(format, keys, values);
|
|
110
|
+
if (!ok || !bytes?.length)
|
|
111
|
+
throw new Error(`Encoding ${format.toUpperCase()} produced no data`);
|
|
112
|
+
return new Uint8Array(bytes);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function decodeImageFile(path) {
|
|
117
|
+
const pixbuf = GdkPixbuf.Pixbuf.new_from_file(path);
|
|
118
|
+
if (!pixbuf)
|
|
119
|
+
throw new Error(`Could not decode ${path}`);
|
|
120
|
+
return wrap(pixbuf);
|
|
121
|
+
}
|
|
122
|
+
export function decodeImageBytes(bytes) {
|
|
123
|
+
const loader = new GdkPixbuf.PixbufLoader();
|
|
124
|
+
loader.write(bytes);
|
|
125
|
+
loader.close();
|
|
126
|
+
const pixbuf = loader.get_pixbuf();
|
|
127
|
+
if (!pixbuf)
|
|
128
|
+
throw new Error('Could not decode an image from memory');
|
|
129
|
+
return wrap(pixbuf);
|
|
130
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ImageFormat, type RasterImage } from './image-backend.js';
|
|
2
|
+
export type { ImageFormat };
|
|
3
|
+
export interface ImageFile {
|
|
4
|
+
url: string;
|
|
5
|
+
width: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ProcessedImage {
|
|
8
|
+
/** Intrinsic size of the largest rendition, for the `width`/`height` attributes. */
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
/** CSS pixels the page gives this screenshot at most; the renditions above it serve HiDPI. */
|
|
12
|
+
displayWidth: number;
|
|
13
|
+
formats: Record<ImageFormat, ImageFile[]>;
|
|
14
|
+
/** The artwork already carries its own shadow (a window shot), so the page adds no frame. */
|
|
15
|
+
frameless: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface ImageOptions {
|
|
18
|
+
/** Directory the renditions are published into (served below `urlBase`). */
|
|
19
|
+
outDir: string;
|
|
20
|
+
/** Persistent store of encoded renditions; AVIF is slow, so it is reused while the source is unchanged. */
|
|
21
|
+
cacheDir: string;
|
|
22
|
+
urlBase: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The widest the page ever shows a screenshot, in CSS pixels. A screenshot is shown at its natural
|
|
26
|
+
* width capped here and NEVER upscaled, which also settles what a "2×" source is without asking the
|
|
27
|
+
* metainfo: a 2560px source fills 1280 CSS px at device-pixel-ratio 2, a 1280px one fills the same
|
|
28
|
+
* box at ratio 1 and is as sharp as its artwork allows. Renditions therefore go up to twice this.
|
|
29
|
+
*/
|
|
30
|
+
export declare const MAX_DISPLAY_WIDTH = 1280;
|
|
31
|
+
/** Never upscales: the largest rendition is the source itself, or twice the display cap if that is smaller. */
|
|
32
|
+
export declare function renditionWidths(natural: number): number[];
|
|
33
|
+
/** CSS pixels this source fills: the display cap, or the source itself when it is narrower. */
|
|
34
|
+
export declare function displayWidth(natural: number): number;
|
|
35
|
+
/**
|
|
36
|
+
* A window shot cut out of a desktop (GNOME's screenshot tool) keeps a transparent margin with the
|
|
37
|
+
* window's own shadow, so all four corner pixels are see-through. Opaque screenshots have none.
|
|
38
|
+
*/
|
|
39
|
+
export declare function hasTransparentBorder(input: string | Uint8Array): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Drops rows and columns in which not one pixel is painted at all.
|
|
42
|
+
*
|
|
43
|
+
* A window shot is saved on a canvas large enough for the window's shadow, and how much empty space
|
|
44
|
+
* is left around that shadow is up to whoever took the shot. The page cannot see it: the slide sizes
|
|
45
|
+
* the whole file, so the picture shrinks by the margin and the band above and below it stays blank.
|
|
46
|
+
* Cropping to the painted area makes every screenshot fill its slide equally — and it keeps the
|
|
47
|
+
* shadow itself, which is why the threshold is "nothing at all" rather than "barely visible".
|
|
48
|
+
*/
|
|
49
|
+
export declare function trimTransparentMargin(image: RasterImage): RasterImage;
|
|
50
|
+
/** Encodes AVIF, WebP and PNG renditions of one screenshot into `outDir` and describes them. */
|
|
51
|
+
export declare function processImage(source: string, slug: string, options: ImageOptions): Promise<ProcessedImage>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { copyFile, mkdir, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { decodeImageBytes, decodeImageFile } from './image-backend.js';
|
|
5
|
+
/**
|
|
6
|
+
* The widest the page ever shows a screenshot, in CSS pixels. A screenshot is shown at its natural
|
|
7
|
+
* width capped here and NEVER upscaled, which also settles what a "2×" source is without asking the
|
|
8
|
+
* metainfo: a 2560px source fills 1280 CSS px at device-pixel-ratio 2, a 1280px one fills the same
|
|
9
|
+
* box at ratio 1 and is as sharp as its artwork allows. Renditions therefore go up to twice this.
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_DISPLAY_WIDTH = 1280;
|
|
12
|
+
const TARGET_WIDTHS = [640, MAX_DISPLAY_WIDTH, MAX_DISPLAY_WIDTH * 2];
|
|
13
|
+
const FORMATS = ['avif', 'webp', 'png'];
|
|
14
|
+
/** Never upscales: the largest rendition is the source itself, or twice the display cap if that is smaller. */
|
|
15
|
+
export function renditionWidths(natural) {
|
|
16
|
+
const largest = Math.min(natural, MAX_DISPLAY_WIDTH * 2);
|
|
17
|
+
const widths = new Set(TARGET_WIDTHS.filter((width) => width < largest));
|
|
18
|
+
widths.add(largest);
|
|
19
|
+
return [...widths].sort((a, b) => a - b);
|
|
20
|
+
}
|
|
21
|
+
/** CSS pixels this source fills: the display cap, or the source itself when it is narrower. */
|
|
22
|
+
export function displayWidth(natural) {
|
|
23
|
+
return Math.min(natural, MAX_DISPLAY_WIDTH);
|
|
24
|
+
}
|
|
25
|
+
/** Alpha below this is nothing at all: a pixel the source left empty, not the faint tail of a shadow. */
|
|
26
|
+
const ALPHA_EMPTY = 1;
|
|
27
|
+
const CORNER_ALPHA_MAX = 16;
|
|
28
|
+
/**
|
|
29
|
+
* A window shot cut out of a desktop (GNOME's screenshot tool) keeps a transparent margin with the
|
|
30
|
+
* window's own shadow, so all four corner pixels are see-through. Opaque screenshots have none.
|
|
31
|
+
*/
|
|
32
|
+
export function hasTransparentBorder(input) {
|
|
33
|
+
return cornersTransparent(typeof input === 'string' ? decodeImageFile(input) : decodeImageBytes(input));
|
|
34
|
+
}
|
|
35
|
+
function cornersTransparent(image) {
|
|
36
|
+
const right = image.width - 1;
|
|
37
|
+
const bottom = image.height - 1;
|
|
38
|
+
return [image.alphaAt(0, 0), image.alphaAt(right, 0), image.alphaAt(0, bottom), image.alphaAt(right, bottom)].every((alpha) => alpha < CORNER_ALPHA_MAX);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Drops rows and columns in which not one pixel is painted at all.
|
|
42
|
+
*
|
|
43
|
+
* A window shot is saved on a canvas large enough for the window's shadow, and how much empty space
|
|
44
|
+
* is left around that shadow is up to whoever took the shot. The page cannot see it: the slide sizes
|
|
45
|
+
* the whole file, so the picture shrinks by the margin and the band above and below it stays blank.
|
|
46
|
+
* Cropping to the painted area makes every screenshot fill its slide equally — and it keeps the
|
|
47
|
+
* shadow itself, which is why the threshold is "nothing at all" rather than "barely visible".
|
|
48
|
+
*/
|
|
49
|
+
export function trimTransparentMargin(image) {
|
|
50
|
+
const bounds = image.opaqueBounds(ALPHA_EMPTY);
|
|
51
|
+
if (!bounds)
|
|
52
|
+
return image;
|
|
53
|
+
if (bounds.width === image.width && bounds.height === image.height)
|
|
54
|
+
return image;
|
|
55
|
+
return image.cropped(bounds);
|
|
56
|
+
}
|
|
57
|
+
async function isFresh(cached, source) {
|
|
58
|
+
if (!existsSync(cached))
|
|
59
|
+
return false;
|
|
60
|
+
const [a, b] = await Promise.all([stat(cached), stat(source)]);
|
|
61
|
+
return a.mtimeMs >= b.mtimeMs;
|
|
62
|
+
}
|
|
63
|
+
/** Encodes AVIF, WebP and PNG renditions of one screenshot into `outDir` and describes them. */
|
|
64
|
+
export async function processImage(source, slug, options) {
|
|
65
|
+
const decoded = decodeImageFile(source);
|
|
66
|
+
if (!decoded.width || !decoded.height)
|
|
67
|
+
throw new Error(`Cannot read the size of ${source}`);
|
|
68
|
+
const image = trimTransparentMargin(decoded);
|
|
69
|
+
const widths = renditionWidths(image.width);
|
|
70
|
+
await Promise.all([mkdir(options.outDir, { recursive: true }), mkdir(options.cacheDir, { recursive: true })]);
|
|
71
|
+
const formats = { avif: [], webp: [], png: [] };
|
|
72
|
+
for (const width of widths) {
|
|
73
|
+
// Scaled at most once per width, and only when some format misses the
|
|
74
|
+
// cache — the resample is the expensive half of a warm rebuild.
|
|
75
|
+
let scaled;
|
|
76
|
+
for (const format of FORMATS) {
|
|
77
|
+
const name = `${slug}-${width}.${format}`;
|
|
78
|
+
const cached = join(options.cacheDir, name);
|
|
79
|
+
if (!(await isFresh(cached, source))) {
|
|
80
|
+
scaled ??= image.resizedToWidth(width);
|
|
81
|
+
await writeFile(cached, scaled.encode(format));
|
|
82
|
+
}
|
|
83
|
+
await copyFile(cached, join(options.outDir, name));
|
|
84
|
+
formats[format].push({ url: `${options.urlBase}${name}`, width });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const format of FORMATS)
|
|
88
|
+
formats[format].sort((a, b) => a.width - b.width);
|
|
89
|
+
const largest = widths[widths.length - 1];
|
|
90
|
+
return {
|
|
91
|
+
width: largest,
|
|
92
|
+
height: Math.round((largest * image.height) / image.width),
|
|
93
|
+
displayWidth: displayWidth(image.width),
|
|
94
|
+
formats,
|
|
95
|
+
frameless: cornersTransparent(image),
|
|
96
|
+
};
|
|
97
|
+
}
|