@tristan2828/ui-foundation 4.16.0 → 4.18.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/vite/pwa.js ADDED
@@ -0,0 +1,230 @@
1
+ // `pwa()` from `@tristan2828/ui-foundation/vite`: the app installable (a web
2
+ // manifest and icons) and the service worker reminders arrive through
3
+ // (issue #167). An app's vite.config.ts:
4
+ //
5
+ // import buildInfo, { pwa } from '@tristan2828/ui-foundation/vite'
6
+ // plugins: [react(), tailwindcss(), buildInfo(), pwa({ name: 'Morning Routines', shortName: 'Routines' })]
7
+ //
8
+ // Serves and builds, at the site root:
9
+ // - /manifest.webmanifest: the name, `display: standalone` (which iOS needs
10
+ // before push works there), and the theme's colours.
11
+ // - /icons/: an SVG with the name's first letter (Chrome, Android) and
12
+ // plain PNGs in the theme colour (iOS's Home Screen icon must be a PNG,
13
+ // and drawing a letter into one needs a font rasteriser the package
14
+ // doesn't carry). `icon` replaces them all with the app's own PNG.
15
+ // - /push-sw.js: ./push-sw.js, showing pushes and opening their path.
16
+ // And adds the manifest, theme-color and apple-touch-icon tags to
17
+ // index.html. Registering the worker is usePushPermission's, on a click.
18
+ //
19
+ // Colours default to the package's tokens (styles/theme.css: --primary,
20
+ // --primary-foreground, --background), converted to hex as a manifest
21
+ // needs. An app that changed its tokens passes its own.
22
+
23
+ import fs from 'node:fs'
24
+ import path from 'node:path'
25
+ import { fileURLToPath } from 'node:url'
26
+ import zlib from 'node:zlib'
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url))
29
+ const THEME_CSS = path.join(HERE, '..', 'styles', 'theme.css')
30
+ const WORKER_SOURCE = path.join(HERE, 'push-sw.js')
31
+ const HEX = /^#[0-9a-f]{6}$/i
32
+
33
+ // ------------------------------------------------------------- colours
34
+
35
+ /** The value of `--name` in theme.css's :root block, var() followed. */
36
+ export function themeToken(css, name, seen = new Set()) {
37
+ const root = css.slice(css.indexOf(':root'), css.indexOf('.dark'))
38
+ const match = new RegExp(`--${name}:\\s*([^;]+);`).exec(root)
39
+ if (!match) throw new Error(`pwa: no --${name} in the theme.`)
40
+ const value = match[1].trim()
41
+ const reference = /^var\(--([\w-]+)\)$/.exec(value)
42
+ if (!reference) return value
43
+ if (seen.has(name)) throw new Error(`pwa: --${name} refers to itself.`)
44
+ seen.add(name)
45
+ return themeToken(css, reference[1], seen)
46
+ }
47
+
48
+ /** `oklch(L C H)` → `#rrggbb` (sRGB, clipped). */
49
+ export function oklchToHex(value) {
50
+ const match = /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*\)$/.exec(value.trim())
51
+ if (!match) throw new Error(`pwa: can't read ${value} as oklch(L C H); pass the colour as #rrggbb.`)
52
+ const l = Number(match[1]) / (match[2] ? 100 : 1)
53
+ const c = Number(match[3])
54
+ const h = (Number(match[4]) * Math.PI) / 180
55
+ const a = c * Math.cos(h)
56
+ const b = c * Math.sin(h)
57
+ // OKLab → LMS (cubed) → linear sRGB (Björn Ottosson's matrices).
58
+ const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3
59
+ const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3
60
+ const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3
61
+ const linear = [
62
+ 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
63
+ -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
64
+ -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_,
65
+ ]
66
+ const toByte = (x) => {
67
+ const clipped = Math.min(1, Math.max(0, x))
68
+ const gamma = clipped <= 0.0031308 ? 12.92 * clipped : 1.055 * clipped ** (1 / 2.4) - 0.055
69
+ return Math.round(gamma * 255)
70
+ }
71
+ return `#${linear.map((x) => toByte(x).toString(16).padStart(2, '0')).join('')}`
72
+ }
73
+
74
+ function colour(value, token) {
75
+ if (value !== undefined) {
76
+ if (!HEX.test(value)) throw new Error(`pwa: ${token} must be #rrggbb, not ${value}.`)
77
+ return value.toLowerCase()
78
+ }
79
+ return oklchToHex(themeToken(fs.readFileSync(THEME_CSS, 'utf8'), token))
80
+ }
81
+
82
+ // ---------------------------------------------------------------- icons
83
+
84
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
85
+ let c = n
86
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
87
+ return c >>> 0
88
+ })
89
+
90
+ function crc32(bytes) {
91
+ let crc = 0xffffffff
92
+ for (const byte of bytes) crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8)
93
+ return (crc ^ 0xffffffff) >>> 0
94
+ }
95
+
96
+ function chunk(type, data) {
97
+ const length = Buffer.alloc(4)
98
+ length.writeUInt32BE(data.length)
99
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
100
+ const crc = Buffer.alloc(4)
101
+ crc.writeUInt32BE(crc32(body))
102
+ return Buffer.concat([length, body, crc])
103
+ }
104
+
105
+ /** A `size`×`size` PNG of one colour (#rrggbb). */
106
+ export function solidPng(size, hex) {
107
+ const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16))
108
+ const header = Buffer.alloc(13)
109
+ header.writeUInt32BE(size, 0)
110
+ header.writeUInt32BE(size, 4)
111
+ header.set([8, 2, 0, 0, 0], 8) // 8-bit RGB, no interlace
112
+ const row = Buffer.alloc(1 + size * 3) // filter byte 0, then the pixels
113
+ for (let x = 0; x < size; x++) row.set(rgb, 1 + x * 3)
114
+ const pixels = zlib.deflateSync(Buffer.concat(Array.from({ length: size }, () => row)))
115
+ return Buffer.concat([
116
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
117
+ chunk('IHDR', header),
118
+ chunk('IDAT', pixels),
119
+ chunk('IEND', Buffer.alloc(0)),
120
+ ])
121
+ }
122
+
123
+ const escapeXml = (text) =>
124
+ text.replace(/[<>&'"]/g, (ch) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[ch])
125
+
126
+ /** A square SVG: the name's first letter on the theme colour. */
127
+ export function letterSvg(name, background, foreground) {
128
+ const letter = escapeXml([...name.trim()][0]?.toUpperCase() ?? '')
129
+ return (
130
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">` +
131
+ `<rect width="512" height="512" fill="${background}"/>` +
132
+ `<text x="256" y="256" dominant-baseline="central" text-anchor="middle" ` +
133
+ `font-family="system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="288" font-weight="600" ` +
134
+ `fill="${foreground}">${letter}</text></svg>\n`
135
+ )
136
+ }
137
+
138
+ // --------------------------------------------------------------- plugin
139
+
140
+ /**
141
+ * Everything pwa() serves, by path from the site root, for `base`.
142
+ * @param {import('./index').PwaOptions} options
143
+ */
144
+ export function pwaFiles(options, base = '/', root = process.cwd()) {
145
+ if (!options?.name?.trim()) throw new Error('pwa: `name` is required (the app as the Home Screen names it).')
146
+ const name = options.name.trim()
147
+ const shortName = options.shortName?.trim() || name
148
+ const theme = colour(options.themeColor, 'primary')
149
+ const onTheme = colour(options.themeForegroundColor, 'primary-foreground')
150
+ const background = colour(options.backgroundColor, 'background')
151
+ const at = (file) => `${base}${file}`
152
+
153
+ /** @type {Map<string, { type: string, source: string | Buffer }>} */
154
+ const files = new Map()
155
+ let icons
156
+ let touchIcon
157
+ if (options.icon) {
158
+ const own = path.resolve(root, options.icon)
159
+ if (!own.endsWith('.png') || !fs.existsSync(own)) throw new Error(`pwa: icon must be a PNG file; ${own} isn't one.`)
160
+ files.set('icons/icon.png', { type: 'image/png', source: fs.readFileSync(own) })
161
+ icons = [{ src: at('icons/icon.png'), sizes: '512x512', type: 'image/png', purpose: 'any' }]
162
+ touchIcon = 'icons/icon.png'
163
+ } else {
164
+ files.set('icons/icon.svg', { type: 'image/svg+xml', source: letterSvg(shortName, theme, onTheme) })
165
+ for (const size of [192, 512]) files.set(`icons/icon-${size}.png`, { type: 'image/png', source: solidPng(size, theme) })
166
+ files.set('icons/apple-touch-icon.png', { type: 'image/png', source: solidPng(180, theme) })
167
+ icons = [
168
+ { src: at('icons/icon.svg'), sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
169
+ { src: at('icons/icon-192.png'), sizes: '192x192', type: 'image/png', purpose: 'any' },
170
+ { src: at('icons/icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'any' },
171
+ // One colour edge to edge: safe to crop to any mask shape.
172
+ { src: at('icons/icon-512.png'), sizes: '512x512', type: 'image/png', purpose: 'maskable' },
173
+ ]
174
+ touchIcon = 'icons/apple-touch-icon.png'
175
+ }
176
+ const manifest = {
177
+ name,
178
+ short_name: shortName,
179
+ ...(options.description ? { description: options.description } : {}),
180
+ start_url: base,
181
+ scope: base,
182
+ display: 'standalone',
183
+ theme_color: theme,
184
+ background_color: background,
185
+ icons,
186
+ }
187
+ files.set('manifest.webmanifest', {
188
+ type: 'application/manifest+json',
189
+ source: `${JSON.stringify(manifest, null, 2)}\n`,
190
+ })
191
+ const worker = fs.readFileSync(WORKER_SOURCE, 'utf8').replace('__ICON__', options.icon ? at('icons/icon.png') : at('icons/icon-192.png'))
192
+ files.set('push-sw.js', { type: 'text/javascript', source: worker })
193
+ return {
194
+ files,
195
+ tags: [
196
+ { tag: 'link', attrs: { rel: 'manifest', href: at('manifest.webmanifest') }, injectTo: 'head' },
197
+ { tag: 'meta', attrs: { name: 'theme-color', content: theme }, injectTo: 'head' },
198
+ { tag: 'link', attrs: { rel: 'apple-touch-icon', href: at(touchIcon) }, injectTo: 'head' },
199
+ ],
200
+ }
201
+ }
202
+
203
+ /** @param {import('./index').PwaOptions} options */
204
+ export function pwa(options) {
205
+ if (!options?.name?.trim()) throw new Error('pwa: `name` is required (the app as the Home Screen names it).')
206
+ /** @type {ReturnType<typeof pwaFiles>} */
207
+ let built
208
+ return {
209
+ name: 'ui-foundation:pwa',
210
+ configResolved(config) {
211
+ built = pwaFiles(options, config.base, config.root)
212
+ },
213
+ configureServer(server) {
214
+ server.middlewares.use((req, res, next) => {
215
+ const base = server.config.base
216
+ const url = (req.url ?? '').split('?')[0]
217
+ const file = url.startsWith(base) ? built.files.get(url.slice(base.length)) : undefined
218
+ if (!file) return next()
219
+ res.setHeader('Content-Type', file.type)
220
+ res.end(file.source)
221
+ })
222
+ },
223
+ transformIndexHtml() {
224
+ return built.tags
225
+ },
226
+ generateBundle() {
227
+ for (const [fileName, file] of built.files) this.emitFile({ type: 'asset', fileName, source: file.source })
228
+ },
229
+ }
230
+ }