@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.223",
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.223",
16
- "@pylonsync/sync": "0.3.223"
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,