Skip to content

Commit 8127f01

Browse files
committed
docs: add image guide to docs example
1 parent c3f7c88 commit 8127f01

8 files changed

Lines changed: 207 additions & 7 deletions

File tree

.changeset/pracht-image-package.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,5 @@ exports `createImageHandler()`, a sharp-backed optimization endpoint (sharp is
1414
an optional peer dependency) mounted as the `src/api/_pracht/image.ts` API
1515
route: it negotiates WebP/AVIF via `Accept`, only serves allowlisted widths,
1616
restricts sources to same-origin unless `remotePatterns` opts hosts in, and
17-
answers with immutable cache headers. See docs/IMAGES.md.
17+
stream-enforces the source image size cap before optimization. Answers with
18+
immutable cache headers. See docs/IMAGES.md.

examples/docs/src/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export const app = defineApp({
5151
id: "styling",
5252
render: "ssg",
5353
}),
54+
route("/docs/images", () => import("./routes/docs/images.md"), {
55+
id: "images",
56+
render: "ssg",
57+
}),
5458
route("/docs/cli", () => import("./routes/docs/cli.md"), {
5559
id: "cli",
5660
render: "ssg",

examples/docs/src/routes/docs/cli.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ title: CLI
33
lead: The <code>@pracht/cli</code> package provides development, build, scaffolding, and doctor commands for your app.
44
breadcrumb: CLI
55
prev:
6-
href: /docs/styling
7-
title: Styling
6+
href: /docs/images
7+
title: Images
88
next:
99
href: /docs/deployment
1010
title: Deployment
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
---
2+
title: Images
3+
lead: Use <code>@pracht/image</code> for responsive image markup, reserved layout space, and deployment-specific optimization loaders.
4+
breadcrumb: Images
5+
prev:
6+
href: /docs/styling
7+
title: Styling
8+
next:
9+
href: /docs/cli
10+
title: CLI
11+
---
12+
13+
## Install
14+
15+
```sh
16+
pnpm add @pracht/image
17+
18+
# Only needed when you use the built-in Node optimization endpoint.
19+
pnpm add sharp
20+
```
21+
22+
`@pracht/image` is split into a framework-agnostic component entry and a Node endpoint entry. Import the component from `@pracht/image`; import the optimization handler from `@pracht/image/node`.
23+
24+
---
25+
26+
## Render an Image
27+
28+
```tsx [src/routes/gallery.tsx]
29+
import { Image } from "@pracht/image";
30+
31+
export function Component() {
32+
return (
33+
<Image
34+
src="/banner.jpg"
35+
alt="Pracht banner"
36+
width={1200}
37+
height={280}
38+
sizes="(max-width: 1200px) 100vw, 1200px"
39+
priority
40+
/>
41+
);
42+
}
43+
```
44+
45+
The component renders plain `<img>` markup, so it works during SSR and SSG without adding client runtime. `loading="lazy"` and `decoding="async"` are the defaults. Use `priority` for above-the-fold images; it switches the image to eager loading and adds `fetchpriority="high"`.
46+
47+
Always provide meaningful `alt` text, or `alt=""` for decorative images.
48+
49+
---
50+
51+
## Reserve Layout Space
52+
53+
Images need either intrinsic dimensions or `fill`:
54+
55+
```tsx
56+
<Image src="/card.jpg" alt="Product preview" width={640} height={360} />
57+
```
58+
59+
For background-style images, use `fill` inside a positioned parent:
60+
61+
```tsx
62+
<div style={{ position: "relative", height: "18rem" }}>
63+
<Image
64+
src="/hero.jpg"
65+
alt="Pracht docs hero"
66+
fill
67+
sizes="100vw"
68+
style={{ objectFit: "cover" }}
69+
/>
70+
</div>
71+
```
72+
73+
`fill` images stretch with `position: absolute; inset: 0`. The parent controls the rendered size, so give the parent a stable height or aspect ratio.
74+
75+
---
76+
77+
## Mount the Default Endpoint
78+
79+
The default loader points at `/api/_pracht/image`. Add an API route at that path to resize and encode same-origin source images with `sharp`:
80+
81+
```ts [src/api/_pracht/image.ts]
82+
import { createImageHandler } from "@pracht/image/node";
83+
84+
export const GET = createImageHandler();
85+
```
86+
87+
This endpoint works in `pracht dev`, adapter-node, and Node-compatible runtimes. It returns immutable cache headers, varies on `Accept`, and negotiates modern output formats such as WebP.
88+
89+
---
90+
91+
## Configure Loaders
92+
93+
Loaders turn `{ src, width, quality }` into a URL. Configure one globally when your deployment platform should serve image variants:
94+
95+
```ts [src/routes.ts]
96+
import { cloudflareLoader, configureImage } from "@pracht/image";
97+
98+
configureImage({
99+
loader: cloudflareLoader,
100+
quality: 75,
101+
});
102+
```
103+
104+
| Loader | Best For |
105+
| ------ | -------- |
106+
| `defaultLoader` | The `/api/_pracht/image` endpoint |
107+
| `cloudflareLoader` | Cloudflare Image Resizing |
108+
| `vercelLoader` | Vercel Image Optimization |
109+
| `passthroughLoader` | Static hosts without an image service |
110+
111+
You can also pass a `loader` prop to a single `<Image>` when one image needs different handling.
112+
113+
---
114+
115+
## Remote Images
116+
117+
The Node endpoint accepts same-origin URLs by default. Allow remote hosts explicitly:
118+
119+
```ts [src/api/_pracht/image.ts]
120+
import { createImageHandler } from "@pracht/image/node";
121+
122+
export const GET = createImageHandler({
123+
remotePatterns: [
124+
{ protocol: "https", hostname: "images.example.com", pathname: "/uploads" },
125+
],
126+
});
127+
```
128+
129+
Remote allowlists are rechecked after redirects. Widths are also restricted to configured breakpoints, which keeps attackers from filling your cache with arbitrary image variants.
130+
131+
---
132+
133+
## Platform Notes
134+
135+
| Target | Recommendation |
136+
| ------ | -------------- |
137+
| Node | Mount `createImageHandler()` and use the default loader |
138+
| Cloudflare Workers | Use `cloudflareLoader`; `sharp` does not run in Workers |
139+
| Vercel | Use `vercelLoader` and keep Vercel image sizes aligned with your Pracht breakpoints |
140+
| Static hosting | Use `passthroughLoader` so images render without an optimization backend |
141+
142+
See the `examples/basic` gallery route for a complete endpoint plus component example.

examples/docs/src/routes/docs/styling.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ prev:
66
href: /docs/shells
77
title: Shells
88
next:
9-
href: /docs/cli
10-
title: CLI
9+
href: /docs/images
10+
title: Images
1111
---
1212

1313
## Recommended Approaches

examples/docs/src/shells/docs.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
IconSparkles,
2424
IconPresentationAnalytics,
2525
IconActivity,
26+
IconPhoto,
2627
} from "@tabler/icons-preact";
2728
import "../styles/global.css";
2829

@@ -45,6 +46,7 @@ const NAV = [
4546
{ href: "/docs/middleware", Icon: IconShield, title: "Middleware" },
4647
{ href: "/docs/shells", Icon: IconLayout, title: "Shells" },
4748
{ href: "/docs/styling", Icon: IconPalette, title: "Styling" },
49+
{ href: "/docs/images", Icon: IconPhoto, title: "Images" },
4850
],
4951
},
5052
{

packages/image/src/node.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,36 @@ function matchesRemotePatterns(url: URL, patterns: RemotePattern[]): boolean {
113113
});
114114
}
115115

116+
async function readCappedBody(response: Response, maxBytes: number): Promise<Uint8Array | null> {
117+
if (!response.body) {
118+
return new Uint8Array(await response.arrayBuffer());
119+
}
120+
121+
const reader = response.body.getReader();
122+
const chunks: Uint8Array[] = [];
123+
let total = 0;
124+
125+
while (true) {
126+
const { done, value } = await reader.read();
127+
if (done) break;
128+
129+
total += value.byteLength;
130+
if (total > maxBytes) {
131+
await reader.cancel();
132+
return null;
133+
}
134+
chunks.push(value);
135+
}
136+
137+
const body = new Uint8Array(total);
138+
let offset = 0;
139+
for (const chunk of chunks) {
140+
body.set(chunk, offset);
141+
offset += chunk.byteLength;
142+
}
143+
return body;
144+
}
145+
116146
/**
117147
* Create the pracht image optimization endpoint.
118148
*
@@ -254,8 +284,8 @@ export function createImageHandler(
254284
return errorResponse(415, `Source "${source}" is not an image (got "${sourceType}").`);
255285
}
256286

257-
const sourceBytes = new Uint8Array(await upstream.arrayBuffer());
258-
if (sourceBytes.byteLength > maxSourceBytes) {
287+
const sourceBytes = await readCappedBody(upstream, maxSourceBytes);
288+
if (sourceBytes == null) {
259289
return errorResponse(413, `Source image "${source}" exceeds ${maxSourceBytes} bytes.`);
260290
}
261291

packages/image/test/node-handler.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,27 @@ describe("createImageHandler optimization", () => {
218218
expect(response.status).toBe(413);
219219
});
220220

221+
it("stops reading the source body after the size cap is exceeded", async () => {
222+
let canceled = false;
223+
const body = new ReadableStream<Uint8Array>({
224+
pull(controller) {
225+
controller.enqueue(new Uint8Array(8));
226+
},
227+
cancel() {
228+
canceled = true;
229+
},
230+
});
231+
const handler = createImageHandler({
232+
fetchImage: async () => new Response(body, { headers: { "content-type": "image/png" } }),
233+
maxSourceBytes: 10,
234+
});
235+
236+
const response = await handler(imageRequest("url=%2Fhero.png&w=640"));
237+
238+
expect(response.status).toBe(413);
239+
expect(canceled).toBe(true);
240+
});
241+
221242
it("explains how to install sharp when it is missing", async () => {
222243
const handler = createImageHandler({
223244
fetchImage: pngFetcher(),

0 commit comments

Comments
 (0)