@goodandready/dsh-image-gen 0.10.3 → 0.10.5
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 +15 -0
- package/README.ru.md +12 -0
- package/README.zh.md +8 -0
- package/lib/client.js +57 -30
- package/lib/frontend-assets.js +436 -0
- package/lib/index.js +566 -3
- package/lib/providers.js +50 -2
- package/lib/resolve-image.js +243 -0
- package/package.json +5 -1
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert RGB to Hex string
|
|
7
|
+
* @param {number} r
|
|
8
|
+
* @param {number} g
|
|
9
|
+
* @param {number} b
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
export function rgbToHex(r, g, b) {
|
|
13
|
+
const toHex = (c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0')
|
|
14
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toLowerCase()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse Hex string to RGB
|
|
19
|
+
* @param {string} hex
|
|
20
|
+
* @returns {{ r: number, g: number, b: number }}
|
|
21
|
+
*/
|
|
22
|
+
export function hexToRgb(hex) {
|
|
23
|
+
let clean = hex.replace(/^#/, '').trim()
|
|
24
|
+
if (clean.length === 3) {
|
|
25
|
+
clean = clean.split('').map((c) => c + c).join('')
|
|
26
|
+
}
|
|
27
|
+
const num = parseInt(clean, 16)
|
|
28
|
+
if (Number.isNaN(num) || clean.length !== 6) {
|
|
29
|
+
return { r: 0, g: 0, b: 0 }
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
r: (num >> 16) & 255,
|
|
33
|
+
g: (num >> 8) & 255,
|
|
34
|
+
b: num & 255,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Calculates WCAG 2.1 relative luminance for sRGB color.
|
|
40
|
+
* @param {{ r: number, g: number, b: number }} rgb
|
|
41
|
+
* @returns {number}
|
|
42
|
+
*/
|
|
43
|
+
export function getRelativeLuminance({ r, g, b }) {
|
|
44
|
+
const a = [r, g, b].map((v) => {
|
|
45
|
+
const s = v / 255
|
|
46
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
|
|
47
|
+
})
|
|
48
|
+
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Calculates WCAG 2.1 contrast ratio between two colors (1.0 to 21.0).
|
|
53
|
+
* @param {string} hex1
|
|
54
|
+
* @param {string} hex2
|
|
55
|
+
* @returns {number}
|
|
56
|
+
*/
|
|
57
|
+
export function getContrastRatio(hex1, hex2) {
|
|
58
|
+
const lum1 = getRelativeLuminance(hexToRgb(hex1))
|
|
59
|
+
const lum2 = getRelativeLuminance(hexToRgb(hex2))
|
|
60
|
+
const brightest = Math.max(lum1, lum2)
|
|
61
|
+
const darkest = Math.min(lum1, lum2)
|
|
62
|
+
const ratio = (brightest + 0.05) / (darkest + 0.05)
|
|
63
|
+
return Math.round(ratio * 100) / 100
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Extract palette sample colors from raw image bytes.
|
|
68
|
+
* Scans byte frequencies and samples color anchors across buffer.
|
|
69
|
+
* @param {Buffer} buffer
|
|
70
|
+
* @returns {string[]} Hex colors
|
|
71
|
+
*/
|
|
72
|
+
export function extractSampleColorsFromBuffer(buffer) {
|
|
73
|
+
if (!buffer || buffer.length < 16) {
|
|
74
|
+
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if buffer is SVG (UTF-8 text)
|
|
78
|
+
const head = buffer.slice(0, 256).toString('utf8').toLowerCase()
|
|
79
|
+
if (head.includes('<svg') || head.includes('<?xml')) {
|
|
80
|
+
const text = buffer.toString('utf8')
|
|
81
|
+
const hexMatches = text.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g)
|
|
82
|
+
if (hexMatches && hexMatches.length > 0) {
|
|
83
|
+
const counts = {}
|
|
84
|
+
for (const h of hexMatches) {
|
|
85
|
+
let full = h.toLowerCase()
|
|
86
|
+
if (full.length === 4) {
|
|
87
|
+
full = '#' + full[1] + full[1] + full[2] + full[2] + full[3] + full[3]
|
|
88
|
+
}
|
|
89
|
+
counts[full] = (counts[full] || 0) + 1
|
|
90
|
+
}
|
|
91
|
+
return Object.entries(counts)
|
|
92
|
+
.sort((a, b) => b[1] - a[1])
|
|
93
|
+
.slice(0, 6)
|
|
94
|
+
.map(([c]) => c)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Sample bytes from buffer chunks
|
|
99
|
+
const step = Math.max(4, Math.floor(buffer.length / 500))
|
|
100
|
+
const samples = []
|
|
101
|
+
for (let i = 64; i < buffer.length - 8; i += step) {
|
|
102
|
+
const r = buffer[i]
|
|
103
|
+
const g = buffer[i + 1]
|
|
104
|
+
const b = buffer[i + 2]
|
|
105
|
+
samples.push({ r, g, b })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (samples.length === 0) {
|
|
109
|
+
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
samples.sort((a, b) => (a.r * 299 + a.g * 587 + a.b * 114) - (b.r * 299 + b.g * 587 + b.b * 114))
|
|
113
|
+
|
|
114
|
+
const anchors = []
|
|
115
|
+
const bucketSize = Math.floor(samples.length / 6)
|
|
116
|
+
for (let b = 0; b < 6; b++) {
|
|
117
|
+
const start = b * bucketSize
|
|
118
|
+
const end = b === 5 ? samples.length : start + bucketSize
|
|
119
|
+
let sumR = 0, sumG = 0, sumB = 0
|
|
120
|
+
for (let j = start; j < end; j++) {
|
|
121
|
+
sumR += samples[j].r
|
|
122
|
+
sumG += samples[j].g
|
|
123
|
+
sumB += samples[j].b
|
|
124
|
+
}
|
|
125
|
+
const count = Math.max(1, end - start)
|
|
126
|
+
anchors.push(rgbToHex(sumR / count, sumG / count, sumB / count))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const unique = Array.from(new Set(anchors))
|
|
130
|
+
while (unique.length < 5) {
|
|
131
|
+
unique.push('#3b82f6')
|
|
132
|
+
}
|
|
133
|
+
return unique
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Issue #172: extract_design_tokens
|
|
138
|
+
* Generates CSS Variables, Tailwind color palette config, and W3C Design Tokens JSON.
|
|
139
|
+
*/
|
|
140
|
+
export function extractDesignTokens(colors, options = {}) {
|
|
141
|
+
const palette = colors && colors.length > 0 ? colors : ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
142
|
+
const prefix = options.prefix || 'color'
|
|
143
|
+
|
|
144
|
+
const bg = palette[0]
|
|
145
|
+
const primary = palette[1] || palette[0]
|
|
146
|
+
const secondary = palette[2] || '#64748b'
|
|
147
|
+
const accent = palette[3] || '#f59e0b'
|
|
148
|
+
const text = palette[palette.length - 1]
|
|
149
|
+
|
|
150
|
+
const tokens = {
|
|
151
|
+
background: bg,
|
|
152
|
+
primary,
|
|
153
|
+
secondary,
|
|
154
|
+
accent,
|
|
155
|
+
text,
|
|
156
|
+
palette,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let cssVariables = ':root {\n'
|
|
160
|
+
cssVariables += ` --${prefix}-background: ${bg};\n`
|
|
161
|
+
cssVariables += ` --${prefix}-primary: ${primary};\n`
|
|
162
|
+
cssVariables += ` --${prefix}-secondary: ${secondary};\n`
|
|
163
|
+
cssVariables += ` --${prefix}-accent: ${accent};\n`
|
|
164
|
+
cssVariables += ` --${prefix}-text: ${text};\n`
|
|
165
|
+
palette.forEach((col, idx) => {
|
|
166
|
+
cssVariables += ` --${prefix}-palette-${idx + 1}: ${col};\n`
|
|
167
|
+
})
|
|
168
|
+
cssVariables += '}'
|
|
169
|
+
|
|
170
|
+
const tailwindColors = {
|
|
171
|
+
[prefix]: {
|
|
172
|
+
background: bg,
|
|
173
|
+
primary,
|
|
174
|
+
secondary,
|
|
175
|
+
accent,
|
|
176
|
+
text,
|
|
177
|
+
...palette.reduce((acc, col, idx) => {
|
|
178
|
+
acc[`shade-${(idx + 1) * 100}`] = col
|
|
179
|
+
return acc
|
|
180
|
+
}, {}),
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
const tailwindSnippet = `// tailwind.config.js\nmodule.exports = {\n theme: {\n extend: {\n colors: ${JSON.stringify(tailwindColors, null, 2).replace(/\\n/g, '\n ')}\n }\n }\n}`
|
|
184
|
+
|
|
185
|
+
const w3cTokens = {
|
|
186
|
+
color: {
|
|
187
|
+
background: { $value: bg, $type: 'color' },
|
|
188
|
+
primary: { $value: primary, $type: 'color' },
|
|
189
|
+
secondary: { $value: secondary, $type: 'color' },
|
|
190
|
+
accent: { $value: accent, $type: 'color' },
|
|
191
|
+
text: { $value: text, $type: 'color' },
|
|
192
|
+
palette: palette.map((c, i) => ({
|
|
193
|
+
[`step-${i + 1}`]: { $value: c, $type: 'color' },
|
|
194
|
+
})),
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
tokens,
|
|
200
|
+
cssVariables,
|
|
201
|
+
tailwindSnippet,
|
|
202
|
+
w3cTokens,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Issue #174: image_to_css_gradient
|
|
208
|
+
* Converts image dominant colors into pure CSS mesh / radial / linear gradient (< 1KB).
|
|
209
|
+
*/
|
|
210
|
+
export function generateCssGradient(colors, type = 'mesh') {
|
|
211
|
+
const palette = colors && colors.length >= 2 ? colors : ['#0f172a', '#1e293b', '#3b82f6', '#06b6d4']
|
|
212
|
+
const c1 = palette[0]
|
|
213
|
+
const c2 = palette[1]
|
|
214
|
+
const c3 = palette[2] || palette[0]
|
|
215
|
+
const c4 = palette[3] || palette[1]
|
|
216
|
+
|
|
217
|
+
let css = ''
|
|
218
|
+
let fallbackColor = c1
|
|
219
|
+
|
|
220
|
+
if (type === 'linear') {
|
|
221
|
+
css = `linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c3} 100%)`
|
|
222
|
+
} else if (type === 'radial') {
|
|
223
|
+
css = `radial-gradient(circle at 30% 20%, ${c2} 0%, ${c1} 70%)`
|
|
224
|
+
} else {
|
|
225
|
+
css = [
|
|
226
|
+
`radial-gradient(at 0% 0%, ${c1} 0px, transparent 50%)`,
|
|
227
|
+
`radial-gradient(at 100% 0%, ${c2} 0px, transparent 50%)`,
|
|
228
|
+
`radial-gradient(at 100% 100%, ${c3} 0px, transparent 50%)`,
|
|
229
|
+
`radial-gradient(at 0% 100%, ${c4} 0px, transparent 50%)`,
|
|
230
|
+
`radial-gradient(at 50% 50%, ${c2}88 0px, transparent 50%)`,
|
|
231
|
+
fallbackColor,
|
|
232
|
+
].join(',\n ')
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const completeStyle = `background-color: ${fallbackColor};\nbackground-image:\n ${css};`
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
type,
|
|
239
|
+
fallbackColor,
|
|
240
|
+
gradientCss: css,
|
|
241
|
+
completeStyle,
|
|
242
|
+
byteSize: Buffer.byteLength(completeStyle, 'utf8'),
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Issue #175: check_image_contrast
|
|
248
|
+
* Calculates WCAG 2.1 contrast ratio and suggests overlay / scrim.
|
|
249
|
+
*/
|
|
250
|
+
export function checkWcagContrast(colors, textHex = '#ffffff') {
|
|
251
|
+
const palette = colors && colors.length > 0 ? colors : ['#0f172a']
|
|
252
|
+
const targetText = textHex.startsWith('#') ? textHex : `#${textHex}`
|
|
253
|
+
|
|
254
|
+
const ratios = palette.map((bgHex) => {
|
|
255
|
+
const ratio = getContrastRatio(bgHex, targetText)
|
|
256
|
+
const passAA = ratio >= 4.5
|
|
257
|
+
const passAALarge = ratio >= 3.0
|
|
258
|
+
const passAAA = ratio >= 7.0
|
|
259
|
+
return {
|
|
260
|
+
backgroundColor: bgHex,
|
|
261
|
+
contrastRatio: ratio,
|
|
262
|
+
passAA,
|
|
263
|
+
passAALarge,
|
|
264
|
+
passAAA,
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
const minRatio = Math.min(...ratios.map((r) => r.contrastRatio))
|
|
269
|
+
const passedAA = ratios.every((r) => r.passAA)
|
|
270
|
+
const passedAALarge = ratios.every((r) => r.passAALarge)
|
|
271
|
+
const passedAAA = ratios.every((r) => r.passAAA)
|
|
272
|
+
|
|
273
|
+
let recommendation = 'Contrast satisfies WCAG 2.1 AA standards.'
|
|
274
|
+
let scrimCss = ''
|
|
275
|
+
|
|
276
|
+
if (!passedAA) {
|
|
277
|
+
const lum = getRelativeLuminance(hexToRgb(targetText))
|
|
278
|
+
if (lum > 0.5) {
|
|
279
|
+
scrimCss = 'background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.7) 100%); backdrop-filter: blur(4px);'
|
|
280
|
+
recommendation = `Contrast ratio (${minRatio}:1) is below WCAG 2.1 AA (4.5:1). Apply dark overlay scrim or increase text weight/shadow.`
|
|
281
|
+
} else {
|
|
282
|
+
scrimCss = 'background: linear-gradient(180deg, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0.85) 100%); backdrop-filter: blur(4px);'
|
|
283
|
+
recommendation = `Contrast ratio (${minRatio}:1) is below WCAG 2.1 AA (4.5:1). Apply light overlay scrim.`
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
textHex: targetText,
|
|
289
|
+
minContrastRatio: minRatio,
|
|
290
|
+
passedAA,
|
|
291
|
+
passedAALarge,
|
|
292
|
+
passedAAA,
|
|
293
|
+
recommendation,
|
|
294
|
+
suggestedScrimCss: scrimCss,
|
|
295
|
+
details: ratios,
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Issue #176: optimize_vector_svg
|
|
301
|
+
* Cleans, sanitizes, and minifies SVG content, normalizes viewBox, and exports React TSX component.
|
|
302
|
+
*/
|
|
303
|
+
export function optimizeSvgContent(svgString, options = {}) {
|
|
304
|
+
if (!svgString || typeof svgString !== 'string') {
|
|
305
|
+
throw new Error('Invalid SVG string provided')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let cleaned = svgString.trim()
|
|
309
|
+
|
|
310
|
+
cleaned = cleaned.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
|
311
|
+
cleaned = cleaned.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
|
|
312
|
+
cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '')
|
|
313
|
+
|
|
314
|
+
cleaned = cleaned.replace(/\s*(?:xmlns:inkscape|xmlns:sodipodi|xmlns:sketch|inkscape:[a-z-]+|sodipodi:[a-z-]+|sketch:[a-z-]+)="[^"]*"/gi, '')
|
|
315
|
+
cleaned = cleaned.replace(/<(?:metadata|sodipodi:namedview)[\s\S]*?<\/(?:metadata|sodipodi:namedview)>/gi, '')
|
|
316
|
+
|
|
317
|
+
cleaned = cleaned.replace(/\s{2,}/g, ' ')
|
|
318
|
+
cleaned = cleaned.replace(/>\s+</g, '><')
|
|
319
|
+
|
|
320
|
+
const viewBoxMatch = cleaned.match(/viewBox="([^"]+)"/i)
|
|
321
|
+
let viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24'
|
|
322
|
+
|
|
323
|
+
if (!viewBoxMatch) {
|
|
324
|
+
const widthMatch = cleaned.match(/width="([0-9.]+)(?:px)?"/i)
|
|
325
|
+
const heightMatch = cleaned.match(/height="([0-9.]+)(?:px)?"/i)
|
|
326
|
+
if (widthMatch && heightMatch) {
|
|
327
|
+
viewBox = `0 0 ${widthMatch[1]} ${heightMatch[1]}`
|
|
328
|
+
cleaned = cleaned.replace(/<svg\b/i, `<svg viewBox="${viewBox}"`)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const originalSize = Buffer.byteLength(svgString, 'utf8')
|
|
333
|
+
const optimizedSize = Buffer.byteLength(cleaned, 'utf8')
|
|
334
|
+
const savedBytes = Math.max(0, originalSize - optimizedSize)
|
|
335
|
+
const reductionPercent = Math.round((savedBytes / (originalSize || 1)) * 100)
|
|
336
|
+
|
|
337
|
+
const componentName = options.componentName || 'VectorIcon'
|
|
338
|
+
let jsxBody = cleaned
|
|
339
|
+
.replace(/<svg\b[^>]*>/i, '')
|
|
340
|
+
.replace(/<\/svg>/i, '')
|
|
341
|
+
.replace(/class=/g, 'className=')
|
|
342
|
+
.replace(/clip-rule=/g, 'clipRule=')
|
|
343
|
+
.replace(/fill-rule=/g, 'fillRule=')
|
|
344
|
+
.replace(/stroke-width=/g, 'strokeWidth=')
|
|
345
|
+
.replace(/stroke-linecap=/g, 'strokeLinecap=')
|
|
346
|
+
.replace(/stroke-linejoin=/g, 'strokeLinejoin=')
|
|
347
|
+
.replace(/stroke-miterlimit=/g, 'strokeMiterlimit=')
|
|
348
|
+
|
|
349
|
+
const reactTsx = `import React from 'react';
|
|
350
|
+
|
|
351
|
+
export interface ${componentName}Props extends React.SVGProps<SVGSVGElement> {
|
|
352
|
+
size?: number | string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export const ${componentName}: React.FC<${componentName}Props> = ({ size = 24, className, ...props }) => (
|
|
356
|
+
<svg
|
|
357
|
+
viewBox="${viewBox}"
|
|
358
|
+
width={size}
|
|
359
|
+
height={size}
|
|
360
|
+
fill="currentColor"
|
|
361
|
+
className={className}
|
|
362
|
+
{...props}
|
|
363
|
+
>
|
|
364
|
+
${jsxBody.trim()}
|
|
365
|
+
</svg>
|
|
366
|
+
);
|
|
367
|
+
`
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
svg: cleaned,
|
|
371
|
+
viewBox,
|
|
372
|
+
originalSize,
|
|
373
|
+
optimizedSize,
|
|
374
|
+
savedBytes,
|
|
375
|
+
reductionPercent,
|
|
376
|
+
reactTsx,
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Issue #190: generate_pwa_icon_suite
|
|
382
|
+
* Generates standard PWA icon suite metadata, web app manifest.json, and icon configuration.
|
|
383
|
+
*/
|
|
384
|
+
export async function generatePwaIconSuite({
|
|
385
|
+
name = 'App',
|
|
386
|
+
shortName = 'App',
|
|
387
|
+
themeColor = '#0f172a',
|
|
388
|
+
backgroundColor = '#ffffff',
|
|
389
|
+
iconsDir = 'icons',
|
|
390
|
+
sourcePath = null,
|
|
391
|
+
} = {}) {
|
|
392
|
+
const iconSizes = [
|
|
393
|
+
{ size: 16, rel: 'icon', filename: `${iconsDir}/favicon-16x16.png`, purpose: 'any' },
|
|
394
|
+
{ size: 32, rel: 'icon', filename: `${iconsDir}/favicon-32x32.png`, purpose: 'any' },
|
|
395
|
+
{ size: 48, rel: 'icon', filename: `${iconsDir}/favicon-48x48.png`, purpose: 'any' },
|
|
396
|
+
{ size: 180, rel: 'apple-touch-icon', filename: `${iconsDir}/apple-touch-icon.png`, purpose: 'any' },
|
|
397
|
+
{ size: 192, rel: 'manifest', filename: `${iconsDir}/icon-192x192.png`, purpose: 'any' },
|
|
398
|
+
{ size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512.png`, purpose: 'any' },
|
|
399
|
+
{ size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512-maskable.png`, purpose: 'maskable' },
|
|
400
|
+
]
|
|
401
|
+
|
|
402
|
+
const manifest = {
|
|
403
|
+
name,
|
|
404
|
+
short_name: shortName,
|
|
405
|
+
start_url: '/',
|
|
406
|
+
display: 'standalone',
|
|
407
|
+
background_color: backgroundColor,
|
|
408
|
+
theme_color: themeColor,
|
|
409
|
+
icons: iconSizes
|
|
410
|
+
.filter((i) => i.rel === 'manifest')
|
|
411
|
+
.map((i) => ({
|
|
412
|
+
src: i.filename,
|
|
413
|
+
sizes: `${i.size}x${i.size}`,
|
|
414
|
+
type: 'image/png',
|
|
415
|
+
purpose: i.purpose,
|
|
416
|
+
})),
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const htmlHeadSnippet = [
|
|
420
|
+
`<link rel="icon" type="image/png" sizes="16x16" href="/${iconsDir}/favicon-16x16.png">`,
|
|
421
|
+
`<link rel="icon" type="image/png" sizes="32x32" href="/${iconsDir}/favicon-32x32.png">`,
|
|
422
|
+
`<link rel="apple-touch-icon" sizes="180x180" href="/${iconsDir}/apple-touch-icon.png">`,
|
|
423
|
+
`<link rel="manifest" href="/manifest.json">`,
|
|
424
|
+
`<meta name="theme-color" content="${themeColor}">`,
|
|
425
|
+
].join('\n')
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
name,
|
|
429
|
+
shortName,
|
|
430
|
+
themeColor,
|
|
431
|
+
backgroundColor,
|
|
432
|
+
icons: iconSizes,
|
|
433
|
+
manifestJson: JSON.stringify(manifest, null, 2),
|
|
434
|
+
htmlHeadSnippet,
|
|
435
|
+
}
|
|
436
|
+
}
|