@pyreon/zero 0.12.7 → 0.12.9

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.
@@ -29,6 +29,46 @@ function warnSharpMissing() {
29
29
  // import { Image } from "@pyreon/zero/image"
30
30
  // <Image src="/hero.jpg" width={1920} height={1080} optimize />
31
31
 
32
+ /**
33
+ * CDN provider — rewrites image URLs to CDN endpoints.
34
+ * Return the rewritten URL, or null to use local processing.
35
+ */
36
+ export type ImageCdnProvider = (src: string, opts: {
37
+ width: number
38
+ quality: number
39
+ format: ImageFormat
40
+ }) => string | null
41
+
42
+ /** Built-in CDN providers. */
43
+ export const cdnProviders = {
44
+ /** Cloudinary: `https://res.cloudinary.com/{cloud}/image/upload/...` */
45
+ cloudinary: (cloudName: string): ImageCdnProvider => (src, { width, quality, format }) =>
46
+ `https://res.cloudinary.com/${cloudName}/image/upload/w_${width},q_${quality},f_${format}/${src}`,
47
+
48
+ /** Imgix: `https://{domain}.imgix.net/...?w=...&q=...&fm=...` */
49
+ imgix: (domain: string): ImageCdnProvider => (src, { width, quality, format }) =>
50
+ `https://${domain}.imgix.net/${src}?w=${width}&q=${quality}&fm=${format}&auto=format`,
51
+
52
+ /** Vercel Image Optimization: `/_next/image?url=...&w=...&q=...` */
53
+ vercel: (): ImageCdnProvider => (src, { width, quality }) =>
54
+ `/_vercel/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`,
55
+
56
+ /** Bunny CDN: `https://{pullZone}.b-cdn.net/...?width=...&quality=...` */
57
+ bunny: (pullZone: string): ImageCdnProvider => (src, { width, quality }) =>
58
+ `https://${pullZone}.b-cdn.net/${src}?width=${width}&quality=${quality}`,
59
+ } as const
60
+
61
+ /** Placeholder generation strategy. */
62
+ export type PlaceholderStrategy = 'blur' | 'dominant-color' | 'none'
63
+
64
+ /** SVG processing options for ?component imports. */
65
+ export interface SvgOptions {
66
+ /** Replace fill/stroke with currentColor. Default: true */
67
+ currentColor?: boolean
68
+ /** Default size (width/height). */
69
+ defaultSize?: number
70
+ }
71
+
32
72
  export interface ImagePluginConfig {
33
73
  /** Output directory for processed images. Default: "assets/img" */
34
74
  outDir?: string
@@ -40,8 +80,31 @@ export interface ImagePluginConfig {
40
80
  quality?: number
41
81
  /** Blur placeholder size in px. Default: 16 */
42
82
  placeholderSize?: number
83
+ /** Placeholder strategy. Default: "blur" */
84
+ placeholder?: PlaceholderStrategy
43
85
  /** File patterns to process. Default: /\.(jpe?g|png|webp|avif)$/i */
44
86
  include?: RegExp
87
+ /**
88
+ * CDN provider for URL rewriting. When set, images are NOT processed
89
+ * locally — URLs are rewritten to the CDN endpoint.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * imagePlugin({ cdn: cdnProviders.cloudinary('my-cloud') })
94
+ * imagePlugin({ cdn: cdnProviders.vercel() })
95
+ * ```
96
+ */
97
+ cdn?: ImageCdnProvider
98
+ /**
99
+ * SVG processing options. Enables `?component` import for inline SVGs.
100
+ *
101
+ * @example
102
+ * ```tsx
103
+ * import Logo from './logo.svg?component'
104
+ * <Logo width={24} class="text-primary" />
105
+ * ```
106
+ */
107
+ svg?: SvgOptions | boolean
45
108
  }
46
109
 
47
110
  export type ImageFormat = 'webp' | 'avif' | 'jpeg' | 'png'
@@ -102,8 +165,15 @@ export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
102
165
  const defaultFormats = config.formats ?? ['webp']
103
166
  const quality = config.quality ?? 80
104
167
  const placeholderSize = config.placeholderSize ?? 16
168
+ const placeholderStrategy = config.placeholder ?? 'blur'
105
169
  const outSubDir = config.outDir ?? 'assets/img'
106
170
  const include = config.include ?? IMAGE_EXT_RE
171
+ const cdn = config.cdn
172
+ const svgOpts: SvgOptions | false = config.svg === true
173
+ ? { currentColor: true }
174
+ : config.svg === false || config.svg === undefined
175
+ ? false
176
+ : config.svg
107
177
 
108
178
  let root = ''
109
179
  let outDir = ''
@@ -120,6 +190,10 @@ export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
120
190
  },
121
191
 
122
192
  async resolveId(id) {
193
+ // SVG as component: import Logo from './logo.svg?component'
194
+ if (svgOpts && id.includes('?component') && id.split('?')[0]!.endsWith('.svg')) {
195
+ return `\0virtual:zero-svg:${id}`
196
+ }
123
197
  // Handle ?optimize query on image imports
124
198
  if (id.includes('?optimize') && include.test(id.split('?')[0]!)) {
125
199
  return `\0virtual:zero-image:${id}`
@@ -128,11 +202,80 @@ export function imagePlugin(config: ImagePluginConfig = {}): Plugin {
128
202
  },
129
203
 
130
204
  async load(id) {
205
+ // SVG component loading
206
+ if (id.startsWith('\0virtual:zero-svg:')) {
207
+ const rawPath = id.replace('\0virtual:zero-svg:', '').split('?')[0] ?? id
208
+ const absPath = rawPath.startsWith('/') ? join(root, rawPath) : rawPath
209
+ if (!existsSync(absPath)) return null
210
+
211
+ let svg = await readFile(absPath, 'utf-8')
212
+
213
+ // Replace fill/stroke with currentColor
214
+ if (svgOpts && (svgOpts as SvgOptions).currentColor !== false) {
215
+ svg = svg
216
+ .replace(/fill="(?!none)[^"]*"/g, 'fill="currentColor"')
217
+ .replace(/stroke="(?!none)[^"]*"/g, 'stroke="currentColor"')
218
+ }
219
+
220
+ // Add default size from config
221
+ const defaultSize = svgOpts && (svgOpts as SvgOptions).defaultSize
222
+ if (defaultSize && !svg.includes('width=')) {
223
+ svg = svg.replace('<svg', `<svg width="${defaultSize}" height="${defaultSize}"`)
224
+ }
225
+
226
+ // Export as Pyreon component
227
+ return `
228
+ import { h } from '@pyreon/core'
229
+ const _svg = ${JSON.stringify(svg)}
230
+ export default function SvgComponent(props) {
231
+ const el = h('span', {
232
+ ...props,
233
+ dangerouslySetInnerHTML: { __html: _svg },
234
+ style: [
235
+ 'display:inline-flex;align-items:center;justify-content:center',
236
+ props.width ? 'width:' + props.width + 'px' : '',
237
+ props.height ? 'height:' + props.height + 'px' : '',
238
+ props.style || '',
239
+ ].filter(Boolean).join(';'),
240
+ })
241
+ return el
242
+ }
243
+ `
244
+ }
245
+
246
+ // Image optimization loading
131
247
  if (!id.startsWith('\0virtual:zero-image:')) return null
132
248
 
133
249
  const rawPath = id.replace('\0virtual:zero-image:', '').split('?')[0] ?? id
134
250
  const absPath = rawPath.startsWith('/') ? join(root, 'public', rawPath) : rawPath
135
251
 
252
+ // CDN mode — rewrite URLs, no local processing
253
+ if (cdn) {
254
+ const metadata = await getImageMetadata(absPath)
255
+ const sources = defaultWidths.map((w) => ({
256
+ src: cdn(rawPath, { width: w, quality, format: defaultFormats[0]! }) ?? rawPath,
257
+ width: w,
258
+ format: defaultFormats[0]! as string,
259
+ }))
260
+ const srcset = sources.map((s) => `${s.src} ${s.width}w`).join(', ')
261
+ const result: ProcessedImage = {
262
+ src: sources[sources.length - 1]?.src ?? rawPath,
263
+ srcset,
264
+ width: metadata.width,
265
+ height: metadata.height,
266
+ placeholder: placeholderStrategy === 'none' ? ''
267
+ : await generateBlurPlaceholder(absPath, placeholderSize),
268
+ formats: defaultFormats.map((fmt) => ({
269
+ type: `image/${fmt}`,
270
+ srcset: defaultWidths
271
+ .map((w) => `${cdn(rawPath, { width: w, quality, format: fmt }) ?? rawPath} ${w}w`)
272
+ .join(', '),
273
+ })),
274
+ sources,
275
+ }
276
+ return `export default ${JSON.stringify(result)}`
277
+ }
278
+
136
279
  if (!isBuild) {
137
280
  const result = await loadDevImage(absPath, rawPath, placeholderSize)
138
281
  return `export default ${JSON.stringify(result)}`
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Type declarations for image imports processed by @pyreon/zero's imagePlugin.
3
+ *
4
+ * Add to your tsconfig.json:
5
+ * "types": ["@pyreon/zero/image-types"]
6
+ *
7
+ * Or reference directly:
8
+ * /// <reference types="@pyreon/zero/image-types" />
9
+ */
10
+
11
+ declare module '*.jpg?optimize' {
12
+ const image: import('./image-plugin').ProcessedImage
13
+ export default image
14
+ }
15
+
16
+ declare module '*.jpeg?optimize' {
17
+ const image: import('./image-plugin').ProcessedImage
18
+ export default image
19
+ }
20
+
21
+ declare module '*.png?optimize' {
22
+ const image: import('./image-plugin').ProcessedImage
23
+ export default image
24
+ }
25
+
26
+ declare module '*.webp?optimize' {
27
+ const image: import('./image-plugin').ProcessedImage
28
+ export default image
29
+ }
30
+
31
+ declare module '*.avif?optimize' {
32
+ const image: import('./image-plugin').ProcessedImage
33
+ export default image
34
+ }
35
+
36
+ declare module '*.svg?component' {
37
+ import type { ComponentFn } from '@pyreon/core'
38
+ const component: ComponentFn<{
39
+ width?: number
40
+ height?: number
41
+ class?: string
42
+ style?: string
43
+ [key: string]: unknown
44
+ }>
45
+ export default component
46
+ }
47
+
48
+ declare module '*.svg?raw' {
49
+ const svg: string
50
+ export default svg
51
+ }
package/src/server.ts CHANGED
@@ -57,9 +57,17 @@ export { render404Page } from "./not-found";
57
57
 
58
58
  export { compose, getContext } from "./middleware";
59
59
 
60
- // ─── Vite plugin ────────────────────────────────────────────────────────────
60
+ // ─── Vite plugins ───────────────────────────────────────────────────────────
61
61
 
62
62
  export { zeroPlugin as default } from "./vite-plugin";
63
+ export type { FaviconPluginConfig, FaviconLocaleConfig } from "./favicon";
64
+ export { faviconPlugin, faviconLinks } from "./favicon";
65
+ export type { SeoPluginConfig, SitemapConfig, RobotsConfig } from "./seo";
66
+ export { seoPlugin, generateSitemap, generateRobots, jsonLd, seoMiddleware } from "./seo";
67
+ export type { OgImagePluginConfig, OgImageTemplate, OgImageLayer } from "./og-image";
68
+ export { ogImagePlugin, ogImagePath } from "./og-image";
69
+ export type { AiPluginConfig, InferJsonLdOptions } from "./ai";
70
+ export { aiPlugin, inferJsonLd, generateLlmsTxt, generateLlmsFullTxt } from "./ai";
63
71
 
64
72
  // ─── I18n server-only ───────────────────────────────────────────────────────
65
73