@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,32 @@
|
|
|
1
|
+
import { type DownloadTarget, type Localized, type Screenshot, type SiteData, type SiteSupportSepa } from '../site-data/index.js';
|
|
2
|
+
import type { ProcessedImage } from './images.js';
|
|
3
|
+
export interface ClientAssets {
|
|
4
|
+
js: string;
|
|
5
|
+
css: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface RenderContext {
|
|
8
|
+
data: SiteData;
|
|
9
|
+
lang: string;
|
|
10
|
+
t: (msgid: string) => string;
|
|
11
|
+
assets: ClientAssets;
|
|
12
|
+
/** Processed renditions keyed by a screenshot's slug. */
|
|
13
|
+
images: ReadonlyMap<string, ProcessedImage>;
|
|
14
|
+
/** Published URL of the app icon, when there is one. */
|
|
15
|
+
iconUrl?: string;
|
|
16
|
+
}
|
|
17
|
+
type Translate = (msgid: string) => string;
|
|
18
|
+
export declare function localized(text: Localized, lang: string): string;
|
|
19
|
+
export declare function pageUrl(data: SiteData, lang: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* What the primary button says. Installing from a store, opening the web build and downloading a
|
|
22
|
+
* file are three different promises, so each gets its own wording.
|
|
23
|
+
*/
|
|
24
|
+
export declare function ctaLabel(target: DownloadTarget, t: Translate): string;
|
|
25
|
+
/** A window shot brings its own shadow; ours would draw a second one around it. */
|
|
26
|
+
export declare function frameAttribute(ctx: Pick<RenderContext, 'images'>, screenshot: Screenshot): 'none' | undefined;
|
|
27
|
+
/** A bank transfer as a card: the details to read or copy, and an EPC QR code that fills them into a banking app. */
|
|
28
|
+
export declare function sepaCard(entry: SiteSupportSepa, t: Translate): string;
|
|
29
|
+
export declare function renderPage(ctx: RenderContext): string;
|
|
30
|
+
/** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
|
|
31
|
+
export declare function renderRoot(data: SiteData, assets: ClientAssets, iconUrl: string | undefined): string;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import { epcPayload, formatIban, isBrowserPlatform, PLATFORM_ORDER, pickByLanguage, } from '../site-data/index.js';
|
|
2
|
+
import { readableText } from './color.js';
|
|
3
|
+
import { attrs, esc, fmt, plain } from './html.js';
|
|
4
|
+
import { icon } from './icons.js';
|
|
5
|
+
import { qrSvg } from './qr.js';
|
|
6
|
+
const FORMAT_TYPES = { avif: 'image/avif', webp: 'image/webp', png: 'image/png' };
|
|
7
|
+
const LINKS = [
|
|
8
|
+
['homepage', (t) => t('Website'), 'web'],
|
|
9
|
+
['help', (t) => t('Help'), 'help'],
|
|
10
|
+
['faq', (t) => t('FAQ'), 'help'],
|
|
11
|
+
['vcsBrowser', (t) => t('Source code'), 'code'],
|
|
12
|
+
['bugtracker', (t) => t('Report an issue'), 'bug'],
|
|
13
|
+
['contribute', (t) => t('Contribute'), 'people'],
|
|
14
|
+
['translate', (t) => t('Help translate'), 'translate'],
|
|
15
|
+
['contact', (t) => t('Contact'), 'mail'],
|
|
16
|
+
['donation', (t) => t('Donate'), 'heart'],
|
|
17
|
+
];
|
|
18
|
+
const PLATFORM_ICONS = {
|
|
19
|
+
linux: 'linux',
|
|
20
|
+
darwin: 'macos',
|
|
21
|
+
win32: 'windows',
|
|
22
|
+
android: 'android',
|
|
23
|
+
web: 'web',
|
|
24
|
+
firefox: 'firefox',
|
|
25
|
+
chromium: 'chromium',
|
|
26
|
+
};
|
|
27
|
+
export function localized(text, lang) {
|
|
28
|
+
return pickByLanguage(text.byLanguage, lang) ?? text.source;
|
|
29
|
+
}
|
|
30
|
+
export function pageUrl(data, lang) {
|
|
31
|
+
return `${data.config.base}${lang}/`;
|
|
32
|
+
}
|
|
33
|
+
function absolute(data, rootRelative) {
|
|
34
|
+
return new URL(rootRelative.replace(/^\//, ''), `${new URL(data.config.url).origin}/`).href;
|
|
35
|
+
}
|
|
36
|
+
function languageName(code) {
|
|
37
|
+
return new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code;
|
|
38
|
+
}
|
|
39
|
+
function blocks(content, lang) {
|
|
40
|
+
return content
|
|
41
|
+
.map((block) => block.type === 'paragraph'
|
|
42
|
+
? `<p>${esc(localized(block.text, lang))}</p>`
|
|
43
|
+
: `<ul>${block.items.map((item) => `<li>${esc(localized(item, lang))}</li>`).join('')}</ul>`)
|
|
44
|
+
.join('\n');
|
|
45
|
+
}
|
|
46
|
+
function osName(os, t) {
|
|
47
|
+
return {
|
|
48
|
+
linux: t('Linux'),
|
|
49
|
+
darwin: t('macOS'),
|
|
50
|
+
win32: t('Windows'),
|
|
51
|
+
android: t('Android'),
|
|
52
|
+
web: t('Web'),
|
|
53
|
+
firefox: t('Firefox'),
|
|
54
|
+
chromium: t('Chromium'),
|
|
55
|
+
}[os];
|
|
56
|
+
}
|
|
57
|
+
function sizeLabel(bytes, lang) {
|
|
58
|
+
if (!bytes)
|
|
59
|
+
return '';
|
|
60
|
+
return `${new Intl.NumberFormat(lang, { maximumFractionDigits: 1 }).format(bytes / (1024 * 1024))} MB`;
|
|
61
|
+
}
|
|
62
|
+
/** The second line of a link card: the address without the noise a reader does not need. */
|
|
63
|
+
function prettyUrl(url) {
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(url);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return url;
|
|
70
|
+
}
|
|
71
|
+
if (parsed.protocol === 'mailto:')
|
|
72
|
+
return parsed.pathname;
|
|
73
|
+
const host = parsed.host.replace(/^www\./, '');
|
|
74
|
+
const path = parsed.pathname.replace(/\/$/, '');
|
|
75
|
+
return `${host}${path}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* What the primary button says. Installing from a store, opening the web build and downloading a
|
|
79
|
+
* file are three different promises, so each gets its own wording.
|
|
80
|
+
*/
|
|
81
|
+
export function ctaLabel(target, t) {
|
|
82
|
+
if (target.format === 'flathub')
|
|
83
|
+
return t('Install from Flathub');
|
|
84
|
+
if (target.format === 'source')
|
|
85
|
+
return t('Build from source');
|
|
86
|
+
if (target.os === 'web' && target.format === 'web')
|
|
87
|
+
return t('Try in browser');
|
|
88
|
+
if (isBrowserPlatform(target.os)) {
|
|
89
|
+
return plain(target.format === 'store' ? t('Add to {browser}') : t('Install for {browser}'), {
|
|
90
|
+
browser: osName(target.os, t),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return plain(t('Download for {os}'), { os: osName(target.os, t) });
|
|
94
|
+
}
|
|
95
|
+
function srcset(files) {
|
|
96
|
+
return files.map((file) => `${file.url} ${file.width}w`).join(', ');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* What the browser is told the screenshot will occupy. Capped at the display width the renditions
|
|
100
|
+
* were cut for, so a wide viewport asks for the 1× file and a HiDPI one for the 2× — never more.
|
|
101
|
+
*/
|
|
102
|
+
function sizes(image) {
|
|
103
|
+
return `(max-width: 600px) 92vw, min(86vw, ${image.displayWidth}px)`;
|
|
104
|
+
}
|
|
105
|
+
/** What a phone shows for this screenshot: portrait artwork needs a taller track than a landscape window. */
|
|
106
|
+
function portraitOnPhone(ctx, screenshot) {
|
|
107
|
+
const image = ctx.images.get(screenshot.slug);
|
|
108
|
+
return image !== undefined && image.height > image.width * 1.1;
|
|
109
|
+
}
|
|
110
|
+
function picture(ctx, screenshot, alt, eager) {
|
|
111
|
+
const image = ctx.images.get(screenshot.slug);
|
|
112
|
+
const shared = { sizes: sizes(image), width: image.width, height: image.height };
|
|
113
|
+
const sources = ['avif', 'webp'].map((format) => `<source${attrs({ type: FORMAT_TYPES[format], srcset: srcset(image.formats[format]), ...shared })}>`);
|
|
114
|
+
const png = image.formats.png;
|
|
115
|
+
const img = `<img${attrs({
|
|
116
|
+
src: png[png.length - 1].url,
|
|
117
|
+
srcset: srcset(png),
|
|
118
|
+
...shared,
|
|
119
|
+
alt,
|
|
120
|
+
loading: eager ? undefined : 'lazy',
|
|
121
|
+
decoding: 'async',
|
|
122
|
+
})}>`;
|
|
123
|
+
return `<picture${attrs({ 'data-frame': frameAttribute(ctx, screenshot), style: `--shot-w:${image.displayWidth}px` })}>${[...sources, img].join('')}</picture>`;
|
|
124
|
+
}
|
|
125
|
+
/** A window shot brings its own shadow; ours would draw a second one around it. */
|
|
126
|
+
export function frameAttribute(ctx, screenshot) {
|
|
127
|
+
return ctx.images.get(screenshot.slug)?.frameless === true ? 'none' : undefined;
|
|
128
|
+
}
|
|
129
|
+
/** The screenshots, at the bottom of the brand-coloured masthead. */
|
|
130
|
+
function screenshotBand(ctx) {
|
|
131
|
+
if (ctx.data.screenshots.length === 0)
|
|
132
|
+
return '';
|
|
133
|
+
const { t, lang } = ctx;
|
|
134
|
+
const slides = ctx.data.screenshots.map((screenshot, index) => {
|
|
135
|
+
const caption = screenshot.caption ? localized(screenshot.caption, lang) : '';
|
|
136
|
+
const alt = caption || plain(t('Screenshot {number}'), { number: index + 1 });
|
|
137
|
+
const label = plain(t('{number} of {total}'), { number: index + 1, total: ctx.data.screenshots.length });
|
|
138
|
+
return `<li${attrs({ class: 'slide', 'data-portrait': portraitOnPhone(ctx, screenshot) })} role="group" aria-roledescription="${esc(t('slide'))}" aria-label="${esc(label)}">
|
|
139
|
+
<figure>
|
|
140
|
+
${picture(ctx, screenshot, alt, index === 0)}
|
|
141
|
+
${caption ? `<figcaption>${esc(caption)}</figcaption>` : ''}
|
|
142
|
+
<button class="slide-expand" type="button" aria-label="${esc(t('View full screen'))}" hidden>${icon('expand')}</button>
|
|
143
|
+
</figure>
|
|
144
|
+
</li>`;
|
|
145
|
+
});
|
|
146
|
+
// The controls carry translated labels, so they are rendered here and only unhidden by the component.
|
|
147
|
+
const dots = ctx.data.screenshots.map((_screenshot, index) => `<li><button class="carousel-dot" type="button" aria-label="${esc(plain(t('Screenshot {number}'), { number: index + 1 }))}"></button></li>`);
|
|
148
|
+
return `<section class="band" aria-label="${esc(t('Screenshots'))}">
|
|
149
|
+
<screenshot-carousel${attrs({ class: 'carousel', 'data-portrait': ctx.data.screenshots.some((screenshot) => portraitOnPhone(ctx, screenshot)) })} role="group" aria-roledescription="${esc(t('carousel'))}" aria-label="${esc(t('Screenshots'))}">
|
|
150
|
+
<button class="carousel-arrow carousel-prev" type="button" aria-label="${esc(t('Previous screenshot'))}" hidden>${icon('chevron-left')}</button>
|
|
151
|
+
<ul class="carousel-track" aria-label="${esc(t('Screenshots'))}">
|
|
152
|
+
${slides.join('\n')}
|
|
153
|
+
</ul>
|
|
154
|
+
<button class="carousel-arrow carousel-next" type="button" aria-label="${esc(t('Next screenshot'))}" hidden>${icon('chevron-right')}</button>
|
|
155
|
+
<ol class="carousel-dots" hidden>${dots.join('')}</ol>
|
|
156
|
+
<dialog class="lightbox" aria-label="${esc(t('Screenshots'))}">
|
|
157
|
+
<button class="dialog-close" type="button" aria-label="${esc(t('Close'))}" hidden>${icon('close')}</button>
|
|
158
|
+
<figure></figure>
|
|
159
|
+
</dialog>
|
|
160
|
+
</screenshot-carousel>
|
|
161
|
+
</section>`;
|
|
162
|
+
}
|
|
163
|
+
/** The build the page offers before any browser detection: a store install if there is one. */
|
|
164
|
+
function defaultTarget(targets) {
|
|
165
|
+
const available = targets.filter((target) => target.available);
|
|
166
|
+
return (available.find((target) => target.recommended && target.os === 'linux') ??
|
|
167
|
+
available.find((target) => target.recommended) ??
|
|
168
|
+
available[0]);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* One row of the download dialog. The data attributes carry everything the client needs to put a
|
|
172
|
+
* row on the primary button, so it never has to build a translated string itself.
|
|
173
|
+
*/
|
|
174
|
+
function targetItem(target, ctx) {
|
|
175
|
+
const { t, lang } = ctx;
|
|
176
|
+
const size = sizeLabel(target.size, lang);
|
|
177
|
+
const meta = [
|
|
178
|
+
target.arch === 'noarch' ? '' : target.arch,
|
|
179
|
+
target.recommended ? t('Recommended') : '',
|
|
180
|
+
target.available ? '' : t('Not yet available'),
|
|
181
|
+
]
|
|
182
|
+
.filter(Boolean)
|
|
183
|
+
.join(' · ');
|
|
184
|
+
const inner = `<span class="target-icon">${icon(target.format === 'flathub' || target.format === 'store' ? 'package' : 'download')}</span>
|
|
185
|
+
<span class="target-text"><span class="target-name">${esc(t(target.label))}</span>${meta ? `<span class="target-meta">${esc(meta)}</span>` : ''}</span>
|
|
186
|
+
${size ? `<span class="target-size">${esc(size)}</span>` : ''}`;
|
|
187
|
+
return `<li${attrs({
|
|
188
|
+
class: 'target',
|
|
189
|
+
'data-os': target.os,
|
|
190
|
+
'data-arch': target.arch,
|
|
191
|
+
'data-recommended': target.recommended,
|
|
192
|
+
'data-unavailable': !target.available,
|
|
193
|
+
'data-cta': target.available ? ctaLabel(target, t) : undefined,
|
|
194
|
+
'data-size': size || undefined,
|
|
195
|
+
})}>
|
|
196
|
+
${target.available ? `<a href="${esc(target.url)}">\n${inner}\n</a>` : `<span class="target-link">\n${inner}\n</span>`}
|
|
197
|
+
</li>`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The split button plus the dialog holding every target. Both are server-rendered: without
|
|
201
|
+
* JavaScript the dialog stays an open panel below the button (see `site.css`), with it the
|
|
202
|
+
* component turns the same markup into a modal and swaps the button for the detected platform.
|
|
203
|
+
*/
|
|
204
|
+
function downloadWidget(ctx) {
|
|
205
|
+
const { data, t } = ctx;
|
|
206
|
+
const targets = data.downloads.targets;
|
|
207
|
+
const primary = defaultTarget(targets);
|
|
208
|
+
if (!primary) {
|
|
209
|
+
// Nothing downloadable yet: point at the releases, or at the source when there are none.
|
|
210
|
+
const [href, label] = data.downloads.hasReleases
|
|
211
|
+
? [data.downloads.releasesUrl, t('All releases')]
|
|
212
|
+
: [repositoryUrl(data) ?? data.downloads.releasesUrl, t('Source code')];
|
|
213
|
+
return `<div class="download">
|
|
214
|
+
<a class="button download-cta" href="${esc(href)}">${icon('download')}<span class="download-label">${esc(label)}</span></a>
|
|
215
|
+
</div>`;
|
|
216
|
+
}
|
|
217
|
+
const groups = PLATFORM_ORDER.flatMap((os) => {
|
|
218
|
+
const own = targets.filter((target) => target.os === os);
|
|
219
|
+
return own.length > 0 ? [[os, own]] : [];
|
|
220
|
+
});
|
|
221
|
+
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>`);
|
|
222
|
+
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)}">
|
|
223
|
+
<h3>${icon(PLATFORM_ICONS[os])}<span>${esc(osName(os, t))}</span></h3>
|
|
224
|
+
<ul>
|
|
225
|
+
${own.map((target) => targetItem(target, ctx)).join('\n')}
|
|
226
|
+
</ul>
|
|
227
|
+
${os === 'darwin' ? `<p class="os-note">${esc(t('Apple Silicon is assumed. Pick the Intel build if your Mac has an Intel processor.'))}</p>` : ''}
|
|
228
|
+
</section>`);
|
|
229
|
+
return `<app-download class="download">
|
|
230
|
+
<div class="download-split">
|
|
231
|
+
<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>
|
|
232
|
+
<a class="button download-toggle" href="#all-downloads" aria-label="${esc(t('All download options'))}">${icon('chevron-down')}</a>
|
|
233
|
+
</div>
|
|
234
|
+
<dialog id="all-downloads" class="download-dialog" aria-labelledby="all-downloads-title">
|
|
235
|
+
<div class="dialog-head">
|
|
236
|
+
<h2 id="all-downloads-title">${esc(t('All download options'))}</h2>
|
|
237
|
+
<button class="dialog-close" type="button" aria-label="${esc(t('Close'))}" hidden>${icon('close')}</button>
|
|
238
|
+
</div>
|
|
239
|
+
<div class="os-tabs" role="tablist" aria-label="${esc(t('Operating system'))}" hidden>${tabs.join('')}</div>
|
|
240
|
+
<div class="os-groups">
|
|
241
|
+
${sections.join('\n')}
|
|
242
|
+
</div>
|
|
243
|
+
${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>` : ''}
|
|
244
|
+
</dialog>
|
|
245
|
+
</app-download>`;
|
|
246
|
+
}
|
|
247
|
+
function badge(spec) {
|
|
248
|
+
return `<li${attrs({ class: spec.class ? `badge ${spec.class}` : 'badge', hidden: spec.hidden })}>
|
|
249
|
+
<span class="badge-pill">${spec.pill ?? icon(spec.glyph)}</span>
|
|
250
|
+
<span${attrs({ class: 'badge-value', title: spec.title })}>${esc(spec.value)}</span>
|
|
251
|
+
<span class="badge-label">${esc(spec.label)}</span>
|
|
252
|
+
</li>`;
|
|
253
|
+
}
|
|
254
|
+
/** `GPL-3.0-or-later` is too long for a badge; the full id stays in the title. */
|
|
255
|
+
function shortLicense(id) {
|
|
256
|
+
return id.replace(/-or-later$/, '+').replace(/-only$/, '');
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The facts row under the hero. A badge whose fact is missing is left out, not faked, and the row
|
|
260
|
+
* tells CSS how many it ended up with so the columns divide the width evenly.
|
|
261
|
+
*/
|
|
262
|
+
function badges(ctx) {
|
|
263
|
+
const { data, t, lang } = ctx;
|
|
264
|
+
const number = new Intl.NumberFormat(lang, { notation: 'compact', maximumFractionDigits: 1 });
|
|
265
|
+
const targets = data.downloads.targets;
|
|
266
|
+
const specs = [];
|
|
267
|
+
const primary = defaultTarget(targets);
|
|
268
|
+
if (primary) {
|
|
269
|
+
// Not every target reports a size; the client swaps this one as the reader picks another.
|
|
270
|
+
const size = sizeLabel(primary.size, lang);
|
|
271
|
+
specs.push({ glyph: 'download', value: size, label: t('Download size'), class: 'badge-size', hidden: !size });
|
|
272
|
+
}
|
|
273
|
+
if (data.app.license) {
|
|
274
|
+
const short = shortLicense(data.app.license);
|
|
275
|
+
specs.push({
|
|
276
|
+
glyph: 'license',
|
|
277
|
+
value: short,
|
|
278
|
+
label: t('License'),
|
|
279
|
+
title: short === data.app.license ? undefined : data.app.license,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const oses = PLATFORM_ORDER.filter((os) => targets.some((target) => target.available && target.os === os));
|
|
283
|
+
if (oses.length > 0) {
|
|
284
|
+
// The class is the hook for the platform's own colour, which the stylesheet reveals on hover.
|
|
285
|
+
const glyphs = oses
|
|
286
|
+
.map((os) => icon(PLATFORM_ICONS[os], { title: osName(os, t), class: `platform platform-${os}` }))
|
|
287
|
+
.join('');
|
|
288
|
+
const label = oses.every(isBrowserPlatform) ? t('Browsers') : t('Platforms');
|
|
289
|
+
specs.push({ pill: `<span class="badge-platforms">${glyphs}</span>`, value: '', label });
|
|
290
|
+
}
|
|
291
|
+
if (data.app.ageRating) {
|
|
292
|
+
const { minAge } = data.app.ageRating;
|
|
293
|
+
specs.push({
|
|
294
|
+
glyph: 'people',
|
|
295
|
+
value: minAge === 0 ? t('All ages') : plain(t('{age}+'), { age: minAge }),
|
|
296
|
+
label: t('Age rating'),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (data.stats.flathubInstalls !== undefined)
|
|
300
|
+
specs.push({ glyph: 'package', value: number.format(data.stats.flathubInstalls), label: t('Installs') });
|
|
301
|
+
if (specs.length === 0)
|
|
302
|
+
return '';
|
|
303
|
+
const visible = specs.filter((spec) => !spec.hidden).length;
|
|
304
|
+
const odd = visible % 2 === 1 ? ' data-odd="true"' : '';
|
|
305
|
+
return `<ul class="badges" style="--badge-count:${visible}"${odd}>\n${specs.map(badge).join('\n')}\n</ul>`;
|
|
306
|
+
}
|
|
307
|
+
function releaseDate(date, lang) {
|
|
308
|
+
const parsed = new Date(date);
|
|
309
|
+
return Number.isNaN(parsed.getTime())
|
|
310
|
+
? date
|
|
311
|
+
: new Intl.DateTimeFormat(lang, { dateStyle: 'long', timeZone: 'UTC' }).format(parsed);
|
|
312
|
+
}
|
|
313
|
+
function datePill(release, lang) {
|
|
314
|
+
return `<p class="date-pill"><time datetime="${esc(release.date)}">${esc(releaseDate(release.date, lang))}</time></p>`;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The latest release as a card, older ones folded away. The "show more" button is rendered
|
|
318
|
+
* hidden and the body unclamped: a reader without JavaScript gets the whole text, which a closed
|
|
319
|
+
* `<details>` would have hidden instead.
|
|
320
|
+
*/
|
|
321
|
+
function releases(ctx) {
|
|
322
|
+
// A release without notes would render an empty card: it only has a version and a date.
|
|
323
|
+
const [latest, ...older] = ctx.data.releases.filter((release) => release.description.length > 0);
|
|
324
|
+
if (!latest)
|
|
325
|
+
return '';
|
|
326
|
+
const { t, lang } = ctx;
|
|
327
|
+
return `<section class="releases">
|
|
328
|
+
<release-notes class="card release"${attrs({ 'data-more': t('Show more'), 'data-less': t('Show less') })}>
|
|
329
|
+
<div class="release-head">
|
|
330
|
+
<h2>${fmt(t('Changes in version {version}'), { version: latest.version })}</h2>
|
|
331
|
+
${datePill(latest, lang)}
|
|
332
|
+
</div>
|
|
333
|
+
<div class="release-body">
|
|
334
|
+
${blocks(latest.description, lang)}
|
|
335
|
+
</div>
|
|
336
|
+
<button class="release-more" type="button" hidden>${esc(t('Show more'))}</button>
|
|
337
|
+
</release-notes>
|
|
338
|
+
${older.length > 0
|
|
339
|
+
? `<details class="card older">
|
|
340
|
+
<summary>${esc(t('Older releases'))}${icon('chevron-down', { class: 'summary-chevron' })}</summary>
|
|
341
|
+
${older
|
|
342
|
+
.map((release) => `<article class="release-old">
|
|
343
|
+
<div class="release-head">
|
|
344
|
+
<h3>${fmt(t('Changes in version {version}'), { version: release.version })}</h3>
|
|
345
|
+
${datePill(release, lang)}
|
|
346
|
+
</div>
|
|
347
|
+
${blocks(release.description, lang)}
|
|
348
|
+
</article>`)
|
|
349
|
+
.join('\n')}
|
|
350
|
+
</details>`
|
|
351
|
+
: ''}
|
|
352
|
+
</section>`;
|
|
353
|
+
}
|
|
354
|
+
function linkCard(href, title, glyph) {
|
|
355
|
+
return `<li>
|
|
356
|
+
<a href="${esc(href)}">
|
|
357
|
+
<span class="link-icon">${icon(glyph)}</span>
|
|
358
|
+
<span class="link-text"><span class="link-title">${esc(title)}</span><span class="link-url">${esc(prettyUrl(href))}</span></span>
|
|
359
|
+
${icon('external', { class: 'link-external' })}
|
|
360
|
+
</a>
|
|
361
|
+
</li>`;
|
|
362
|
+
}
|
|
363
|
+
/** Same address, trailing slash or not. */
|
|
364
|
+
function sameUrl(a, b) {
|
|
365
|
+
return a.replace(/\/+$/, '') === b.replace(/\/+$/, '');
|
|
366
|
+
}
|
|
367
|
+
function links(ctx) {
|
|
368
|
+
const { data, t } = ctx;
|
|
369
|
+
const items = LINKS.flatMap(([key, label, glyph]) => {
|
|
370
|
+
const href = data.app.urls[key];
|
|
371
|
+
// No point linking the page the reader is on: the metainfo's homepage is often this site.
|
|
372
|
+
if (!href || (key === 'homepage' && sameUrl(href, data.config.url)))
|
|
373
|
+
return [];
|
|
374
|
+
return [linkCard(href, label(t), glyph)];
|
|
375
|
+
});
|
|
376
|
+
if (data.config.flathub)
|
|
377
|
+
items.unshift(linkCard(`https://flathub.org/apps/${data.config.flathub}`, 'Flathub', 'package'));
|
|
378
|
+
if (items.length === 0)
|
|
379
|
+
return '';
|
|
380
|
+
return `<section class="links">
|
|
381
|
+
<h2>${esc(t('Links'))}</h2>
|
|
382
|
+
<ul class="card-list">
|
|
383
|
+
${items.join('\n')}
|
|
384
|
+
</ul>
|
|
385
|
+
</section>`;
|
|
386
|
+
}
|
|
387
|
+
/** A bank transfer as a card: the details to read or copy, and an EPC QR code that fills them into a banking app. */
|
|
388
|
+
export function sepaCard(entry, t) {
|
|
389
|
+
const grouped = formatIban(entry.iban);
|
|
390
|
+
const row = (label, value, extra = '') => `<div class="sepa-row"><dt>${esc(label)}</dt><dd>${extra ? `<span class="sepa-value">${esc(value)}</span>${extra}` : esc(value)}</dd></div>`;
|
|
391
|
+
const copy = `<button class="sepa-copy" type="button" data-copy="${esc(entry.iban)}" data-done="${esc(t('Copied'))}" hidden>${icon('copy')}<span>${esc(t('Copy'))}</span></button>`;
|
|
392
|
+
return `<li class="sepa">
|
|
393
|
+
<div class="sepa-text">
|
|
394
|
+
<h3>${esc(t('Bank transfer'))}</h3>
|
|
395
|
+
<dl class="sepa-details">
|
|
396
|
+
${row(t('Recipient'), entry.name)}
|
|
397
|
+
${row('IBAN', grouped, copy)}
|
|
398
|
+
${entry.bic ? row('BIC', entry.bic) : ''}
|
|
399
|
+
${entry.reference ? row(t('Reference'), entry.reference) : ''}
|
|
400
|
+
</dl>
|
|
401
|
+
</div>
|
|
402
|
+
<figure class="sepa-qr">
|
|
403
|
+
${qrSvg(epcPayload(entry), t('QR code for a bank transfer'))}
|
|
404
|
+
<figcaption>${esc(t('Scan with your banking app'))}</figcaption>
|
|
405
|
+
</figure>
|
|
406
|
+
</li>`;
|
|
407
|
+
}
|
|
408
|
+
function supportItem(entry, t) {
|
|
409
|
+
return entry.kind === 'sepa'
|
|
410
|
+
? sepaCard(entry, t)
|
|
411
|
+
: linkCard(entry.url, t(entry.kind), 'heart');
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Ways to support the project, if the site configures any. Same no-JavaScript shape as the
|
|
415
|
+
* download dialog: an anchor to a dialog that stays an open panel until the component takes over.
|
|
416
|
+
*/
|
|
417
|
+
function support(ctx) {
|
|
418
|
+
const { data, t } = ctx;
|
|
419
|
+
if (data.config.support.length === 0)
|
|
420
|
+
return '';
|
|
421
|
+
return `<section class="support">
|
|
422
|
+
<app-support class="support-widget">
|
|
423
|
+
<a class="button support-cta" href="#support-ways">${icon('heart')}<span>${esc(t('Support this project'))}</span></a>
|
|
424
|
+
<dialog id="support-ways" class="support-dialog" aria-labelledby="support-ways-title">
|
|
425
|
+
<div class="dialog-head">
|
|
426
|
+
<h2 id="support-ways-title">${esc(t('Support this project'))}</h2>
|
|
427
|
+
<button class="dialog-close" type="button" aria-label="${esc(t('Close'))}" hidden>${icon('close')}</button>
|
|
428
|
+
</div>
|
|
429
|
+
<ul class="card-list">
|
|
430
|
+
${data.config.support.map((entry) => supportItem(entry, t)).join('\n')}
|
|
431
|
+
</ul>
|
|
432
|
+
</dialog>
|
|
433
|
+
</app-support>
|
|
434
|
+
</section>`;
|
|
435
|
+
}
|
|
436
|
+
function tags(ctx) {
|
|
437
|
+
const { data, t, lang } = ctx;
|
|
438
|
+
const keywords = data.app.keywords.map((keyword) => localized(keyword, lang));
|
|
439
|
+
const arches = [
|
|
440
|
+
...new Set(data.downloads.targets.map((target) => target.arch).filter((arch) => arch !== 'noarch')),
|
|
441
|
+
];
|
|
442
|
+
const list = (label, values) => values.length === 0
|
|
443
|
+
? ''
|
|
444
|
+
: `<div class="tag-group"><h3>${esc(label)}</h3><ul class="tags">${values.map((value) => `<li>${esc(value)}</li>`).join('')}</ul></div>`;
|
|
445
|
+
const body = [
|
|
446
|
+
list(t('Categories'), data.app.categories),
|
|
447
|
+
list(t('Keywords'), keywords),
|
|
448
|
+
list(t('Architectures'), arches),
|
|
449
|
+
].join('\n');
|
|
450
|
+
return body.trim() ? `<section class="tags-section">\n${body}\n</section>` : '';
|
|
451
|
+
}
|
|
452
|
+
function languageSwitch(ctx, className, style) {
|
|
453
|
+
const { data, lang, t } = ctx;
|
|
454
|
+
const items = data.config.languages.map((code) => {
|
|
455
|
+
const label = style === 'code' ? code.toUpperCase() : languageName(code);
|
|
456
|
+
return code === lang
|
|
457
|
+
? `<li><span aria-current="true">${esc(label)}</span></li>`
|
|
458
|
+
: `<li><a href="${esc(pageUrl(data, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(label)}</a></li>`;
|
|
459
|
+
});
|
|
460
|
+
return `<nav class="${className}" aria-label="${esc(t('Language'))}"><ul>${items.join('')}</ul></nav>`;
|
|
461
|
+
}
|
|
462
|
+
function repositoryUrl(data) {
|
|
463
|
+
return data.app.urls.vcsBrowser ?? (data.config.repository || undefined);
|
|
464
|
+
}
|
|
465
|
+
function topBar(ctx) {
|
|
466
|
+
const { data, lang, t } = ctx;
|
|
467
|
+
const repo = repositoryUrl(data);
|
|
468
|
+
const onGitHub = repo ? /(^|\.)github\.com$/.test(new URL(repo).host) : false;
|
|
469
|
+
return `<div class="topbar">
|
|
470
|
+
<div class="bar-inner">
|
|
471
|
+
<a class="bar-brand" href="${esc(pageUrl(data, lang))}">
|
|
472
|
+
${ctx.iconUrl ? `<img src="${esc(ctx.iconUrl)}" alt="" width="24" height="24">` : ''}
|
|
473
|
+
<span>${esc(localized(data.app.name, lang))}</span>
|
|
474
|
+
</a>
|
|
475
|
+
<div class="bar-end">
|
|
476
|
+
${languageSwitch(ctx, 'bar-languages', 'code')}
|
|
477
|
+
${repo ? `<a class="bar-link" href="${esc(repo)}">${icon('code')}<span>${onGitHub ? 'GitHub' : esc(t('Source code'))}</span></a>` : ''}
|
|
478
|
+
</div>
|
|
479
|
+
</div>
|
|
480
|
+
</div>`;
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Everything above the text: the icon and name, the install button, the facts row and the
|
|
484
|
+
* screenshots. One element, so the app's brand colour is behind all of it rather than behind the
|
|
485
|
+
* screenshots alone. The bar is its sibling and not its child — a `position: sticky` element
|
|
486
|
+
* cannot outlive its own containing block — but it is painted in the same colour, so it reads as
|
|
487
|
+
* the top of this header and needs no script to tell whether it has detached.
|
|
488
|
+
*/
|
|
489
|
+
function masthead(ctx) {
|
|
490
|
+
const { data, lang, t } = ctx;
|
|
491
|
+
const developer = data.app.developer.name;
|
|
492
|
+
return `<header class="masthead">
|
|
493
|
+
<div class="hero-inner">
|
|
494
|
+
${ctx.iconUrl ? `<div class="hero-icon" style="--icon-url:url(${esc(ctx.iconUrl)})"><img src="${esc(ctx.iconUrl)}" alt="" width="128" height="128"></div>` : ''}
|
|
495
|
+
<div class="hero-text">
|
|
496
|
+
<h1>${esc(localized(data.app.name, lang))}</h1>
|
|
497
|
+
${developer ? `<p class="hero-by">${fmt(t('by {developer}'), { developer })}</p>` : ''}
|
|
498
|
+
</div>
|
|
499
|
+
${downloadWidget(ctx)}
|
|
500
|
+
</div>
|
|
501
|
+
${badges(ctx)}
|
|
502
|
+
${screenshotBand(ctx)}
|
|
503
|
+
</header>`;
|
|
504
|
+
}
|
|
505
|
+
function footer(ctx) {
|
|
506
|
+
const { data, lang, t } = ctx;
|
|
507
|
+
const name = localized(data.app.name, lang);
|
|
508
|
+
const repo = repositoryUrl(data);
|
|
509
|
+
const legal = data.config.legal
|
|
510
|
+
.map((link) => `<li><a href="${esc(link.url)}">${esc(link.label)}</a></li>`)
|
|
511
|
+
.join('');
|
|
512
|
+
const licenses = [
|
|
513
|
+
data.app.license ? fmt(t('{app} is licensed under {license}.'), { app: name, license: data.app.license }) : '',
|
|
514
|
+
data.app.contentLicense
|
|
515
|
+
? fmt(t('This page reproduces text licensed under {license}.'), { license: data.app.contentLicense })
|
|
516
|
+
: '',
|
|
517
|
+
].filter(Boolean);
|
|
518
|
+
return `<footer class="footer">
|
|
519
|
+
<div class="footer-inner">
|
|
520
|
+
${languageSwitch(ctx, 'footer-languages', 'name')}
|
|
521
|
+
${repo ? `<p class="footer-source"><a href="${esc(repo)}">${icon('code')}<span>${esc(t('Source code'))}</span></a></p>` : ''}
|
|
522
|
+
${licenses.length > 0 ? `<p class="footer-license">${licenses.join(' ')}</p>` : ''}
|
|
523
|
+
<p class="footer-made"><a href="https://github.com/gjsify/gjsify">${esc(plain(t('Made with {project}'), { project: 'gjsify' }))}</a></p>
|
|
524
|
+
${legal ? `<ul class="footer-legal">${legal}</ul>` : ''}
|
|
525
|
+
</div>
|
|
526
|
+
</footer>`;
|
|
527
|
+
}
|
|
528
|
+
function head(ctx) {
|
|
529
|
+
const { data, lang } = ctx;
|
|
530
|
+
const name = localized(data.app.name, lang);
|
|
531
|
+
const summary = localized(data.app.summary, lang);
|
|
532
|
+
const title = `${name} – ${summary}`;
|
|
533
|
+
const url = absolute(data, pageUrl(data, lang));
|
|
534
|
+
const first = data.screenshots[0];
|
|
535
|
+
const og = first ? ctx.images.get(first.slug) : undefined;
|
|
536
|
+
const ogImage = og ? absolute(data, og.formats.png.at(-1).url) : undefined;
|
|
537
|
+
const { light, dark } = data.app.branding;
|
|
538
|
+
// An app without `<branding>` keeps the stylesheet's own palette: the custom property is left
|
|
539
|
+
// unset so its `var()` fallback wins, rather than being set to a stand-in colour here.
|
|
540
|
+
const brandVars = [
|
|
541
|
+
light ? `--brand-light:${light};--band-fg-light:${readableText(light)}` : '',
|
|
542
|
+
dark ? `--brand-dark:${dark};--band-fg-dark:${readableText(dark)}` : '',
|
|
543
|
+
]
|
|
544
|
+
.filter(Boolean)
|
|
545
|
+
.join(';');
|
|
546
|
+
const alternates = data.config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, pageUrl(data, code)))}">`);
|
|
547
|
+
const meta = [
|
|
548
|
+
['og:type', 'website'],
|
|
549
|
+
['og:title', title],
|
|
550
|
+
['og:description', summary],
|
|
551
|
+
['og:url', url],
|
|
552
|
+
['og:site_name', name],
|
|
553
|
+
['og:locale', lang.replace(/-/g, '_')],
|
|
554
|
+
];
|
|
555
|
+
if (ogImage)
|
|
556
|
+
meta.push(['og:image', ogImage]);
|
|
557
|
+
return `<meta charset="utf-8">
|
|
558
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
559
|
+
<title>${esc(title)}</title>
|
|
560
|
+
<meta name="description" content="${esc(summary)}">
|
|
561
|
+
<link rel="canonical" href="${esc(url)}">
|
|
562
|
+
${alternates.join('\n')}
|
|
563
|
+
<link rel="alternate" hreflang="x-default" href="${esc(absolute(data, data.config.base))}">
|
|
564
|
+
${meta.map(([property, content]) => `<meta property="${property}" content="${esc(content)}">`).join('\n')}
|
|
565
|
+
<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}">
|
|
566
|
+
<meta name="theme-color" content="${esc(light ?? '#ffffff')}" media="(prefers-color-scheme: light)">
|
|
567
|
+
<meta name="theme-color" content="${esc(dark ?? '#1e1e1e')}" media="(prefers-color-scheme: dark)">
|
|
568
|
+
${ctx.iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(ctx.iconUrl)}">` : ''}
|
|
569
|
+
${brandVars ? `<style>:root{${brandVars}}</style>` : ''}
|
|
570
|
+
${ctx.assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
|
|
571
|
+
<script type="module" src="${esc(ctx.assets.js)}"></script>`;
|
|
572
|
+
}
|
|
573
|
+
export function renderPage(ctx) {
|
|
574
|
+
const { data, lang } = ctx;
|
|
575
|
+
return `<!doctype html>
|
|
576
|
+
<html lang="${esc(lang)}">
|
|
577
|
+
<head>
|
|
578
|
+
${head(ctx)}
|
|
579
|
+
</head>
|
|
580
|
+
<body>
|
|
581
|
+
${topBar(ctx)}
|
|
582
|
+
${masthead(ctx)}
|
|
583
|
+
<main>
|
|
584
|
+
<div class="column">
|
|
585
|
+
<section class="about">
|
|
586
|
+
<h2>${esc(localized(data.app.summary, lang))}</h2>
|
|
587
|
+
${blocks(data.app.description, lang)}
|
|
588
|
+
</section>
|
|
589
|
+
${releases(ctx)}
|
|
590
|
+
${support(ctx)}
|
|
591
|
+
${links(ctx)}
|
|
592
|
+
${tags(ctx)}
|
|
593
|
+
</div>
|
|
594
|
+
</main>
|
|
595
|
+
${footer(ctx)}
|
|
596
|
+
</body>
|
|
597
|
+
</html>
|
|
598
|
+
`;
|
|
599
|
+
}
|
|
600
|
+
/** The root page: a script picks the visitor's language, the link list is what everyone else (and every crawler) sees. */
|
|
601
|
+
export function renderRoot(data, assets, iconUrl) {
|
|
602
|
+
const { config } = data;
|
|
603
|
+
const name = data.app.name.source;
|
|
604
|
+
const json = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
605
|
+
const choose = `var l=${json(config.languages)},d=${json(config.defaultLanguage)},p=(navigator.languages||[navigator.language]).map(function(x){return String(x).toLowerCase()}),c=d;for(var i=0;i<p.length;i++){var m=l.filter(function(x){return p[i]===x.toLowerCase()||p[i].split("-")[0]===x.toLowerCase()})[0];if(m){c=m;break}}location.replace(${json(config.base)}+c+"/")`;
|
|
606
|
+
const alternates = config.languages.map((code) => `<link rel="alternate" hreflang="${esc(code)}" href="${esc(absolute(data, pageUrl(data, code)))}">`);
|
|
607
|
+
return `<!doctype html>
|
|
608
|
+
<html lang="${esc(config.defaultLanguage)}">
|
|
609
|
+
<head>
|
|
610
|
+
<meta charset="utf-8">
|
|
611
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
612
|
+
<title>${esc(name)}</title>
|
|
613
|
+
<link rel="canonical" href="${esc(absolute(data, pageUrl(data, config.defaultLanguage)))}">
|
|
614
|
+
${alternates.join('\n')}
|
|
615
|
+
<link rel="alternate" hreflang="x-default" href="${esc(absolute(data, config.base))}">
|
|
616
|
+
${iconUrl ? `<link rel="icon" type="image/svg+xml" href="${esc(iconUrl)}">` : ''}
|
|
617
|
+
${assets.css.map((href) => `<link rel="stylesheet" href="${esc(href)}">`).join('\n')}
|
|
618
|
+
<script>${choose}</script>
|
|
619
|
+
</head>
|
|
620
|
+
<body class="root">
|
|
621
|
+
<main>
|
|
622
|
+
<h1>${esc(name)}</h1>
|
|
623
|
+
<ul class="languages-list">${config.languages.map((code) => `<li><a href="${esc(pageUrl(data, code))}" hreflang="${esc(code)}" lang="${esc(code)}">${esc(languageName(code))}</a></li>`).join('')}</ul>
|
|
624
|
+
</main>
|
|
625
|
+
</body>
|
|
626
|
+
</html>
|
|
627
|
+
`;
|
|
628
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renders `text` as an inline SVG at build time. Error correction M, as the EPC spec prefers
|
|
3
|
+
* (L is the minimum it allows). The quiet zone is part of the SVG and always light, so the code
|
|
4
|
+
* scans on a dark theme too: scanners expect dark modules on a light ground.
|
|
5
|
+
*/
|
|
6
|
+
export declare function qrSvg(text: string, label: string, options?: {
|
|
7
|
+
margin?: number;
|
|
8
|
+
}): string;
|