@pyreon/zero 0.1.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/lib/cache.js +80 -0
  4. package/lib/cache.js.map +1 -0
  5. package/lib/client.js +58 -0
  6. package/lib/client.js.map +1 -0
  7. package/lib/config.js +35 -0
  8. package/lib/config.js.map +1 -0
  9. package/lib/font.js +251 -0
  10. package/lib/font.js.map +1 -0
  11. package/lib/fs-router-BkbIWqek.js +30 -0
  12. package/lib/fs-router-BkbIWqek.js.map +1 -0
  13. package/lib/fs-router-jfd1QGLB.js +261 -0
  14. package/lib/fs-router-jfd1QGLB.js.map +1 -0
  15. package/lib/image-plugin.js +289 -0
  16. package/lib/image-plugin.js.map +1 -0
  17. package/lib/image.js +113 -0
  18. package/lib/image.js.map +1 -0
  19. package/lib/index.js +1665 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/link.js +186 -0
  22. package/lib/link.js.map +1 -0
  23. package/lib/script.js +102 -0
  24. package/lib/script.js.map +1 -0
  25. package/lib/seo.js +136 -0
  26. package/lib/seo.js.map +1 -0
  27. package/lib/theme.js +165 -0
  28. package/lib/theme.js.map +1 -0
  29. package/lib/types/adapters/bun.d.ts +6 -0
  30. package/lib/types/adapters/bun.d.ts.map +1 -0
  31. package/lib/types/adapters/index.d.ts +10 -0
  32. package/lib/types/adapters/index.d.ts.map +1 -0
  33. package/lib/types/adapters/node.d.ts +6 -0
  34. package/lib/types/adapters/node.d.ts.map +1 -0
  35. package/lib/types/adapters/static.d.ts +7 -0
  36. package/lib/types/adapters/static.d.ts.map +1 -0
  37. package/lib/types/app.d.ts +24 -0
  38. package/lib/types/app.d.ts.map +1 -0
  39. package/lib/types/cache.d.ts +54 -0
  40. package/lib/types/cache.d.ts.map +1 -0
  41. package/lib/types/client.d.ts +19 -0
  42. package/lib/types/client.d.ts.map +1 -0
  43. package/lib/types/config.d.ts +18 -0
  44. package/lib/types/config.d.ts.map +1 -0
  45. package/lib/types/entry-server.d.ts +26 -0
  46. package/lib/types/entry-server.d.ts.map +1 -0
  47. package/lib/types/font.d.ts +119 -0
  48. package/lib/types/font.d.ts.map +1 -0
  49. package/lib/types/fs-router.d.ts +33 -0
  50. package/lib/types/fs-router.d.ts.map +1 -0
  51. package/lib/types/image-plugin.d.ts +79 -0
  52. package/lib/types/image-plugin.d.ts.map +1 -0
  53. package/lib/types/image.d.ts +50 -0
  54. package/lib/types/image.d.ts.map +1 -0
  55. package/lib/types/index.d.ts +27 -0
  56. package/lib/types/index.d.ts.map +1 -0
  57. package/lib/types/isr.d.ts +9 -0
  58. package/lib/types/isr.d.ts.map +1 -0
  59. package/lib/types/link.d.ts +116 -0
  60. package/lib/types/link.d.ts.map +1 -0
  61. package/lib/types/script.d.ts +34 -0
  62. package/lib/types/script.d.ts.map +1 -0
  63. package/lib/types/seo.d.ts +88 -0
  64. package/lib/types/seo.d.ts.map +1 -0
  65. package/lib/types/theme.d.ts +38 -0
  66. package/lib/types/theme.d.ts.map +1 -0
  67. package/lib/types/types.d.ts +104 -0
  68. package/lib/types/types.d.ts.map +1 -0
  69. package/lib/types/utils/use-intersection-observer.d.ts +10 -0
  70. package/lib/types/utils/use-intersection-observer.d.ts.map +1 -0
  71. package/lib/types/utils/with-headers.d.ts +6 -0
  72. package/lib/types/utils/with-headers.d.ts.map +1 -0
  73. package/lib/types/vite-plugin.d.ts +17 -0
  74. package/lib/types/vite-plugin.d.ts.map +1 -0
  75. package/package.json +100 -0
  76. package/src/adapters/bun.ts +65 -0
  77. package/src/adapters/index.ts +29 -0
  78. package/src/adapters/node.ts +113 -0
  79. package/src/adapters/static.ts +17 -0
  80. package/src/app.ts +62 -0
  81. package/src/cache.ts +149 -0
  82. package/src/client.ts +43 -0
  83. package/src/config.ts +36 -0
  84. package/src/entry-server.ts +51 -0
  85. package/src/font.ts +461 -0
  86. package/src/fs-router.ts +380 -0
  87. package/src/image-plugin.ts +452 -0
  88. package/src/image.tsx +167 -0
  89. package/src/index.ts +119 -0
  90. package/src/isr.ts +95 -0
  91. package/src/link.tsx +266 -0
  92. package/src/script.tsx +133 -0
  93. package/src/seo.ts +281 -0
  94. package/src/sharp.d.ts +20 -0
  95. package/src/theme.tsx +162 -0
  96. package/src/types.ts +130 -0
  97. package/src/utils/use-intersection-observer.ts +36 -0
  98. package/src/utils/with-headers.ts +16 -0
  99. package/src/vite-plugin.ts +92 -0
package/src/font.ts ADDED
@@ -0,0 +1,461 @@
1
+ import type { Plugin } from 'vite'
2
+
3
+ // ─── Font optimization ──────────────────────────────────────────────────────
4
+ //
5
+ // Zero provides automatic font optimization:
6
+ // - Downloads and self-hosts Google Fonts at build time (privacy + performance)
7
+ // - Falls back to CDN link in dev mode (for fast dev startup)
8
+ // - Injects preconnect/preload hints into the HTML
9
+ // - Sets font-display: swap to prevent FOIT (Flash of Invisible Text)
10
+ // - Generates optimized @font-face declarations
11
+ // - Size-adjusted fallback fonts to reduce CLS
12
+
13
+ export interface FontConfig {
14
+ /**
15
+ * Google Fonts families.
16
+ *
17
+ * Accepts both string shorthand and structured objects:
18
+ * - String: "Inter:wght@400;500;700" or "Inter:wght@100..900"
19
+ * - Object: { family: "Inter", weights: [400, 500, 700] }
20
+ * - Variable: { family: "Inter", variable: true, weightRange: [100, 900] }
21
+ */
22
+ google?: GoogleFontInput[]
23
+ /** Local font files. */
24
+ local?: LocalFont[]
25
+ /** Default font-display strategy. Default: "swap" */
26
+ display?: FontDisplay
27
+ /** Preload critical fonts. Default: true */
28
+ preload?: boolean
29
+ /** Self-host Google Fonts at build time. Default: true */
30
+ selfHost?: boolean
31
+ /** Fallback font metrics for reducing CLS. */
32
+ fallbacks?: Record<string, FallbackMetrics>
33
+ }
34
+
35
+ /** Static Google Font config. */
36
+ export interface GoogleFontStatic {
37
+ family: string
38
+ weights: number[]
39
+ italic?: boolean
40
+ variable?: false
41
+ }
42
+
43
+ /** Variable Google Font config. */
44
+ export interface GoogleFontVariable {
45
+ family: string
46
+ /** Weight range as [min, max] tuple. e.g. [100, 900] */
47
+ weightRange: [number, number]
48
+ italic?: boolean
49
+ variable: true
50
+ }
51
+
52
+ /** Google font input: structured object or string shorthand. */
53
+ export type GoogleFontInput = GoogleFontStatic | GoogleFontVariable | string
54
+
55
+ export interface LocalFont {
56
+ family: string
57
+ src: string
58
+ /** Single weight (400) or variable range ("100 900"). */
59
+ weight?: number | `${number} ${number}`
60
+ style?: 'normal' | 'italic'
61
+ display?: FontDisplay
62
+ }
63
+
64
+ export type FontDisplay = 'auto' | 'block' | 'swap' | 'fallback' | 'optional'
65
+
66
+ /** Metrics for generating size-adjusted fallback fonts to reduce CLS. */
67
+ export interface FallbackMetrics {
68
+ /** The fallback font to adjust. e.g. "Arial", "Georgia" */
69
+ fallback: string
70
+ /** Size adjustment factor. e.g. 1.05 */
71
+ sizeAdjust?: number
72
+ /** Ascent override percentage. e.g. 90 */
73
+ ascentOverride?: number
74
+ /** Descent override percentage. e.g. 22 */
75
+ descentOverride?: number
76
+ /** Line gap override percentage. e.g. 0 */
77
+ lineGapOverride?: number
78
+ }
79
+
80
+ interface ResolvedFontBase {
81
+ family: string
82
+ italic: boolean
83
+ }
84
+
85
+ interface StaticFont extends ResolvedFontBase {
86
+ variable: false
87
+ weights: number[]
88
+ }
89
+
90
+ interface VariableFont extends ResolvedFontBase {
91
+ variable: true
92
+ weightRange: [number, number]
93
+ }
94
+
95
+ type ResolvedFont = StaticFont | VariableFont
96
+
97
+ /**
98
+ * Normalize a GoogleFontInput (string or object) into a ResolvedFont.
99
+ */
100
+ export function resolveGoogleFont(input: GoogleFontInput): ResolvedFont {
101
+ if (typeof input === 'string') {
102
+ return parseGoogleFamily(input)
103
+ }
104
+
105
+ if (input.variable) {
106
+ return {
107
+ family: input.family,
108
+ italic: input.italic ?? false,
109
+ variable: true,
110
+ weightRange: input.weightRange,
111
+ }
112
+ }
113
+
114
+ return {
115
+ family: input.family,
116
+ italic: input.italic ?? false,
117
+ variable: false,
118
+ weights: input.weights,
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Parse Google Fonts family string shorthand.
124
+ *
125
+ * Static weights: "Inter:wght@400;500;700"
126
+ * Variable range: "Inter:wght@100..900"
127
+ * Variable with italic: "Inter:ital,wght@100..900"
128
+ */
129
+ export function parseGoogleFamily(input: string): ResolvedFont {
130
+ const [familyPart, spec] = input.split(':')
131
+ const family = familyPart?.trim()
132
+ let italic = false
133
+
134
+ if (spec) {
135
+ italic = spec.includes('ital')
136
+
137
+ // Variable font range syntax: wght@100..900
138
+ const rangeMatch = spec.match(/wght@(\d+)\.\.(\d+)/)
139
+ if (rangeMatch) {
140
+ return {
141
+ family,
142
+ italic,
143
+ variable: true,
144
+ weightRange: [Number(rangeMatch[1]), Number(rangeMatch[2])],
145
+ }
146
+ }
147
+
148
+ // Static weights: wght@400;500;700
149
+ const weightMatch = spec.match(/wght@([\d;]+)/)
150
+ if (weightMatch) {
151
+ return {
152
+ family,
153
+ italic,
154
+ variable: false,
155
+ weights: weightMatch[1]?.split(';').map(Number),
156
+ }
157
+ }
158
+ }
159
+
160
+ return { family, italic, variable: false, weights: [400] }
161
+ }
162
+
163
+ /**
164
+ * Generate a Google Fonts CSS URL.
165
+ */
166
+ export function googleFontsUrl(
167
+ families: ResolvedFont[],
168
+ display: FontDisplay = 'swap',
169
+ ): string {
170
+ const params = families
171
+ .map((f) => {
172
+ const axes = f.italic ? 'ital,wght' : 'wght'
173
+ const name = f.family.replace(/ /g, '+')
174
+
175
+ if (f.variable) {
176
+ const range = `${f.weightRange[0]}..${f.weightRange[1]}`
177
+ const value = f.italic ? `0,${range};1,${range}` : range
178
+ return `family=${name}:${axes}@${value}`
179
+ }
180
+
181
+ const values = f.weights
182
+ .map((w) => (f.italic ? `0,${w};1,${w}` : String(w)))
183
+ .join(';')
184
+ return `family=${name}:${axes}@${values}`
185
+ })
186
+ .join('&')
187
+
188
+ return `https://fonts.googleapis.com/css2?${params}&display=${display}`
189
+ }
190
+
191
+ /**
192
+ * Generate @font-face CSS for local fonts.
193
+ */
194
+ function localFontFaces(fonts: LocalFont[], display: FontDisplay): string {
195
+ return fonts
196
+ .map(
197
+ (f) => `@font-face {
198
+ font-family: "${f.family}";
199
+ src: url("${f.src}");
200
+ font-weight: ${f.weight ?? '400'};
201
+ font-style: ${f.style ?? 'normal'};
202
+ font-display: ${f.display ?? display};
203
+ }`,
204
+ )
205
+ .join('\n\n')
206
+ }
207
+
208
+ /**
209
+ * Generate size-adjusted fallback @font-face declarations to reduce CLS.
210
+ */
211
+ function fallbackFontFaces(fallbacks: Record<string, FallbackMetrics>): string {
212
+ return Object.entries(fallbacks)
213
+ .map(([family, metrics]) => {
214
+ const overrides: string[] = []
215
+ if (metrics.sizeAdjust != null)
216
+ overrides.push(` size-adjust: ${metrics.sizeAdjust * 100}%;`)
217
+ if (metrics.ascentOverride != null)
218
+ overrides.push(` ascent-override: ${metrics.ascentOverride}%;`)
219
+ if (metrics.descentOverride != null)
220
+ overrides.push(` descent-override: ${metrics.descentOverride}%;`)
221
+ if (metrics.lineGapOverride != null)
222
+ overrides.push(` line-gap-override: ${metrics.lineGapOverride}%;`)
223
+
224
+ return `@font-face {
225
+ font-family: "${family} Fallback";
226
+ src: local("${metrics.fallback}");
227
+ ${overrides.join('\n')}
228
+ }`
229
+ })
230
+ .join('\n\n')
231
+ }
232
+
233
+ /**
234
+ * Generate preload link tags for critical font files.
235
+ */
236
+ function preloadTags(fonts: LocalFont[]): string {
237
+ return fonts
238
+ .map((f) => {
239
+ const ext = f.src.split('.').pop()
240
+ const type =
241
+ ext === 'woff2'
242
+ ? 'font/woff2'
243
+ : ext === 'woff'
244
+ ? 'font/woff'
245
+ : ext === 'ttf'
246
+ ? 'font/ttf'
247
+ : 'font/otf'
248
+ return `<link rel="preload" href="${f.src}" as="font" type="${type}" crossorigin>`
249
+ })
250
+ .join('\n')
251
+ }
252
+
253
+ /**
254
+ * Download Google Fonts CSS with woff2 user agent.
255
+ */
256
+ async function downloadGoogleFontsCSS(url: string): Promise<string> {
257
+ const response = await fetch(url, {
258
+ headers: {
259
+ 'User-Agent':
260
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
261
+ },
262
+ })
263
+ if (!response.ok) {
264
+ throw new Error(`Failed to fetch Google Fonts CSS: ${response.status}`)
265
+ }
266
+ return response.text()
267
+ }
268
+
269
+ /**
270
+ * Download a font file.
271
+ */
272
+ async function downloadFontFile(url: string): Promise<Buffer> {
273
+ const response = await fetch(url)
274
+ if (!response.ok) throw new Error(`Failed to download font: ${url}`)
275
+ const arrayBuffer = await response.arrayBuffer()
276
+ return Buffer.from(arrayBuffer)
277
+ }
278
+
279
+ /**
280
+ * Extract font file URLs from Google Fonts CSS.
281
+ */
282
+ function extractFontUrls(css: string): string[] {
283
+ const urls: string[] = []
284
+ const regex = /url\((https:\/\/fonts\.gstatic\.com\/[^)]+)\)/g
285
+ for (const match of css.matchAll(regex)) {
286
+ if (match[1]) urls.push(match[1])
287
+ }
288
+ return urls
289
+ }
290
+
291
+ /**
292
+ * Self-host Google Fonts: download CSS + font files, rewrite URLs to local paths.
293
+ */
294
+ async function selfHostFonts(
295
+ cssUrl: string,
296
+ fontsSubDir: string,
297
+ ): Promise<{
298
+ css: string
299
+ fontFiles: Array<{ name: string; content: Buffer }>
300
+ }> {
301
+ const css = await downloadGoogleFontsCSS(cssUrl)
302
+ const fontUrls = extractFontUrls(css)
303
+ const fontFiles: Array<{ name: string; content: Buffer }> = []
304
+
305
+ let rewrittenCss = css
306
+
307
+ for (const url of fontUrls) {
308
+ const urlParts = url.split('/')
309
+ const fileName = urlParts.at(-1)?.split('?')[0] ?? 'font'
310
+ const content = await downloadFontFile(url)
311
+
312
+ fontFiles.push({ name: fileName, content })
313
+ rewrittenCss = rewrittenCss.replace(url, `/${fontsSubDir}/${fileName}`)
314
+ }
315
+
316
+ return { css: rewrittenCss, fontFiles }
317
+ }
318
+
319
+ /**
320
+ * Zero font optimization Vite plugin.
321
+ *
322
+ * Dev mode: injects Google Fonts CDN link for fast startup.
323
+ * Build mode: downloads and self-hosts fonts for maximum performance + privacy.
324
+ *
325
+ * @example
326
+ * import { fontPlugin } from "@pyreon/zero/font"
327
+ *
328
+ * export default {
329
+ * plugins: [
330
+ * pyreon(),
331
+ * zero(),
332
+ * fontPlugin({
333
+ * google: ["Inter:wght@400;500;600;700", "JetBrains Mono:wght@400"],
334
+ * fallbacks: {
335
+ * "Inter": { fallback: "Arial", sizeAdjust: 1.07, ascentOverride: 90 },
336
+ * },
337
+ * }),
338
+ * ],
339
+ * }
340
+ */
341
+ export function fontPlugin(config: FontConfig = {}): Plugin {
342
+ const display = config.display ?? 'swap'
343
+ const shouldPreload = config.preload !== false
344
+ const shouldSelfHost = config.selfHost !== false
345
+ const googleFamilies = (config.google ?? []).map(resolveGoogleFont)
346
+
347
+ let isBuild = false
348
+ let selfHostedCSS = ''
349
+ let selfHostedFontFiles: Array<{ name: string; content: Buffer }> = []
350
+
351
+ return {
352
+ name: 'pyreon-zero-fonts',
353
+
354
+ configResolved(resolvedConfig) {
355
+ isBuild = resolvedConfig.command === 'build'
356
+ },
357
+
358
+ async buildStart() {
359
+ if (isBuild && shouldSelfHost && googleFamilies.length > 0) {
360
+ const cssUrl = googleFontsUrl(googleFamilies, display)
361
+ try {
362
+ const result = await selfHostFonts(cssUrl, 'assets/fonts')
363
+ selfHostedCSS = result.css
364
+ selfHostedFontFiles = result.fontFiles
365
+ } catch {
366
+ // Self-hosting failed — fall back to CDN link
367
+ }
368
+ }
369
+ },
370
+
371
+ generateBundle() {
372
+ // Emit self-hosted font files as assets
373
+ for (const file of selfHostedFontFiles) {
374
+ this.emitFile({
375
+ type: 'asset',
376
+ fileName: `assets/fonts/${file.name}`,
377
+ source: file.content,
378
+ })
379
+ }
380
+ },
381
+
382
+ transformIndexHtml(html) {
383
+ const tags: string[] = []
384
+
385
+ collectGoogleFontTags(tags, {
386
+ isBuild,
387
+ selfHostedCSS,
388
+ selfHostedFontFiles,
389
+ shouldPreload,
390
+ googleFamilies,
391
+ display,
392
+ })
393
+ collectLocalFontTags(tags, config, shouldPreload, display)
394
+
395
+ if (tags.length === 0) return html
396
+ return html.replace('</head>', `${tags.join('\n')}\n</head>`)
397
+ },
398
+ }
399
+ }
400
+
401
+ function collectGoogleFontTags(
402
+ tags: string[],
403
+ opts: {
404
+ isBuild: boolean
405
+ selfHostedCSS: string
406
+ selfHostedFontFiles: Array<{ name: string; content: Buffer }>
407
+ shouldPreload: boolean
408
+ googleFamilies: ResolvedFont[]
409
+ display: FontDisplay
410
+ },
411
+ ) {
412
+ if (opts.isBuild && opts.selfHostedCSS) {
413
+ tags.push(`<style>${opts.selfHostedCSS}</style>`)
414
+ if (opts.shouldPreload) {
415
+ for (const file of opts.selfHostedFontFiles.slice(
416
+ 0,
417
+ opts.googleFamilies.length,
418
+ )) {
419
+ const ext = file.name.split('.').pop()
420
+ const type = ext === 'woff2' ? 'font/woff2' : 'font/woff'
421
+ tags.push(
422
+ `<link rel="preload" href="/assets/fonts/${file.name}" as="font" type="${type}" crossorigin>`,
423
+ )
424
+ }
425
+ }
426
+ } else if (opts.googleFamilies.length > 0) {
427
+ const cssUrl = googleFontsUrl(opts.googleFamilies, opts.display)
428
+ tags.push(`<link rel="preconnect" href="https://fonts.googleapis.com">`)
429
+ tags.push(
430
+ `<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>`,
431
+ )
432
+ tags.push(`<link rel="stylesheet" href="${cssUrl}">`)
433
+ }
434
+ }
435
+
436
+ function collectLocalFontTags(
437
+ tags: string[],
438
+ config: FontConfig,
439
+ shouldPreload: boolean,
440
+ display: FontDisplay,
441
+ ) {
442
+ if (shouldPreload && config.local?.length) {
443
+ tags.push(preloadTags(config.local))
444
+ }
445
+ if (config.local?.length) {
446
+ tags.push(`<style>${localFontFaces(config.local, display)}</style>`)
447
+ }
448
+ if (config.fallbacks && Object.keys(config.fallbacks).length > 0) {
449
+ tags.push(`<style>${fallbackFontFaces(config.fallbacks)}</style>`)
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Generate CSS variables for font families.
455
+ */
456
+ export function fontVariables(families: Record<string, string>): string {
457
+ const vars = Object.entries(families)
458
+ .map(([key, value]) => ` --font-${key}: ${value};`)
459
+ .join('\n')
460
+ return `:root {\n${vars}\n}`
461
+ }