@pylonsync/react 0.3.223 → 0.3.224
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/package.json +3 -3
- package/src/Image.tsx +174 -0
- package/src/Link.tsx +119 -0
- package/src/index.ts +7 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.224",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "src/index.ts",
|
|
9
9
|
"types": "src/index.ts",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"check": "tsc -p tsconfig.json --noEmit"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@pylonsync/sdk": "0.3.
|
|
16
|
-
"@pylonsync/sync": "0.3.
|
|
15
|
+
"@pylonsync/sdk": "0.3.224",
|
|
16
|
+
"@pylonsync/sync": "0.3.224"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": ">=19.0.0"
|
package/src/Image.tsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Pylon's Next.js-style <Image>. Generates optimized + responsive
|
|
2
|
+
// images via Pylon's built-in Rust image processor.
|
|
3
|
+
//
|
|
4
|
+
// SSR output: a plain <img> with src + srcset + sizes pointing at
|
|
5
|
+
// `/_pylon/image?src=<your-src>&w=<width>&q=<quality>`. The Rust
|
|
6
|
+
// handler decodes, resizes with SIMD (fast_image_resize), and
|
|
7
|
+
// re-encodes with mozjpeg / libwebp on the fly, then caches the
|
|
8
|
+
// result by content hash so the second request is a static-file
|
|
9
|
+
// serve.
|
|
10
|
+
//
|
|
11
|
+
// What it does for you:
|
|
12
|
+
// - Multiple widths in `srcset` so the browser picks the smallest
|
|
13
|
+
// candidate for the viewport DPR.
|
|
14
|
+
// - `sizes="100vw"` default — override for tighter layouts.
|
|
15
|
+
// - `loading="lazy"` + `decoding="async"` by default (override
|
|
16
|
+
// with `priority` for above-the-fold).
|
|
17
|
+
// - `width` + `height` attrs locked to the explicit props so the
|
|
18
|
+
// browser reserves space before paint (no CLS).
|
|
19
|
+
//
|
|
20
|
+
// What it doesn't do (Phase 2):
|
|
21
|
+
// - No automatic blur placeholder. Pass `placeholder="<color>"` or
|
|
22
|
+
// `placeholder={dataUri}` if you want one.
|
|
23
|
+
// - No remote allowlist enforcement client-side — the server
|
|
24
|
+
// refuses unknown hosts per `PYLON_IMAGE_REMOTE_ALLOWLIST`.
|
|
25
|
+
// - No AVIF (server only emits WebP / JPEG / PNG today).
|
|
26
|
+
|
|
27
|
+
import React from "react";
|
|
28
|
+
|
|
29
|
+
export interface ImageProps
|
|
30
|
+
extends Omit<
|
|
31
|
+
React.ImgHTMLAttributes<HTMLImageElement>,
|
|
32
|
+
"src" | "width" | "height" | "loading" | "srcSet"
|
|
33
|
+
> {
|
|
34
|
+
/** Source URL — site-relative (`/foo.jpg`) or http(s) (allowlisted via env). */
|
|
35
|
+
src: string;
|
|
36
|
+
/** Intrinsic width in CSS px (used for aspect ratio + the 1x candidate). */
|
|
37
|
+
width: number;
|
|
38
|
+
/** Intrinsic height in CSS px. */
|
|
39
|
+
height: number;
|
|
40
|
+
/** Required alt text. Pass `""` for purely decorative images. */
|
|
41
|
+
alt: string;
|
|
42
|
+
/**
|
|
43
|
+
* JPEG/WebP quality 1..=100. Default 75 — matches Next.js.
|
|
44
|
+
* PNG ignores it (lossless).
|
|
45
|
+
*/
|
|
46
|
+
quality?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Override the candidate widths used in `srcset`. By default we
|
|
49
|
+
* emit 1x and 2x of `width`, capped at 3840px.
|
|
50
|
+
*/
|
|
51
|
+
widths?: number[];
|
|
52
|
+
/**
|
|
53
|
+
* `<img sizes>` attribute. Default `100vw` — change to match the
|
|
54
|
+
* container width so the browser picks the smallest srcset
|
|
55
|
+
* candidate that fits. Example: `(max-width: 768px) 100vw, 50vw`.
|
|
56
|
+
*/
|
|
57
|
+
sizes?: string;
|
|
58
|
+
/** Skip lazy-loading + bump fetch priority. Use for above-the-fold hero images. */
|
|
59
|
+
priority?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Skip the Pylon optimizer and render `src` directly. Useful for
|
|
62
|
+
* SVGs (the optimizer rejects them as a security precaution),
|
|
63
|
+
* animated GIFs, or formats Pylon doesn't process. The browser
|
|
64
|
+
* still gets `width`/`height` for layout stability, but there's
|
|
65
|
+
* no `srcset` and no caching beyond whatever the source URL
|
|
66
|
+
* declares.
|
|
67
|
+
*/
|
|
68
|
+
unoptimized?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_QUALITY = 75;
|
|
72
|
+
const MAX_WIDTH = 3840;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Default srcset widths — must match (be a subset of) the server's
|
|
76
|
+
* `PYLON_IMAGE_DEVICE_SIZES + PYLON_IMAGE_IMAGE_SIZES`. The server
|
|
77
|
+
* rejects requests for widths outside its allowlist (cache-fill
|
|
78
|
+
* DoS protection), so the React component has to stay in lock-step.
|
|
79
|
+
*
|
|
80
|
+
* If you customize the server's allowed widths, pass `widths={...}`
|
|
81
|
+
* on each `<Image>` (or wrap with your own component) to make sure
|
|
82
|
+
* the React-side candidates land in your custom set.
|
|
83
|
+
*/
|
|
84
|
+
const DEFAULT_WIDTHS = [
|
|
85
|
+
16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840,
|
|
86
|
+
];
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the optimized URL for a given width / quality. The server
|
|
90
|
+
* picks the output format from the request's Accept header
|
|
91
|
+
* (WebP > JPEG); explicit `format=` could be added later.
|
|
92
|
+
*/
|
|
93
|
+
function optimizedUrl(src: string, width: number, quality: number): string {
|
|
94
|
+
const w = Math.min(Math.max(Math.round(width), 32), MAX_WIDTH);
|
|
95
|
+
const q = Math.min(Math.max(quality, 1), 100);
|
|
96
|
+
return `/_pylon/image?src=${encodeURIComponent(src)}&w=${w}&q=${q}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function Image({
|
|
100
|
+
src,
|
|
101
|
+
width,
|
|
102
|
+
height,
|
|
103
|
+
alt,
|
|
104
|
+
quality = DEFAULT_QUALITY,
|
|
105
|
+
widths,
|
|
106
|
+
sizes = "100vw",
|
|
107
|
+
priority = false,
|
|
108
|
+
unoptimized = false,
|
|
109
|
+
className,
|
|
110
|
+
style,
|
|
111
|
+
...rest
|
|
112
|
+
}: ImageProps) {
|
|
113
|
+
// Bypass route — render the source directly. No srcset, no
|
|
114
|
+
// optimizer round-trip. Width/height still set for layout.
|
|
115
|
+
if (unoptimized) {
|
|
116
|
+
return (
|
|
117
|
+
<img
|
|
118
|
+
src={src}
|
|
119
|
+
width={width}
|
|
120
|
+
height={height}
|
|
121
|
+
alt={alt}
|
|
122
|
+
loading={priority ? "eager" : "lazy"}
|
|
123
|
+
{...({ fetchPriority: priority ? "high" : "auto" } as Record<string, string>)}
|
|
124
|
+
decoding="async"
|
|
125
|
+
className={className}
|
|
126
|
+
style={style}
|
|
127
|
+
{...rest}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Candidate widths: explicit list, else default ladder filtered
|
|
133
|
+
// to "smallest that satisfies 1x" through "smallest that
|
|
134
|
+
// satisfies 2x", picked from DEFAULT_WIDTHS. Picking from the
|
|
135
|
+
// ladder (rather than [width, width*2]) keeps URLs in the
|
|
136
|
+
// server's allowed-widths set so we never get back a 400.
|
|
137
|
+
const candidates = (() => {
|
|
138
|
+
if (widths && widths.length > 0) {
|
|
139
|
+
return Array.from(new Set(widths.map((w) => Math.round(w)))).sort(
|
|
140
|
+
(a, b) => a - b,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const oneX = DEFAULT_WIDTHS.find((w) => w >= width) ?? MAX_WIDTH;
|
|
144
|
+
const targetTwoX = Math.min(width * 2, MAX_WIDTH);
|
|
145
|
+
const twoX = DEFAULT_WIDTHS.find((w) => w >= targetTwoX) ?? MAX_WIDTH;
|
|
146
|
+
return Array.from(new Set([oneX, twoX])).sort((a, b) => a - b);
|
|
147
|
+
})();
|
|
148
|
+
|
|
149
|
+
const baseSrc = optimizedUrl(src, candidates[candidates.length - 1], quality);
|
|
150
|
+
const srcSet = candidates
|
|
151
|
+
.map((w) => `${optimizedUrl(src, w, quality)} ${w}w`)
|
|
152
|
+
.join(", ");
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<img
|
|
156
|
+
src={baseSrc}
|
|
157
|
+
srcSet={srcSet}
|
|
158
|
+
sizes={sizes}
|
|
159
|
+
width={width}
|
|
160
|
+
height={height}
|
|
161
|
+
alt={alt}
|
|
162
|
+
loading={priority ? "eager" : "lazy"}
|
|
163
|
+
// React 19 uses the camelCase `fetchPriority`; older React
|
|
164
|
+
// emits `fetchpriority`. Both serialize to the same HTML
|
|
165
|
+
// attribute. We cast to bypass the typings since the prop
|
|
166
|
+
// is still marked experimental on @types/react.
|
|
167
|
+
{...({ fetchPriority: priority ? "high" : "auto" } as Record<string, string>)}
|
|
168
|
+
decoding="async"
|
|
169
|
+
className={className}
|
|
170
|
+
style={style}
|
|
171
|
+
{...rest}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
package/src/Link.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Pylon's Next.js-style <Link>. SSR-safe (renders <a> with no JS),
|
|
2
|
+
// client-side enhanced for instant navigation + prefetch.
|
|
3
|
+
//
|
|
4
|
+
// Behavior on the client (post-hydration):
|
|
5
|
+
// - Renders <a href data-pylon-link>. The runtime's global click
|
|
6
|
+
// handler intercepts the click and calls `__pylon.navigate(href)`,
|
|
7
|
+
// which fetches the new SSR HTML, dynamic-imports the matching
|
|
8
|
+
// route entry, and re-renders the React root in place. Layouts
|
|
9
|
+
// that match across the two routes survive reconciliation, so
|
|
10
|
+
// their state persists.
|
|
11
|
+
// - Prefetches on viewport entry (IntersectionObserver) by default.
|
|
12
|
+
// The runtime injects `<link rel=prefetch>` for the HTML and
|
|
13
|
+
// `<link rel=modulepreload>` for the shared chunks the user
|
|
14
|
+
// will need.
|
|
15
|
+
// - Hover/touch escalation: if the link isn't in the viewport
|
|
16
|
+
// long enough, hovering still triggers prefetch.
|
|
17
|
+
// - Modifier keys (cmd/ctrl/shift/alt) or middle-click fall
|
|
18
|
+
// through to the browser's default behavior (open in new tab,
|
|
19
|
+
// etc.) — matches Next.js semantics.
|
|
20
|
+
//
|
|
21
|
+
// SSR: with no JS or before hydration, this is just an <a href>, so
|
|
22
|
+
// the link works regardless. Progressive enhancement.
|
|
23
|
+
//
|
|
24
|
+
// Disable client-side nav for a specific link with `prefetch={false}`
|
|
25
|
+
// (HTML still prefetches) or by setting `target="_blank"`.
|
|
26
|
+
|
|
27
|
+
import React, { useEffect, useRef } from "react";
|
|
28
|
+
|
|
29
|
+
declare global {
|
|
30
|
+
interface Window {
|
|
31
|
+
__pylon?: {
|
|
32
|
+
prefetch: (href: string) => Promise<void>;
|
|
33
|
+
navigate: (href: string, opts?: { push?: boolean }) => Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LinkProps
|
|
39
|
+
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
|
|
40
|
+
/** Destination path. Same-origin paths get client-side nav; off-origin paths render as plain <a>. */
|
|
41
|
+
href: string;
|
|
42
|
+
/**
|
|
43
|
+
* Prefetch the destination on viewport entry. Default true.
|
|
44
|
+
* Set `false` to skip prefetch (useful for links the user
|
|
45
|
+
* is unlikely to follow — pagination tail, etc.).
|
|
46
|
+
*/
|
|
47
|
+
prefetch?: boolean;
|
|
48
|
+
children?: React.ReactNode;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function Link({
|
|
52
|
+
href,
|
|
53
|
+
prefetch = true,
|
|
54
|
+
children,
|
|
55
|
+
...rest
|
|
56
|
+
}: LinkProps) {
|
|
57
|
+
const ref = useRef<HTMLAnchorElement>(null);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!prefetch || !ref.current) return;
|
|
61
|
+
if (typeof window === "undefined") return;
|
|
62
|
+
// Don't prefetch off-origin / hash-only / non-HTTP links.
|
|
63
|
+
if (
|
|
64
|
+
href.startsWith("http://") ||
|
|
65
|
+
href.startsWith("https://") ||
|
|
66
|
+
href.startsWith("//") ||
|
|
67
|
+
href.startsWith("#") ||
|
|
68
|
+
href.startsWith("mailto:") ||
|
|
69
|
+
href.startsWith("tel:")
|
|
70
|
+
) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const el = ref.current;
|
|
75
|
+
let prefetched = false;
|
|
76
|
+
const doPrefetch = () => {
|
|
77
|
+
if (prefetched) return;
|
|
78
|
+
prefetched = true;
|
|
79
|
+
window.__pylon?.prefetch(href);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
let io: IntersectionObserver | null = null;
|
|
83
|
+
if (typeof IntersectionObserver !== "undefined") {
|
|
84
|
+
io = new IntersectionObserver(
|
|
85
|
+
(entries) => {
|
|
86
|
+
for (const e of entries) {
|
|
87
|
+
if (e.isIntersecting) {
|
|
88
|
+
doPrefetch();
|
|
89
|
+
io?.disconnect();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
{ rootMargin: "256px" },
|
|
94
|
+
);
|
|
95
|
+
io.observe(el);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const onHover = () => doPrefetch();
|
|
99
|
+
el.addEventListener("mouseenter", onHover, { once: true });
|
|
100
|
+
el.addEventListener("touchstart", onHover, { once: true, passive: true });
|
|
101
|
+
|
|
102
|
+
return () => {
|
|
103
|
+
io?.disconnect();
|
|
104
|
+
el.removeEventListener("mouseenter", onHover);
|
|
105
|
+
el.removeEventListener("touchstart", onHover);
|
|
106
|
+
};
|
|
107
|
+
}, [href, prefetch]);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<a
|
|
111
|
+
ref={ref}
|
|
112
|
+
href={href}
|
|
113
|
+
data-pylon-link=""
|
|
114
|
+
{...rest}
|
|
115
|
+
>
|
|
116
|
+
{children}
|
|
117
|
+
</a>
|
|
118
|
+
);
|
|
119
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export { defineRoute } from "@pylonsync/sdk";
|
|
2
2
|
export type { RouteMode, AppManifest } from "@pylonsync/sdk";
|
|
3
3
|
|
|
4
|
+
// SSR primitives — Next.js-style <Link> and <Image>. Both render
|
|
5
|
+
// progressively (work without JS) and enhance on the client.
|
|
6
|
+
export { Link } from "./Link";
|
|
7
|
+
export type { LinkProps } from "./Link";
|
|
8
|
+
export { Image } from "./Image";
|
|
9
|
+
export type { ImageProps } from "./Image";
|
|
10
|
+
|
|
4
11
|
import {
|
|
5
12
|
defaultStorage,
|
|
6
13
|
pylonFetch,
|