@pyreon/zero 0.22.0 → 0.24.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 +211 -57
- package/lib/_chunks/app-BbPT0Y5M.js +36 -0
- package/lib/{fs-router-Bacdhsq-.js → _chunks/fs-router-DvBlRzmP.js} +21 -5
- package/lib/_chunks/use-intersection-observer-C6opeplh.js +29 -0
- package/lib/actions.js +24 -3
- package/lib/ai.js +1 -102
- package/lib/client.js +3 -33
- package/lib/csp.js +12 -9
- package/lib/favicon.js +1 -1
- package/lib/font.js +1 -1
- package/lib/image-plugin.js +1 -1
- package/lib/image.js +3 -27
- package/lib/index.js +8 -1085
- package/lib/link.js +3 -27
- package/lib/meta.js +1 -25
- package/lib/script.js +2 -26
- package/lib/seo.js +4 -4
- package/lib/server.js +275 -2129
- package/lib/testing.js +1 -69
- package/lib/theme.js +52 -22
- package/lib/types/config.d.ts +115 -0
- package/lib/types/csp.d.ts +9 -1
- package/lib/types/index.d.ts +120 -1
- package/lib/types/server.d.ts +192 -17
- package/lib/types/theme.d.ts +11 -2
- package/package.json +10 -10
- package/src/actions.ts +43 -5
- package/src/adapters/bun.ts +35 -7
- package/src/adapters/cloudflare.ts +17 -12
- package/src/adapters/netlify.ts +7 -1
- package/src/adapters/node.ts +33 -6
- package/src/adapters/vercel.ts +25 -4
- package/src/csp.ts +10 -7
- package/src/fs-router.ts +2 -1
- package/src/isr.ts +256 -51
- package/src/manifest.ts +23 -10
- package/src/server.ts +2 -1
- package/src/ssg-plugin.ts +27 -7
- package/src/theme.tsx +94 -38
- package/src/types.ts +76 -0
- package/lib/api-routes-CMsLztoj.js +0 -148
- package/lib/fs-router-3xzp-4Wj.js +0 -32
- package/lib/rolldown-runtime-CjeV3_4I.js +0 -18
package/lib/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { Image, createImage, useImage } from "./image.js";
|
|
2
|
+
import { Link, createLink, prefetchRoute, useLink } from "./link.js";
|
|
3
|
+
import { Script, createScript, useScript } from "./script.js";
|
|
4
|
+
import { buildLocalePath, extractLocaleFromPath, setLocale, useLocale } from "./i18n-routing.js";
|
|
5
|
+
import { Meta, buildMetaTags } from "./meta.js";
|
|
6
|
+
import { ThemeToggle, initTheme, resolvedTheme, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme } from "./theme.js";
|
|
7
|
+
import { splitProps } from "@pyreon/core";
|
|
8
|
+
import { jsx } from "@pyreon/core/jsx-runtime";
|
|
6
9
|
|
|
7
10
|
//#region src/icon.tsx
|
|
8
11
|
const FILL_STYLE = "display:block;width:100%;height:100%";
|
|
@@ -96,1086 +99,6 @@ function createNamedIcon(registry, options = {}) {
|
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
//#endregion
|
|
100
|
-
//#region src/utils/use-intersection-observer.ts
|
|
101
|
-
/**
|
|
102
|
-
* Observes an element and calls `onIntersect` once it enters the viewport.
|
|
103
|
-
* Automatically disconnects after the first intersection.
|
|
104
|
-
*
|
|
105
|
-
* @param getElement - Getter for the target element (may be undefined before mount).
|
|
106
|
-
* @param onIntersect - Callback fired when the element becomes visible.
|
|
107
|
-
* @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
|
|
108
|
-
*/
|
|
109
|
-
function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px") {
|
|
110
|
-
onMount(() => {
|
|
111
|
-
const el = getElement();
|
|
112
|
-
if (!el) return void 0;
|
|
113
|
-
const observer = new IntersectionObserver((entries) => {
|
|
114
|
-
for (const entry of entries) if (entry.isIntersecting) {
|
|
115
|
-
onIntersect();
|
|
116
|
-
observer.disconnect();
|
|
117
|
-
}
|
|
118
|
-
}, { rootMargin });
|
|
119
|
-
observer.observe(el);
|
|
120
|
-
onUnmount(() => observer.disconnect());
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
//#endregion
|
|
125
|
-
//#region src/image.tsx
|
|
126
|
-
/**
|
|
127
|
-
* Composable that provides all image optimization behavior — lazy loading,
|
|
128
|
-
* srcset/sizes resolution, format selection, blur-placeholder state,
|
|
129
|
-
* load tracking.
|
|
130
|
-
*
|
|
131
|
-
* Use this for full control when `createImage` is too opinionated about
|
|
132
|
-
* the surrounding markup (e.g. custom container layouts, non-`<div>`
|
|
133
|
-
* wrappers, additional overlay elements).
|
|
134
|
-
*
|
|
135
|
-
* @example
|
|
136
|
-
* function MyImage(props: ImageProps) {
|
|
137
|
-
* const img = useImage(props)
|
|
138
|
-
* return (
|
|
139
|
-
* <figure ref={img.containerRef} style={img.containerStyle}>
|
|
140
|
-
* <img
|
|
141
|
-
* src={img.src}
|
|
142
|
-
* srcSet={img.srcSet}
|
|
143
|
-
* sizes={img.sizes}
|
|
144
|
-
* alt={props.alt}
|
|
145
|
-
* loading={img.loading}
|
|
146
|
-
* onLoad={img.handleLoad}
|
|
147
|
-
* style={img.imageStyle}
|
|
148
|
-
* />
|
|
149
|
-
* <figcaption>{props.alt}</figcaption>
|
|
150
|
-
* </figure>
|
|
151
|
-
* )
|
|
152
|
-
* }
|
|
153
|
-
*/
|
|
154
|
-
function useImage(props) {
|
|
155
|
-
const isEager = props.priority || props.loading === "eager";
|
|
156
|
-
const loaded = signal(isEager);
|
|
157
|
-
const inView = signal(isEager);
|
|
158
|
-
const containerRef = createRef();
|
|
159
|
-
const resolvedSrcset = typeof props.srcset === "string" ? props.srcset : props.srcset?.map((s) => `${s.src} ${s.width}w`).join(", ");
|
|
160
|
-
const sizes = props.sizes ?? "100vw";
|
|
161
|
-
const fit = props.fit ?? "cover";
|
|
162
|
-
const hasFormats = !!(props.formats && props.formats.length > 0);
|
|
163
|
-
const aspectRatio = `${props.width} / ${props.height}`;
|
|
164
|
-
if (!isEager) useIntersectionObserver(() => containerRef.current ?? void 0, () => inView.set(true));
|
|
165
|
-
const containerStyle = [
|
|
166
|
-
"position: relative",
|
|
167
|
-
"overflow: hidden",
|
|
168
|
-
`aspect-ratio: ${aspectRatio}`,
|
|
169
|
-
`max-width: ${props.width}px`,
|
|
170
|
-
"width: 100%",
|
|
171
|
-
props.style
|
|
172
|
-
].filter(Boolean).join("; ");
|
|
173
|
-
const imageStyle = () => [
|
|
174
|
-
"display: block",
|
|
175
|
-
"width: 100%",
|
|
176
|
-
"height: 100%",
|
|
177
|
-
`object-fit: ${fit}`,
|
|
178
|
-
"transition: opacity 0.3s ease",
|
|
179
|
-
props.placeholder && !loaded() ? "opacity: 0" : "opacity: 1"
|
|
180
|
-
].join("; ");
|
|
181
|
-
const placeholderStyle = () => [
|
|
182
|
-
"position: absolute",
|
|
183
|
-
"inset: 0",
|
|
184
|
-
"width: 100%",
|
|
185
|
-
"height: 100%",
|
|
186
|
-
"object-fit: cover",
|
|
187
|
-
"filter: blur(20px)",
|
|
188
|
-
"transform: scale(1.1)",
|
|
189
|
-
"transition: opacity 0.4s ease",
|
|
190
|
-
loaded() ? "opacity: 0; pointer-events: none" : "opacity: 1"
|
|
191
|
-
].join("; ");
|
|
192
|
-
return {
|
|
193
|
-
containerRef,
|
|
194
|
-
inView,
|
|
195
|
-
loaded,
|
|
196
|
-
src: () => inView() ? props.src : "",
|
|
197
|
-
srcSet: () => !hasFormats && inView() && resolvedSrcset ? resolvedSrcset : "",
|
|
198
|
-
sizes: resolvedSrcset ? sizes : void 0,
|
|
199
|
-
aspectRatio,
|
|
200
|
-
containerStyle,
|
|
201
|
-
imageStyle,
|
|
202
|
-
placeholderStyle,
|
|
203
|
-
loading: isEager ? "eager" : "lazy",
|
|
204
|
-
fetchPriority: props.priority ? "high" : void 0,
|
|
205
|
-
handleLoad: () => loaded.set(true),
|
|
206
|
-
formats: props.formats,
|
|
207
|
-
hasFormats
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* Higher-order component that wraps any component with image optimization.
|
|
212
|
-
*
|
|
213
|
-
* The wrapped component receives {@link ImageRenderProps} with the pre-rendered
|
|
214
|
-
* `image` JSX (bare `<img>` OR `<picture>` tree depending on formats), the
|
|
215
|
-
* pre-rendered `placeholder` JSX, and the container ref + styles. Consumers
|
|
216
|
-
* compose those pieces with whatever wrapper element / layout they want.
|
|
217
|
-
*
|
|
218
|
-
* @example
|
|
219
|
-
* // Custom figure-based image with caption
|
|
220
|
-
* const FigureImage = createImage((props) => (
|
|
221
|
-
* <figure ref={props.containerRef} class={props.class} style={props.containerStyle}>
|
|
222
|
-
* {props.placeholder}
|
|
223
|
-
* {props.image}
|
|
224
|
-
* <figcaption>Caption goes here</figcaption>
|
|
225
|
-
* </figure>
|
|
226
|
-
* ))
|
|
227
|
-
*
|
|
228
|
-
* // Usage — identical to default <Image>
|
|
229
|
-
* <FigureImage src="/hero.jpg" alt="Hero" width={1200} height={630} />
|
|
230
|
-
*/
|
|
231
|
-
function createImage(Component) {
|
|
232
|
-
return function WrappedImage(props) {
|
|
233
|
-
if (props.raw) return /* @__PURE__ */ jsx("img", {
|
|
234
|
-
src: props.src,
|
|
235
|
-
alt: props.alt,
|
|
236
|
-
width: props.width,
|
|
237
|
-
height: props.height,
|
|
238
|
-
class: props.class,
|
|
239
|
-
style: props.style,
|
|
240
|
-
decoding: props.decoding ?? "async",
|
|
241
|
-
loading: props.loading ?? "lazy",
|
|
242
|
-
fetchPriority: props.priority ? "high" : void 0
|
|
243
|
-
});
|
|
244
|
-
const img = useImage(props);
|
|
245
|
-
const imgEl = /* @__PURE__ */ jsx("img", {
|
|
246
|
-
src: img.src,
|
|
247
|
-
srcSet: img.srcSet,
|
|
248
|
-
sizes: img.sizes,
|
|
249
|
-
alt: props.alt,
|
|
250
|
-
width: props.width,
|
|
251
|
-
height: props.height,
|
|
252
|
-
loading: img.loading,
|
|
253
|
-
decoding: props.decoding ?? "async",
|
|
254
|
-
fetchPriority: img.fetchPriority,
|
|
255
|
-
onLoad: img.handleLoad,
|
|
256
|
-
style: img.imageStyle
|
|
257
|
-
});
|
|
258
|
-
const placeholderEl = props.placeholder ? /* @__PURE__ */ jsx("img", {
|
|
259
|
-
src: props.placeholder,
|
|
260
|
-
alt: "",
|
|
261
|
-
"aria-hidden": "true",
|
|
262
|
-
loading: "eager",
|
|
263
|
-
style: img.placeholderStyle
|
|
264
|
-
}) : null;
|
|
265
|
-
const imageEl = img.hasFormats ? /* @__PURE__ */ jsxs("picture", { children: [img.formats?.map((fmt) => /* @__PURE__ */ jsx("source", {
|
|
266
|
-
type: fmt.type,
|
|
267
|
-
srcSet: () => img.inView() ? fmt.srcset ?? "" : "",
|
|
268
|
-
sizes: img.sizes
|
|
269
|
-
})), imgEl] }) : imgEl;
|
|
270
|
-
return /* @__PURE__ */ jsx(Component, {
|
|
271
|
-
containerRef: img.containerRef,
|
|
272
|
-
class: props.class,
|
|
273
|
-
containerStyle: img.containerStyle,
|
|
274
|
-
placeholder: placeholderEl,
|
|
275
|
-
image: imageEl
|
|
276
|
-
});
|
|
277
|
-
};
|
|
278
|
-
}
|
|
279
|
-
/**
|
|
280
|
-
* Default optimized image component with lazy loading, responsive srcset,
|
|
281
|
-
* `<picture>` multi-format support, and blur-up placeholders.
|
|
282
|
-
*
|
|
283
|
-
* @example
|
|
284
|
-
* // With imagePlugin — spread the import directly
|
|
285
|
-
* import hero from "./hero.jpg?optimize"
|
|
286
|
-
* <Image {...hero} alt="Hero" priority />
|
|
287
|
-
*
|
|
288
|
-
* @example
|
|
289
|
-
* // Manual usage
|
|
290
|
-
* <Image src="/hero.jpg" alt="Hero" width={1200} height={630} />
|
|
291
|
-
*/
|
|
292
|
-
const Image = createImage((props) => /* @__PURE__ */ jsxs("div", {
|
|
293
|
-
ref: props.containerRef,
|
|
294
|
-
class: props.class,
|
|
295
|
-
style: props.containerStyle,
|
|
296
|
-
children: [props.placeholder, props.image]
|
|
297
|
-
}));
|
|
298
|
-
|
|
299
|
-
//#endregion
|
|
300
|
-
//#region src/link.tsx
|
|
301
|
-
const MAX_PREFETCH_CACHE = 200;
|
|
302
|
-
const prefetched = /* @__PURE__ */ new Map();
|
|
303
|
-
function doPrefetch(href) {
|
|
304
|
-
if (typeof document === "undefined") return;
|
|
305
|
-
if (prefetched.has(href)) return;
|
|
306
|
-
if (prefetched.size >= MAX_PREFETCH_CACHE) {
|
|
307
|
-
const firstEntry = prefetched.entries().next().value;
|
|
308
|
-
if (firstEntry) {
|
|
309
|
-
const [oldestHref, oldestLinks] = firstEntry;
|
|
310
|
-
for (const link of oldestLinks) link.remove();
|
|
311
|
-
prefetched.delete(oldestHref);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
const injected = [];
|
|
315
|
-
const docLink = document.createElement("link");
|
|
316
|
-
docLink.rel = "prefetch";
|
|
317
|
-
docLink.href = href;
|
|
318
|
-
docLink.as = "document";
|
|
319
|
-
document.head.appendChild(docLink);
|
|
320
|
-
injected.push(docLink);
|
|
321
|
-
try {
|
|
322
|
-
const chunkHint = document.createElement("link");
|
|
323
|
-
chunkHint.rel = "modulepreload";
|
|
324
|
-
chunkHint.href = href;
|
|
325
|
-
document.head.appendChild(chunkHint);
|
|
326
|
-
injected.push(chunkHint);
|
|
327
|
-
} catch {}
|
|
328
|
-
prefetched.set(href, injected);
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
|
|
332
|
-
* document head. Deduplicates — calling with the same href twice is a no-op.
|
|
333
|
-
*
|
|
334
|
-
* @example
|
|
335
|
-
* prefetchRoute('/about')
|
|
336
|
-
* prefetchRoute('/dashboard')
|
|
337
|
-
*/
|
|
338
|
-
function prefetchRoute(href) {
|
|
339
|
-
doPrefetch(href);
|
|
340
|
-
}
|
|
341
|
-
/**
|
|
342
|
-
* Composable that provides all link behavior — navigation, prefetching,
|
|
343
|
-
* active state, and viewport observation.
|
|
344
|
-
*
|
|
345
|
-
* Use this for full control when `createLink` is too opinionated.
|
|
346
|
-
*
|
|
347
|
-
* @example
|
|
348
|
-
* function MyLink(props: LinkProps) {
|
|
349
|
-
* const link = useLink(props)
|
|
350
|
-
* return (
|
|
351
|
-
* <button ref={link.ref} class={link.classes()} onClick={link.handleClick}>
|
|
352
|
-
* {props.children}
|
|
353
|
-
* </button>
|
|
354
|
-
* )
|
|
355
|
-
* }
|
|
356
|
-
*/
|
|
357
|
-
function useLink(props) {
|
|
358
|
-
const router = useRouter();
|
|
359
|
-
const elementRef = createRef();
|
|
360
|
-
const strategy = props.prefetch ?? "hover";
|
|
361
|
-
function handleClick(e) {
|
|
362
|
-
if (props.onClick) props.onClick(e);
|
|
363
|
-
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || props.external || !props.href) return;
|
|
364
|
-
e.preventDefault();
|
|
365
|
-
router.push(props.href);
|
|
366
|
-
}
|
|
367
|
-
function handleMouseEnter() {
|
|
368
|
-
if (strategy === "hover") doPrefetch(props.href);
|
|
369
|
-
}
|
|
370
|
-
function handleTouchStart() {
|
|
371
|
-
if (strategy === "hover" || strategy === "viewport") doPrefetch(props.href);
|
|
372
|
-
}
|
|
373
|
-
if (strategy === "viewport") useIntersectionObserver(() => elementRef.current ?? void 0, () => doPrefetch(props.href));
|
|
374
|
-
const isActive = () => {
|
|
375
|
-
const currentPath = router.currentRoute()?.path;
|
|
376
|
-
if (!currentPath || !props.href) return false;
|
|
377
|
-
if (props.href === "/") return currentPath === "/";
|
|
378
|
-
return currentPath.startsWith(props.href);
|
|
379
|
-
};
|
|
380
|
-
const isExactActive = () => {
|
|
381
|
-
const currentPath = router.currentRoute()?.path;
|
|
382
|
-
if (!currentPath) return false;
|
|
383
|
-
return currentPath === props.href;
|
|
384
|
-
};
|
|
385
|
-
const classes = () => {
|
|
386
|
-
const cls = [];
|
|
387
|
-
if (props.class) cls.push(props.class);
|
|
388
|
-
if (props.activeClass && isActive()) cls.push(props.activeClass);
|
|
389
|
-
if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass);
|
|
390
|
-
return cls.join(" ");
|
|
391
|
-
};
|
|
392
|
-
return {
|
|
393
|
-
ref: elementRef,
|
|
394
|
-
handleClick,
|
|
395
|
-
handleMouseEnter,
|
|
396
|
-
handleTouchStart,
|
|
397
|
-
isActive,
|
|
398
|
-
isExactActive,
|
|
399
|
-
classes
|
|
400
|
-
};
|
|
401
|
-
}
|
|
402
|
-
/**
|
|
403
|
-
* Higher-order component that wraps any component with link behavior.
|
|
404
|
-
*
|
|
405
|
-
* The wrapped component receives {@link LinkRenderProps} with all handlers,
|
|
406
|
-
* active state, and accessibility attributes pre-wired.
|
|
407
|
-
*
|
|
408
|
-
* @example
|
|
409
|
-
* // Custom button link
|
|
410
|
-
* const ButtonLink = createLink((props) => (
|
|
411
|
-
* <button
|
|
412
|
-
* ref={props.ref}
|
|
413
|
-
* class={props.class}
|
|
414
|
-
* onClick={props.onClick}
|
|
415
|
-
* onMouseEnter={props.onMouseEnter}
|
|
416
|
-
* >
|
|
417
|
-
* {props.children}
|
|
418
|
-
* </button>
|
|
419
|
-
* ))
|
|
420
|
-
*
|
|
421
|
-
* // Custom styled component
|
|
422
|
-
* const CardLink = createLink((props) => (
|
|
423
|
-
* <div
|
|
424
|
-
* ref={props.ref}
|
|
425
|
-
* class={`card ${props.isActive() ? "card--active" : ""}`}
|
|
426
|
-
* onClick={props.onClick}
|
|
427
|
-
* onMouseEnter={props.onMouseEnter}
|
|
428
|
-
* >
|
|
429
|
-
* {props.children}
|
|
430
|
-
* </div>
|
|
431
|
-
* ))
|
|
432
|
-
*
|
|
433
|
-
* // Usage
|
|
434
|
-
* <ButtonLink href="/about">About</ButtonLink>
|
|
435
|
-
* <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
|
|
436
|
-
*/
|
|
437
|
-
function createLink(Component) {
|
|
438
|
-
return function WrappedLink(props) {
|
|
439
|
-
const link = useLink(props);
|
|
440
|
-
return /* @__PURE__ */ jsx(Component, {
|
|
441
|
-
href: props.href,
|
|
442
|
-
ref: link.ref,
|
|
443
|
-
onClick: link.handleClick,
|
|
444
|
-
onMouseEnter: link.handleMouseEnter,
|
|
445
|
-
onTouchStart: link.handleTouchStart,
|
|
446
|
-
isActive: link.isActive,
|
|
447
|
-
isExactActive: link.isExactActive,
|
|
448
|
-
class: link.classes,
|
|
449
|
-
...props.style ? { style: props.style } : {},
|
|
450
|
-
...props.external ? {
|
|
451
|
-
target: "_blank",
|
|
452
|
-
rel: "noopener noreferrer"
|
|
453
|
-
} : {},
|
|
454
|
-
...props["aria-label"] ? { "aria-label": props["aria-label"] } : {},
|
|
455
|
-
children: props.children
|
|
456
|
-
});
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* Default navigation link built on an `<a>` tag.
|
|
461
|
-
*
|
|
462
|
-
* @example
|
|
463
|
-
* <Link href="/about" prefetch="viewport">About</Link>
|
|
464
|
-
* <Link href="/posts" activeClass="nav-active">Posts</Link>
|
|
465
|
-
*/
|
|
466
|
-
const Link = createLink((props) => /* @__PURE__ */ jsx("a", {
|
|
467
|
-
ref: props.ref,
|
|
468
|
-
href: props.href,
|
|
469
|
-
...props.class ? { class: props.class } : {},
|
|
470
|
-
...props.style ? { style: props.style } : {},
|
|
471
|
-
...props.target ? { target: props.target } : {},
|
|
472
|
-
...props.rel ? { rel: props.rel } : {},
|
|
473
|
-
...props["aria-label"] ? { "aria-label": props["aria-label"] } : {},
|
|
474
|
-
...props.isExactActive() ? { "aria-current": "page" } : {},
|
|
475
|
-
onClick: props.onClick,
|
|
476
|
-
onMouseEnter: props.onMouseEnter,
|
|
477
|
-
onTouchStart: props.onTouchStart,
|
|
478
|
-
children: props.children
|
|
479
|
-
}));
|
|
480
|
-
|
|
481
|
-
//#endregion
|
|
482
|
-
//#region src/script.tsx
|
|
483
|
-
/**
|
|
484
|
-
* Composable that provides all script loading behavior — strategy state
|
|
485
|
-
* machine (afterHydration / onIdle / onInteraction / onViewport),
|
|
486
|
-
* deduplication, load/error tracking.
|
|
487
|
-
*
|
|
488
|
-
* Returns reactive signals (`loaded`, `errored`, `pending`) so consumers
|
|
489
|
-
* can render loading indicators, retry buttons, or analytics-readiness
|
|
490
|
-
* gates without re-implementing the strategy machine.
|
|
491
|
-
*
|
|
492
|
-
* @example
|
|
493
|
-
* function MyScript(props: ScriptProps) {
|
|
494
|
-
* const s = useScript(props)
|
|
495
|
-
* return (
|
|
496
|
-
* <>
|
|
497
|
-
* {() => s.loaded() ? <Analytics /> : <Skeleton />}
|
|
498
|
-
* {() => s.needsSentinel && <div ref={s.sentinelRef} style="width:0;height:0" />}
|
|
499
|
-
* </>
|
|
500
|
-
* )
|
|
501
|
-
* }
|
|
502
|
-
*/
|
|
503
|
-
function useScript(props) {
|
|
504
|
-
const strategy = props.strategy ?? "afterHydration";
|
|
505
|
-
const loaded = signal(false);
|
|
506
|
-
const errored = signal(false);
|
|
507
|
-
const pending = signal(strategy !== "beforeHydration" && strategy !== "afterHydration");
|
|
508
|
-
const sentinelRef = strategy === "onViewport" ? createRef() : void 0;
|
|
509
|
-
function loadScript() {
|
|
510
|
-
if (typeof document === "undefined") return;
|
|
511
|
-
if (props.id && document.getElementById(props.id)) {
|
|
512
|
-
loaded.set(true);
|
|
513
|
-
pending.set(false);
|
|
514
|
-
return;
|
|
515
|
-
}
|
|
516
|
-
const script = document.createElement("script");
|
|
517
|
-
if (props.src) script.src = props.src;
|
|
518
|
-
if (props.id) script.id = props.id;
|
|
519
|
-
script.async = props.async !== false;
|
|
520
|
-
script.onload = () => {
|
|
521
|
-
loaded.set(true);
|
|
522
|
-
pending.set(false);
|
|
523
|
-
props.onLoad?.();
|
|
524
|
-
};
|
|
525
|
-
script.onerror = () => {
|
|
526
|
-
errored.set(true);
|
|
527
|
-
pending.set(false);
|
|
528
|
-
props.onError?.(/* @__PURE__ */ new Error(`Failed to load: ${props.src}`));
|
|
529
|
-
};
|
|
530
|
-
if (props.children && !props.src) {
|
|
531
|
-
script.textContent = props.children;
|
|
532
|
-
setTimeout(() => {
|
|
533
|
-
loaded.set(true);
|
|
534
|
-
pending.set(false);
|
|
535
|
-
}, 0);
|
|
536
|
-
}
|
|
537
|
-
document.head.appendChild(script);
|
|
538
|
-
}
|
|
539
|
-
onMount(() => {
|
|
540
|
-
switch (strategy) {
|
|
541
|
-
case "beforeHydration":
|
|
542
|
-
loaded.set(true);
|
|
543
|
-
pending.set(false);
|
|
544
|
-
break;
|
|
545
|
-
case "afterHydration":
|
|
546
|
-
loadScript();
|
|
547
|
-
break;
|
|
548
|
-
case "onIdle":
|
|
549
|
-
if ("requestIdleCallback" in window) requestIdleCallback(() => loadScript(), { timeout: 5e3 });
|
|
550
|
-
else setTimeout(loadScript, 200);
|
|
551
|
-
break;
|
|
552
|
-
case "onInteraction": {
|
|
553
|
-
const events = [
|
|
554
|
-
"click",
|
|
555
|
-
"scroll",
|
|
556
|
-
"keydown",
|
|
557
|
-
"touchstart"
|
|
558
|
-
];
|
|
559
|
-
function handler() {
|
|
560
|
-
for (const e of events) document.removeEventListener(e, handler);
|
|
561
|
-
loadScript();
|
|
562
|
-
}
|
|
563
|
-
for (const e of events) document.addEventListener(e, handler, {
|
|
564
|
-
once: true,
|
|
565
|
-
passive: true
|
|
566
|
-
});
|
|
567
|
-
onUnmount(() => {
|
|
568
|
-
for (const e of events) document.removeEventListener(e, handler);
|
|
569
|
-
});
|
|
570
|
-
break;
|
|
571
|
-
}
|
|
572
|
-
case "onViewport": break;
|
|
573
|
-
}
|
|
574
|
-
});
|
|
575
|
-
if (strategy === "onViewport") useIntersectionObserver(() => sentinelRef.current ?? void 0, () => loadScript());
|
|
576
|
-
return {
|
|
577
|
-
sentinelRef,
|
|
578
|
-
loaded,
|
|
579
|
-
errored,
|
|
580
|
-
pending,
|
|
581
|
-
needsSentinel: strategy === "onViewport",
|
|
582
|
-
load: loadScript
|
|
583
|
-
};
|
|
584
|
-
}
|
|
585
|
-
/**
|
|
586
|
-
* Higher-order component that wraps any component with script load behavior.
|
|
587
|
-
*
|
|
588
|
-
* The wrapped component receives {@link ScriptRenderProps} with the sentinel
|
|
589
|
-
* ref, load-state signals, and a `needsSentinel` flag. Use this when you want
|
|
590
|
-
* to render a loading indicator, retry button, or custom analytics-readiness
|
|
591
|
-
* gate around the script load.
|
|
592
|
-
*
|
|
593
|
-
* @example
|
|
594
|
-
* // Script with a loading indicator
|
|
595
|
-
* const TrackedScript = createScript((props) => (
|
|
596
|
-
* <>
|
|
597
|
-
* {() => props.pending() && <Spinner />}
|
|
598
|
-
* {() => props.errored() && <button onClick={() => location.reload()}>Retry</button>}
|
|
599
|
-
* {props.needsSentinel && <div ref={props.sentinelRef} style="width:0;height:0" />}
|
|
600
|
-
* </>
|
|
601
|
-
* ))
|
|
602
|
-
*
|
|
603
|
-
* <TrackedScript src="/analytics.js" strategy="onIdle" />
|
|
604
|
-
*/
|
|
605
|
-
function createScript(Component) {
|
|
606
|
-
return function WrappedScript(props) {
|
|
607
|
-
const s = useScript(props);
|
|
608
|
-
return /* @__PURE__ */ jsx(Component, {
|
|
609
|
-
sentinelRef: s.sentinelRef,
|
|
610
|
-
needsSentinel: s.needsSentinel,
|
|
611
|
-
loaded: s.loaded,
|
|
612
|
-
errored: s.errored,
|
|
613
|
-
pending: s.pending
|
|
614
|
-
});
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
/**
|
|
618
|
-
* Default optimized script component. Renders a 0×0 sentinel `<div>` for the
|
|
619
|
-
* `onViewport` strategy (so IntersectionObserver has an element to observe),
|
|
620
|
-
* `null` for every other strategy.
|
|
621
|
-
*
|
|
622
|
-
* @example
|
|
623
|
-
* // Load analytics after page is interactive
|
|
624
|
-
* <Script src="https://analytics.example.com/script.js" strategy="onIdle" />
|
|
625
|
-
*
|
|
626
|
-
* // Load chat widget when user scrolls
|
|
627
|
-
* <Script src="/chat-widget.js" strategy="onViewport" />
|
|
628
|
-
*
|
|
629
|
-
* // Inline script with deferred execution
|
|
630
|
-
* <Script strategy="afterHydration">
|
|
631
|
-
* {`console.log("App hydrated!")`}
|
|
632
|
-
* <\/Script>
|
|
633
|
-
*/
|
|
634
|
-
const Script = createScript((props) => {
|
|
635
|
-
if (!props.needsSentinel) return null;
|
|
636
|
-
return /* @__PURE__ */ jsx("div", {
|
|
637
|
-
ref: props.sentinelRef,
|
|
638
|
-
style: "width:0;height:0;overflow:hidden"
|
|
639
|
-
});
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
//#endregion
|
|
643
|
-
//#region src/i18n-routing.ts
|
|
644
|
-
/**
|
|
645
|
-
* Extract locale from a URL path.
|
|
646
|
-
* Returns { locale, pathWithoutLocale }.
|
|
647
|
-
*/
|
|
648
|
-
function extractLocaleFromPath(path, locales, defaultLocale) {
|
|
649
|
-
const segments = path.split("/").filter(Boolean);
|
|
650
|
-
const firstSegment = segments[0]?.toLowerCase();
|
|
651
|
-
if (firstSegment && locales.includes(firstSegment)) return {
|
|
652
|
-
locale: firstSegment,
|
|
653
|
-
pathWithoutLocale: "/" + segments.slice(1).join("/") || "/"
|
|
654
|
-
};
|
|
655
|
-
return {
|
|
656
|
-
locale: defaultLocale,
|
|
657
|
-
pathWithoutLocale: path
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
/**
|
|
661
|
-
* Build a localized path.
|
|
662
|
-
*/
|
|
663
|
-
function buildLocalePath(path, locale, defaultLocale, strategy) {
|
|
664
|
-
const clean = path === "/" ? "" : path;
|
|
665
|
-
if (strategy === "prefix-except-default" && locale === defaultLocale) return path;
|
|
666
|
-
return `/${locale}${clean}`;
|
|
667
|
-
}
|
|
668
|
-
/** @internal Context for the current locale. */
|
|
669
|
-
const LocaleCtx = createContext("en");
|
|
670
|
-
/** Current locale signal — set by the server middleware or client-side detection. */
|
|
671
|
-
const localeSignal = signal("en");
|
|
672
|
-
/**
|
|
673
|
-
* Read the current locale reactively.
|
|
674
|
-
*
|
|
675
|
-
* Returns the locale signal value directly — reactive in both SSR and CSR.
|
|
676
|
-
* The server middleware sets `localeSignal` per-request, and client-side
|
|
677
|
-
* `setLocale()` updates it as well.
|
|
678
|
-
*
|
|
679
|
-
* @example
|
|
680
|
-
* ```tsx
|
|
681
|
-
* const locale = useLocale() // "en", "de", etc.
|
|
682
|
-
* ```
|
|
683
|
-
*/
|
|
684
|
-
function useLocale() {
|
|
685
|
-
return localeSignal();
|
|
686
|
-
}
|
|
687
|
-
/**
|
|
688
|
-
* Set the locale client-side and update the URL.
|
|
689
|
-
*
|
|
690
|
-
* @example
|
|
691
|
-
* ```tsx
|
|
692
|
-
* <button onClick={() => setLocale('de')}>Deutsch</button>
|
|
693
|
-
* ```
|
|
694
|
-
*/
|
|
695
|
-
function setLocale(locale, config) {
|
|
696
|
-
localeSignal.set(locale);
|
|
697
|
-
if (typeof document !== "undefined") document.cookie = `${config.cookieName ?? "locale"}=${locale}; path=/; max-age=31536000`;
|
|
698
|
-
if (typeof window !== "undefined") {
|
|
699
|
-
const strategy = config.strategy ?? "prefix-except-default";
|
|
700
|
-
const { pathWithoutLocale } = extractLocaleFromPath(window.location.pathname, config.locales, config.defaultLocale);
|
|
701
|
-
const newPath = buildLocalePath(pathWithoutLocale, locale, config.defaultLocale, strategy);
|
|
702
|
-
window.history.pushState(null, "", newPath);
|
|
703
|
-
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
//#endregion
|
|
708
|
-
//#region src/meta.tsx
|
|
709
|
-
function faviconLinks(locale, config) {
|
|
710
|
-
const hasLocaleOverride = locale && config.locales?.[locale];
|
|
711
|
-
const prefix = hasLocaleOverride ? `/${locale}` : "";
|
|
712
|
-
const isSvg = (hasLocaleOverride ? config.locales[locale].source : config.source).endsWith(".svg");
|
|
713
|
-
const links = [];
|
|
714
|
-
if (isSvg) links.push({
|
|
715
|
-
rel: "icon",
|
|
716
|
-
type: "image/svg+xml",
|
|
717
|
-
href: `${prefix}/favicon.svg`
|
|
718
|
-
});
|
|
719
|
-
links.push({
|
|
720
|
-
rel: "icon",
|
|
721
|
-
type: "image/png",
|
|
722
|
-
sizes: "32x32",
|
|
723
|
-
href: `${prefix}/favicon-32x32.png`
|
|
724
|
-
}, {
|
|
725
|
-
rel: "icon",
|
|
726
|
-
type: "image/png",
|
|
727
|
-
sizes: "16x16",
|
|
728
|
-
href: `${prefix}/favicon-16x16.png`
|
|
729
|
-
}, {
|
|
730
|
-
rel: "apple-touch-icon",
|
|
731
|
-
sizes: "180x180",
|
|
732
|
-
href: `${prefix}/apple-touch-icon.png`
|
|
733
|
-
});
|
|
734
|
-
if (config.manifest !== false) links.push({
|
|
735
|
-
rel: "manifest",
|
|
736
|
-
href: `${prefix}/site.webmanifest`
|
|
737
|
-
});
|
|
738
|
-
return links;
|
|
739
|
-
}
|
|
740
|
-
function ogImagePath(templateName, locale, outDir = "og", format = "png") {
|
|
741
|
-
const ext = format === "jpeg" ? "jpg" : "png";
|
|
742
|
-
return `/${outDir}/${templateName}${locale ? `-${locale}` : ""}.${ext}`;
|
|
743
|
-
}
|
|
744
|
-
const resolveStr = (v) => typeof v === "function" ? v() : v;
|
|
745
|
-
/**
|
|
746
|
-
* Declarative meta component for SSR-compatible page metadata.
|
|
747
|
-
*
|
|
748
|
-
* Supports reactive title/description — when passed as `() => string` accessors,
|
|
749
|
-
* they are forwarded to `useHead()` as a reactive getter so updates propagate
|
|
750
|
-
* automatically via signal tracking.
|
|
751
|
-
*
|
|
752
|
-
* @example
|
|
753
|
-
* ```tsx
|
|
754
|
-
* <Meta title="My Page" description="..." image="/og.jpg" canonical="https://..." />
|
|
755
|
-
* ```
|
|
756
|
-
*
|
|
757
|
-
* @example Reactive title
|
|
758
|
-
* ```tsx
|
|
759
|
-
* const count = signal(0)
|
|
760
|
-
* <Meta title={() => `${count()} items`} />
|
|
761
|
-
* ```
|
|
762
|
-
*/
|
|
763
|
-
function Meta(props) {
|
|
764
|
-
const hasReactiveTitle = typeof props.title === "function";
|
|
765
|
-
const hasReactiveDescription = typeof props.description === "function";
|
|
766
|
-
if (hasReactiveTitle || hasReactiveDescription) useHead(() => {
|
|
767
|
-
const title = resolveStr(props.title);
|
|
768
|
-
const description = resolveStr(props.description);
|
|
769
|
-
const tags = buildMetaTags({
|
|
770
|
-
...props,
|
|
771
|
-
title,
|
|
772
|
-
description
|
|
773
|
-
});
|
|
774
|
-
const input = {
|
|
775
|
-
meta: tags.meta,
|
|
776
|
-
link: tags.link,
|
|
777
|
-
script: tags.script
|
|
778
|
-
};
|
|
779
|
-
if (title) input.title = title;
|
|
780
|
-
return input;
|
|
781
|
-
});
|
|
782
|
-
else {
|
|
783
|
-
const title = resolveStr(props.title);
|
|
784
|
-
const description = resolveStr(props.description);
|
|
785
|
-
const tags = buildMetaTags({
|
|
786
|
-
...props,
|
|
787
|
-
title,
|
|
788
|
-
description
|
|
789
|
-
});
|
|
790
|
-
const input = {
|
|
791
|
-
meta: tags.meta,
|
|
792
|
-
link: tags.link,
|
|
793
|
-
script: tags.script
|
|
794
|
-
};
|
|
795
|
-
if (title) input.title = title;
|
|
796
|
-
useHead(input);
|
|
797
|
-
}
|
|
798
|
-
return props.children ?? null;
|
|
799
|
-
}
|
|
800
|
-
function buildMetaTags(props) {
|
|
801
|
-
const meta = [];
|
|
802
|
-
const link = [];
|
|
803
|
-
const script = [];
|
|
804
|
-
const { title, description, canonical, imageAlt, imageWidth, imageHeight, type = "website", siteName, twitterCard = "summary_large_image", twitterSite, twitterCreator, locale = "en_US", alternateLocales, publishedTime, modifiedTime, author, tags, jsonLd, extra, video, videoWidth, videoHeight, audio, favicon, ogTemplate, ogImageDir, ogImageFormat } = props;
|
|
805
|
-
const robots = props.noIndex ? "noindex, nofollow" : props.robots ?? "index, follow";
|
|
806
|
-
const image = props.image ?? (ogTemplate ? ogImagePath(ogTemplate, locale !== "en_US" ? locale : void 0, ogImageDir, ogImageFormat) : void 0);
|
|
807
|
-
const resolvedImageWidth = imageWidth ?? (ogTemplate && !props.image ? 1200 : void 0);
|
|
808
|
-
const resolvedImageHeight = imageHeight ?? (ogTemplate && !props.image ? 630 : void 0);
|
|
809
|
-
if (description) meta.push({
|
|
810
|
-
name: "description",
|
|
811
|
-
content: description
|
|
812
|
-
});
|
|
813
|
-
if (robots) meta.push({
|
|
814
|
-
name: "robots",
|
|
815
|
-
content: robots
|
|
816
|
-
});
|
|
817
|
-
if (author) meta.push({
|
|
818
|
-
name: "author",
|
|
819
|
-
content: author
|
|
820
|
-
});
|
|
821
|
-
if (title) meta.push({
|
|
822
|
-
property: "og:title",
|
|
823
|
-
content: title
|
|
824
|
-
});
|
|
825
|
-
if (description) meta.push({
|
|
826
|
-
property: "og:description",
|
|
827
|
-
content: description
|
|
828
|
-
});
|
|
829
|
-
if (canonical) meta.push({
|
|
830
|
-
property: "og:url",
|
|
831
|
-
content: canonical
|
|
832
|
-
});
|
|
833
|
-
if (image) meta.push({
|
|
834
|
-
property: "og:image",
|
|
835
|
-
content: image
|
|
836
|
-
});
|
|
837
|
-
if (imageAlt) meta.push({
|
|
838
|
-
property: "og:image:alt",
|
|
839
|
-
content: imageAlt
|
|
840
|
-
});
|
|
841
|
-
if (resolvedImageWidth) meta.push({
|
|
842
|
-
property: "og:image:width",
|
|
843
|
-
content: String(resolvedImageWidth)
|
|
844
|
-
});
|
|
845
|
-
if (resolvedImageHeight) meta.push({
|
|
846
|
-
property: "og:image:height",
|
|
847
|
-
content: String(resolvedImageHeight)
|
|
848
|
-
});
|
|
849
|
-
meta.push({
|
|
850
|
-
property: "og:type",
|
|
851
|
-
content: type
|
|
852
|
-
});
|
|
853
|
-
if (siteName) meta.push({
|
|
854
|
-
property: "og:site_name",
|
|
855
|
-
content: siteName
|
|
856
|
-
});
|
|
857
|
-
meta.push({
|
|
858
|
-
property: "og:locale",
|
|
859
|
-
content: locale
|
|
860
|
-
});
|
|
861
|
-
if (video) {
|
|
862
|
-
meta.push({
|
|
863
|
-
property: "og:video",
|
|
864
|
-
content: video
|
|
865
|
-
});
|
|
866
|
-
if (videoWidth) meta.push({
|
|
867
|
-
property: "og:video:width",
|
|
868
|
-
content: String(videoWidth)
|
|
869
|
-
});
|
|
870
|
-
if (videoHeight) meta.push({
|
|
871
|
-
property: "og:video:height",
|
|
872
|
-
content: String(videoHeight)
|
|
873
|
-
});
|
|
874
|
-
if (video.endsWith(".mp4")) meta.push({
|
|
875
|
-
property: "og:video:type",
|
|
876
|
-
content: "video/mp4"
|
|
877
|
-
});
|
|
878
|
-
else if (video.endsWith(".webm")) meta.push({
|
|
879
|
-
property: "og:video:type",
|
|
880
|
-
content: "video/webm"
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
if (audio) meta.push({
|
|
884
|
-
property: "og:audio",
|
|
885
|
-
content: audio
|
|
886
|
-
});
|
|
887
|
-
if (type === "article") {
|
|
888
|
-
if (publishedTime) meta.push({
|
|
889
|
-
property: "article:published_time",
|
|
890
|
-
content: publishedTime
|
|
891
|
-
});
|
|
892
|
-
if (modifiedTime) meta.push({
|
|
893
|
-
property: "article:modified_time",
|
|
894
|
-
content: modifiedTime
|
|
895
|
-
});
|
|
896
|
-
if (author) meta.push({
|
|
897
|
-
property: "article:author",
|
|
898
|
-
content: author
|
|
899
|
-
});
|
|
900
|
-
if (tags) for (const tag of tags) meta.push({
|
|
901
|
-
property: "article:tag",
|
|
902
|
-
content: tag
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
meta.push({
|
|
906
|
-
name: "twitter:card",
|
|
907
|
-
content: twitterCard
|
|
908
|
-
});
|
|
909
|
-
if (title) meta.push({
|
|
910
|
-
name: "twitter:title",
|
|
911
|
-
content: title
|
|
912
|
-
});
|
|
913
|
-
if (description) meta.push({
|
|
914
|
-
name: "twitter:description",
|
|
915
|
-
content: description
|
|
916
|
-
});
|
|
917
|
-
if (image) meta.push({
|
|
918
|
-
name: "twitter:image",
|
|
919
|
-
content: image
|
|
920
|
-
});
|
|
921
|
-
if (imageAlt) meta.push({
|
|
922
|
-
name: "twitter:image:alt",
|
|
923
|
-
content: imageAlt
|
|
924
|
-
});
|
|
925
|
-
if (twitterSite) meta.push({
|
|
926
|
-
name: "twitter:site",
|
|
927
|
-
content: twitterSite
|
|
928
|
-
});
|
|
929
|
-
if (twitterCreator) meta.push({
|
|
930
|
-
name: "twitter:creator",
|
|
931
|
-
content: twitterCreator
|
|
932
|
-
});
|
|
933
|
-
if (canonical) link.push({
|
|
934
|
-
rel: "canonical",
|
|
935
|
-
href: canonical
|
|
936
|
-
});
|
|
937
|
-
if (alternateLocales) for (const alt of alternateLocales) link.push({
|
|
938
|
-
rel: "alternate",
|
|
939
|
-
hreflang: alt.locale,
|
|
940
|
-
href: alt.url
|
|
941
|
-
});
|
|
942
|
-
if (jsonLd) script.push({
|
|
943
|
-
type: "application/ld+json",
|
|
944
|
-
children: JSON.stringify({
|
|
945
|
-
"@context": "https://schema.org",
|
|
946
|
-
...jsonLd
|
|
947
|
-
})
|
|
948
|
-
});
|
|
949
|
-
if (extra) for (const tag of extra) meta.push(tag);
|
|
950
|
-
if (props.i18n) {
|
|
951
|
-
const i18nConfig = props.i18n;
|
|
952
|
-
const origin = props.origin ?? "";
|
|
953
|
-
const { pathWithoutLocale } = extractLocaleFromPath(canonical?.replace(origin, "") ?? "/", i18nConfig.locales, i18nConfig.defaultLocale);
|
|
954
|
-
const strategy = i18nConfig.strategy ?? "prefix-except-default";
|
|
955
|
-
for (const loc of i18nConfig.locales) {
|
|
956
|
-
const localizedPath = strategy === "prefix-except-default" && loc === i18nConfig.defaultLocale ? pathWithoutLocale : `/${loc}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
|
|
957
|
-
link.push({
|
|
958
|
-
rel: "alternate",
|
|
959
|
-
hreflang: loc,
|
|
960
|
-
href: `${origin}${localizedPath}`
|
|
961
|
-
});
|
|
962
|
-
if (loc !== locale) meta.push({
|
|
963
|
-
property: "og:locale:alternate",
|
|
964
|
-
content: loc
|
|
965
|
-
});
|
|
966
|
-
}
|
|
967
|
-
link.push({
|
|
968
|
-
rel: "alternate",
|
|
969
|
-
hreflang: "x-default",
|
|
970
|
-
href: `${origin}${pathWithoutLocale}`
|
|
971
|
-
});
|
|
972
|
-
}
|
|
973
|
-
if (favicon) {
|
|
974
|
-
const faviconLocale = locale !== "en_US" ? locale : void 0;
|
|
975
|
-
for (const fl of faviconLinks(faviconLocale, favicon)) link.push(fl);
|
|
976
|
-
if (favicon.themeColor) meta.push({
|
|
977
|
-
name: "theme-color",
|
|
978
|
-
content: favicon.themeColor
|
|
979
|
-
});
|
|
980
|
-
}
|
|
981
|
-
return {
|
|
982
|
-
meta,
|
|
983
|
-
link,
|
|
984
|
-
script
|
|
985
|
-
};
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
//#endregion
|
|
989
|
-
//#region src/theme.tsx
|
|
990
|
-
const STORAGE_KEY = "zero-theme";
|
|
991
|
-
/** Reactive theme signal. */
|
|
992
|
-
const theme = signal("system");
|
|
993
|
-
/**
|
|
994
|
-
* Reactive signal tracking the OS color-scheme preference. Updated by the
|
|
995
|
-
* `matchMedia('(prefers-color-scheme: dark)').change` event registered in
|
|
996
|
-
* `initTheme`. Components reading `resolvedTheme()` subscribe to BOTH
|
|
997
|
-
* `theme` and this signal, so a user toggling dark mode at the OS level
|
|
998
|
-
* re-renders everything reactively — not just the `<html data-theme>`
|
|
999
|
-
* attribute.
|
|
1000
|
-
*
|
|
1001
|
-
* SSR default is `_ssrDefault` (mutable via `setSSRThemeDefault`) so the
|
|
1002
|
-
* server-rendered theme can differ from the client's OS preference.
|
|
1003
|
-
*/
|
|
1004
|
-
const _osPrefersDark = signal(false);
|
|
1005
|
-
/** SSR fallback when system preference can't be detected. Default: 'light'. */
|
|
1006
|
-
let _ssrDefault = "light";
|
|
1007
|
-
/**
|
|
1008
|
-
* Set the default theme for SSR (when `matchMedia` is unavailable).
|
|
1009
|
-
* Call once at server startup before rendering.
|
|
1010
|
-
*/
|
|
1011
|
-
function setSSRThemeDefault(value) {
|
|
1012
|
-
_ssrDefault = value;
|
|
1013
|
-
}
|
|
1014
|
-
/**
|
|
1015
|
-
* Reactive read of the resolved theme. Subscribes to `theme` (explicit
|
|
1016
|
-
* user choice) and — when `theme === 'system'` — to `_osPrefersDark`
|
|
1017
|
-
* (OS color-scheme preference). Components using `resolvedTheme()`
|
|
1018
|
-
* inside JSX / effects / computeds re-render when either changes.
|
|
1019
|
-
*/
|
|
1020
|
-
function resolvedTheme() {
|
|
1021
|
-
const t = theme();
|
|
1022
|
-
if (t === "system") {
|
|
1023
|
-
if (typeof window === "undefined") return _ssrDefault;
|
|
1024
|
-
return _osPrefersDark() ? "dark" : "light";
|
|
1025
|
-
}
|
|
1026
|
-
return t;
|
|
1027
|
-
}
|
|
1028
|
-
/** Toggle between light and dark. */
|
|
1029
|
-
function toggleTheme() {
|
|
1030
|
-
setTheme(resolvedTheme() === "dark" ? "light" : "dark");
|
|
1031
|
-
}
|
|
1032
|
-
/** Set theme explicitly. */
|
|
1033
|
-
function setTheme(t) {
|
|
1034
|
-
theme.set(t);
|
|
1035
|
-
if (typeof document !== "undefined") {
|
|
1036
|
-
document.documentElement.dataset.theme = resolvedTheme();
|
|
1037
|
-
try {
|
|
1038
|
-
localStorage.setItem(STORAGE_KEY, t);
|
|
1039
|
-
} catch {}
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
/**
|
|
1043
|
-
* Initialize the theme system. Call once in your app entry or layout.
|
|
1044
|
-
* Reads from localStorage, listens for system preference changes.
|
|
1045
|
-
*/
|
|
1046
|
-
function initTheme() {
|
|
1047
|
-
onMount(() => {
|
|
1048
|
-
try {
|
|
1049
|
-
const stored = localStorage.getItem(STORAGE_KEY);
|
|
1050
|
-
if (stored === "light" || stored === "dark" || stored === "system") theme.set(stored);
|
|
1051
|
-
} catch {}
|
|
1052
|
-
document.documentElement.dataset.theme = resolvedTheme();
|
|
1053
|
-
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1054
|
-
_osPrefersDark.set(mq.matches);
|
|
1055
|
-
function onChange(e) {
|
|
1056
|
-
_osPrefersDark.set(e.matches);
|
|
1057
|
-
}
|
|
1058
|
-
mq.addEventListener("change", onChange);
|
|
1059
|
-
const dispose = effect(() => {
|
|
1060
|
-
const mode = resolvedTheme();
|
|
1061
|
-
document.documentElement.dataset.theme = mode;
|
|
1062
|
-
const faviconLinks = document.querySelectorAll("[data-favicon-theme]");
|
|
1063
|
-
for (const link of faviconLinks) link.media = link.dataset.faviconTheme === mode ? "" : "not all";
|
|
1064
|
-
});
|
|
1065
|
-
return () => {
|
|
1066
|
-
mq.removeEventListener("change", onChange);
|
|
1067
|
-
dispose?.dispose();
|
|
1068
|
-
};
|
|
1069
|
-
});
|
|
1070
|
-
}
|
|
1071
|
-
/**
|
|
1072
|
-
* Theme toggle button component.
|
|
1073
|
-
*
|
|
1074
|
-
* @example
|
|
1075
|
-
* import { ThemeToggle } from "@pyreon/zero/theme"
|
|
1076
|
-
* <ThemeToggle />
|
|
1077
|
-
*/
|
|
1078
|
-
function ThemeToggle(props) {
|
|
1079
|
-
initTheme();
|
|
1080
|
-
return /* @__PURE__ */ jsx("button", {
|
|
1081
|
-
class: props.class,
|
|
1082
|
-
style: props.style,
|
|
1083
|
-
onClick: toggleTheme,
|
|
1084
|
-
"aria-label": "Toggle theme",
|
|
1085
|
-
title: "Toggle theme",
|
|
1086
|
-
type: "button",
|
|
1087
|
-
children: () => resolvedTheme() === "dark" ? /* @__PURE__ */ jsxs("svg", {
|
|
1088
|
-
width: "18",
|
|
1089
|
-
height: "18",
|
|
1090
|
-
viewBox: "0 0 24 24",
|
|
1091
|
-
fill: "none",
|
|
1092
|
-
stroke: "currentColor",
|
|
1093
|
-
"stroke-width": "2",
|
|
1094
|
-
"stroke-linecap": "round",
|
|
1095
|
-
"stroke-linejoin": "round",
|
|
1096
|
-
"aria-hidden": "true",
|
|
1097
|
-
children: [
|
|
1098
|
-
/* @__PURE__ */ jsx("circle", {
|
|
1099
|
-
cx: "12",
|
|
1100
|
-
cy: "12",
|
|
1101
|
-
r: "5"
|
|
1102
|
-
}),
|
|
1103
|
-
/* @__PURE__ */ jsx("line", {
|
|
1104
|
-
x1: "12",
|
|
1105
|
-
y1: "1",
|
|
1106
|
-
x2: "12",
|
|
1107
|
-
y2: "3"
|
|
1108
|
-
}),
|
|
1109
|
-
/* @__PURE__ */ jsx("line", {
|
|
1110
|
-
x1: "12",
|
|
1111
|
-
y1: "21",
|
|
1112
|
-
x2: "12",
|
|
1113
|
-
y2: "23"
|
|
1114
|
-
}),
|
|
1115
|
-
/* @__PURE__ */ jsx("line", {
|
|
1116
|
-
x1: "4.22",
|
|
1117
|
-
y1: "4.22",
|
|
1118
|
-
x2: "5.64",
|
|
1119
|
-
y2: "5.64"
|
|
1120
|
-
}),
|
|
1121
|
-
/* @__PURE__ */ jsx("line", {
|
|
1122
|
-
x1: "18.36",
|
|
1123
|
-
y1: "18.36",
|
|
1124
|
-
x2: "19.78",
|
|
1125
|
-
y2: "19.78"
|
|
1126
|
-
}),
|
|
1127
|
-
/* @__PURE__ */ jsx("line", {
|
|
1128
|
-
x1: "1",
|
|
1129
|
-
y1: "12",
|
|
1130
|
-
x2: "3",
|
|
1131
|
-
y2: "12"
|
|
1132
|
-
}),
|
|
1133
|
-
/* @__PURE__ */ jsx("line", {
|
|
1134
|
-
x1: "21",
|
|
1135
|
-
y1: "12",
|
|
1136
|
-
x2: "23",
|
|
1137
|
-
y2: "12"
|
|
1138
|
-
}),
|
|
1139
|
-
/* @__PURE__ */ jsx("line", {
|
|
1140
|
-
x1: "4.22",
|
|
1141
|
-
y1: "19.78",
|
|
1142
|
-
x2: "5.64",
|
|
1143
|
-
y2: "18.36"
|
|
1144
|
-
}),
|
|
1145
|
-
/* @__PURE__ */ jsx("line", {
|
|
1146
|
-
x1: "18.36",
|
|
1147
|
-
y1: "5.64",
|
|
1148
|
-
x2: "19.78",
|
|
1149
|
-
y2: "4.22"
|
|
1150
|
-
})
|
|
1151
|
-
]
|
|
1152
|
-
}) : /* @__PURE__ */ jsx("svg", {
|
|
1153
|
-
width: "18",
|
|
1154
|
-
height: "18",
|
|
1155
|
-
viewBox: "0 0 24 24",
|
|
1156
|
-
fill: "none",
|
|
1157
|
-
stroke: "currentColor",
|
|
1158
|
-
"stroke-width": "2",
|
|
1159
|
-
"stroke-linecap": "round",
|
|
1160
|
-
"stroke-linejoin": "round",
|
|
1161
|
-
"aria-hidden": "true",
|
|
1162
|
-
children: /* @__PURE__ */ jsx("path", { d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" })
|
|
1163
|
-
})
|
|
1164
|
-
});
|
|
1165
|
-
}
|
|
1166
|
-
/**
|
|
1167
|
-
* Inline script to prevent flash of wrong theme.
|
|
1168
|
-
* Include this in your index.html <head> BEFORE any stylesheets.
|
|
1169
|
-
*
|
|
1170
|
-
* @example
|
|
1171
|
-
* // index.html
|
|
1172
|
-
* <head>
|
|
1173
|
-
* <script>{themeScript}<\/script>
|
|
1174
|
-
* ...
|
|
1175
|
-
* </head>
|
|
1176
|
-
*/
|
|
1177
|
-
const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");var r=t==="light"?"light":t==="dark"?"dark":window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";document.documentElement.dataset.theme=r;document.querySelectorAll("[data-favicon-theme]").forEach(function(l){l.media=l.dataset.faviconTheme===r?"":"not all"})}catch(e){}})()`;
|
|
1178
|
-
|
|
1179
102
|
//#endregion
|
|
1180
103
|
//#region src/index.ts
|
|
1181
104
|
function serverOnly(name, subpath) {
|