Skip to content

Commit f312ef2

Browse files
committed
feat: cookie support
1 parent 7d4d053 commit f312ef2

10 files changed

Lines changed: 191 additions & 102 deletions

File tree

README.md

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,20 +63,6 @@ Compile a template string into a render function code string.
6363

6464
Renders an HTML template to a Response object.
6565

66-
The template can access the following variables:
67-
68-
- `globalThis`: The global object.
69-
70-
- `$REQUEST`: The incoming Request object (if provided).
71-
72-
- `$METHOD`: The HTTP method of the request (if provided).
73-
74-
- `$URL`: The URL of the request as a URL object (if provided).
75-
76-
- `$HEADERS`: The headers of the request (if provided).
77-
78-
- `$RESPONSE`: An object to customize the response, with properties: status, statusText, and headers.
79-
8066
**Example:**
8167

8268
```ts

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"test:types": "tsc --noEmit --skipLibCheck"
2828
},
2929
"dependencies": {
30+
"cookie-es": "^2.0.0",
3031
"srvx": "^0.8.14"
3132
},
3233
"devDependencies": {
@@ -36,9 +37,9 @@
3637
"changelogen": "^0.6.2",
3738
"eslint": "^9.33.0",
3839
"eslint-config-unjs": "^0.5.0",
39-
"rendu": "workspace:*",
4040
"obuild": "^0.2.1",
4141
"prettier": "^3.6.2",
42+
"rendu": "workspace:*",
4243
"typescript": "^5.9.2",
4344
"vitest": "^3.2.4"
4445
},

playground/cookies.html

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width,initial-scale=1" />
6+
<title>Cookies</title>
7+
<link
8+
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
9+
rel="stylesheet"
10+
/>
11+
</head>
12+
<body
13+
class="bg-light d-flex justify-content-center align-items-center vh-100"
14+
>
15+
<div class="card text-center shadow p-4" style="max-width: 28rem">
16+
<script server>
17+
const userCookie = $COOKIES["user"] || "Guest";
18+
</script>
19+
<div class="fs-3" id="ip">
20+
<script server>
21+
if ($URL.searchParams.has("login")) {
22+
setCookie("user", "RenduUser");
23+
redirect($URL.pathname);
24+
} else if ($URL.searchParams.has("logout")) {
25+
setCookie("user", "", { maxAge: 0 });
26+
redirect($URL.pathname);
27+
}
28+
</script>
29+
<? if ($COOKIES["user"]) { ?> Welcome back!
30+
<a href="?logout" class="btn btn-sm btn-outline-danger ms-2">Logout</a>
31+
<? } else { ?> Hello, Guest!
32+
<a href="?login" class="btn btn-sm btn-outline-primary ms-2">Login</a>
33+
<? } ?>
34+
</div>
35+
</div>
36+
</body>
37+
</html>

playground/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
{ name: "Counter", path: "/counter" },
44
{ name: "Todos", path: "/todos" },
55
{ name: "Fetch IP", path: "/fetch" },
6+
{ name: "Cookies", path: "/cookies" },
67
];
78
</script>
89
<!doctype html>

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
workspace:
2-
packages:
3-
- "playground"
1+
packages:
2+
- "playground"

src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { resolve } from "node:path";
44
import { log } from "srvx/log";
55
import { serve } from "srvx";
66
import { compileTemplate } from "./compiler.ts";
7-
import { renderToResponse } from "./web.ts";
7+
import { renderToResponse } from "./render.ts";
88
import { serveStatic } from "srvx/static";
99

1010
const entry = resolve(process.argv[2] || ".");

src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,9 @@ export {
55
type CompileTemplateOptions,
66
} from "./compiler.ts";
77

8-
export { renderToResponse, type RenderToResponseOptions } from "./web.ts";
8+
export {
9+
renderToResponse,
10+
createRenderContext,
11+
type RenderContext,
12+
type RenderOptions,
13+
} from "./render.ts";

src/render.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import {
2+
parse as parseCookies,
3+
serialize as serializeCookie,
4+
type CookieSerializeOptions,
5+
} from "cookie-es";
6+
import type { CompiledTemplate } from "./compiler.ts";
7+
8+
export interface RenderOptions {
9+
request?: Request;
10+
context?: Record<string, unknown>;
11+
}
12+
13+
/**
14+
* Renders an HTML template to a Response object.
15+
*
16+
* @example
17+
* ```ts
18+
* import { compileTemplate, renderToResponse } from "rendu";
19+
*
20+
* const render = compileTemplate(template, { stream: true });
21+
*
22+
* const response = await renderToResponse(render, { request });
23+
* ```
24+
* @param htmlTemplate The compiled HTML template.
25+
* @param opts Options for rendering.
26+
* @returns A Response object.
27+
*/
28+
export async function renderToResponse(
29+
htmlTemplate: CompiledTemplate<any>,
30+
opts: RenderOptions,
31+
): Promise<Response> {
32+
const ctx = createRenderContext(opts);
33+
const body = await htmlTemplate(ctx);
34+
if (body instanceof Response) {
35+
return body;
36+
}
37+
return new Response(body, {
38+
status: ctx.$RESPONSE.status,
39+
statusText: ctx.$RESPONSE.statusText,
40+
headers: ctx.$RESPONSE.headers,
41+
});
42+
}
43+
44+
export type RenderContext = {
45+
globalThis: typeof globalThis;
46+
htmlspecialchars: (s: string) => string;
47+
setCookie: (
48+
name: string,
49+
value: string,
50+
options?: CookieSerializeOptions,
51+
) => void;
52+
redirect?: (url: string, status?: number) => void;
53+
$REQUEST?: Request;
54+
$METHOD?: string;
55+
$URL?: URL;
56+
$HEADERS?: Headers;
57+
$COOKIES: Readonly<Record<string, string>>;
58+
$RESPONSE: {
59+
status: number;
60+
statusText: string;
61+
headers: Headers;
62+
};
63+
};
64+
65+
export function createRenderContext(options: RenderOptions): RenderContext {
66+
// URL
67+
const url = new URL(options.request?.url || "http://_");
68+
69+
// Prepared response
70+
const response = {
71+
status: 200,
72+
statusText: "OK",
73+
headers: new Headers({ "Content-Type": "text/html ; charset=utf-8" }),
74+
};
75+
76+
// Cookies
77+
const $COOKIES = lazyCookies(options.request!);
78+
const setCookie = (
79+
name: string,
80+
value: string,
81+
sOpts: CookieSerializeOptions = {},
82+
) => {
83+
response.headers.append("Set-Cookie", serializeCookie(name, value, sOpts));
84+
};
85+
86+
// Redirect
87+
const redirect = (to: string, status = 302) => {
88+
response.status = status;
89+
response.headers.set("Location", to);
90+
};
91+
92+
return {
93+
...options.context,
94+
globalThis,
95+
htmlspecialchars,
96+
setCookie,
97+
redirect,
98+
$REQUEST: options.request,
99+
$METHOD: options.request?.method,
100+
$URL: url,
101+
$HEADERS: options.request?.headers,
102+
$COOKIES,
103+
$RESPONSE: response,
104+
};
105+
}
106+
107+
function lazyCookies(req: Request | undefined) {
108+
if (!req) {
109+
return {};
110+
}
111+
let parsed: Record<string, string> | undefined;
112+
return new Proxy(Object.freeze(Object.create(null)), {
113+
get(_, prop: string) {
114+
if (typeof prop !== "string") return undefined;
115+
parsed ??= parseCookies(req.headers.get("cookie") || "");
116+
return parsed[prop];
117+
},
118+
});
119+
}
120+
121+
function htmlspecialchars(s: string): string {
122+
// prettier-ignore
123+
const htmlSpecialCharsMap: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
124+
return String(s).replace(/[&<>"']/g, (c) => htmlSpecialCharsMap[c] || c);
125+
}

src/web.ts

Lines changed: 0 additions & 82 deletions
This file was deleted.

0 commit comments

Comments
 (0)