@timber-js/app 0.2.0-alpha.131 → 0.2.0-alpha.132
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/dist/server/internal.js +60 -14
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-interception.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/cli.ts +0 -0
- package/src/server/pipeline-interception.ts +14 -2
- package/src/server/pipeline-phases.ts +46 -17
- package/src/server/pipeline.ts +43 -3
- package/src/server/rsc-entry/error-renderer.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +3 -2
- package/LICENSE +0 -8
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline-interception.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-interception.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,cAAc,EAAE,MAAM,CAAC;CACxB;
|
|
1
|
+
{"version":3,"file":"pipeline-interception.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-interception.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,cAAc,EAAE,MAAM,CAAC;CACxB;AAaD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,OAAO,4BAA4B,EAAE,mBAAmB,EAAE,GACnE,uBAAuB,GAAG,IAAI,CAahC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAyBjF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;
|
|
1
|
+
{"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;AAwBD;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,CAAC,CAavB;AAID;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,aAAa,EAAE,aAAa,GAC3B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE,aAAa,GACjD,OAAO,CAAC,YAAY,CAAC,CAkBvB;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,QAAQ,CAAC,CAkRnB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAY3D,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/server/pipeline.ts"],"names":[],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ssr-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/ssr-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAIlE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"ssr-renderer.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/ssr-renderer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAIlE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAEjD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAY/D,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE5E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAarD;;;;;;;;GAQG;AACH,eAAO,MAAM,gCAAgC,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAiB,CAAC;AAEhF,UAAU,gBAAgB;IACxB,GAAG,EAAE,OAAO,CAAC;IACb,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,CAAC;IACtC,OAAO,EAAE,aAAa,CAAC;IACvB,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,KAAK,EAAE,UAAU,CAAC;IAClB,eAAe,EAAE,OAAO,CAAC;IACzB,eAAe,EAAE,qBAAqB,CAAC;IACvC,gBAAgB,EAAE,OAAO,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,MAAM,CAAC;IACzB,wDAAwD;IACxD,WAAW,CAAC,EAAE,eAAe,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqSjF"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.132",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -114,6 +114,11 @@
|
|
|
114
114
|
"publishConfig": {
|
|
115
115
|
"access": "public"
|
|
116
116
|
},
|
|
117
|
+
"scripts": {
|
|
118
|
+
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
119
|
+
"typecheck": "tsgo --noEmit",
|
|
120
|
+
"prepublishOnly": "pnpm run build"
|
|
121
|
+
},
|
|
117
122
|
"dependencies": {
|
|
118
123
|
"@opentelemetry/api": "^1.9.1",
|
|
119
124
|
"@opentelemetry/context-async-hooks": "^2.6.1",
|
|
@@ -157,9 +162,5 @@
|
|
|
157
162
|
},
|
|
158
163
|
"engines": {
|
|
159
164
|
"node": ">=22.12.0"
|
|
160
|
-
},
|
|
161
|
-
"scripts": {
|
|
162
|
-
"build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
|
|
163
|
-
"typecheck": "tsgo --noEmit"
|
|
164
165
|
}
|
|
165
|
-
}
|
|
166
|
+
}
|
package/src/cli.ts
CHANGED
|
File without changes
|
|
@@ -17,6 +17,17 @@ export interface InterceptionMatchResult {
|
|
|
17
17
|
sourcePathname: string;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Check if a pathname starts with a prefix on a segment boundary.
|
|
22
|
+
*
|
|
23
|
+
* Prevents /feed from matching /feed-private — the prefix must be
|
|
24
|
+
* followed by '/' or be an exact match.
|
|
25
|
+
*/
|
|
26
|
+
function hasSegmentPrefix(pathname: string, prefix: string): boolean {
|
|
27
|
+
if (!pathname.startsWith(prefix)) return false;
|
|
28
|
+
return pathname.length === prefix.length || pathname[prefix.length] === '/';
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
/**
|
|
21
32
|
* Check if an intercepting route applies for this soft navigation.
|
|
22
33
|
*
|
|
@@ -31,8 +42,9 @@ export function findInterceptionMatch(
|
|
|
31
42
|
rewrites: import('../routing/interception.js').InterceptionRewrite[]
|
|
32
43
|
): InterceptionMatchResult | null {
|
|
33
44
|
for (const rewrite of rewrites) {
|
|
34
|
-
// Check if the source URL starts with the intercepting prefix
|
|
35
|
-
|
|
45
|
+
// Check if the source URL starts with the intercepting prefix,
|
|
46
|
+
// enforcing segment boundary to prevent /feed matching /feed-private.
|
|
47
|
+
if (!hasSegmentPrefix(sourceUrl, rewrite.interceptingPrefix)) continue;
|
|
36
48
|
|
|
37
49
|
// Check if the target URL matches the intercepted pattern.
|
|
38
50
|
// Dynamic segments in the pattern match any single URL segment.
|
|
@@ -43,6 +43,26 @@ interface RenderContext {
|
|
|
43
43
|
interception?: InterceptionContext;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Validate and canonicalize the X-Timber-URL header value.
|
|
48
|
+
*
|
|
49
|
+
* Returns the canonical pathname if valid, or null if rejected:
|
|
50
|
+
* - Must start with '/' (relative pathname, no scheme/authority)
|
|
51
|
+
* - Must not contain control characters
|
|
52
|
+
* - Must pass canonicalization (no encoded separators, null bytes, etc.)
|
|
53
|
+
*/
|
|
54
|
+
function validateInterceptionHeader(raw: string, stripTrailingSlash: boolean): string | null {
|
|
55
|
+
if (!raw.startsWith('/')) return null;
|
|
56
|
+
if (raw.startsWith('//')) return null;
|
|
57
|
+
for (let i = 0; i < raw.length; i++) {
|
|
58
|
+
const code = raw.charCodeAt(i);
|
|
59
|
+
if (code <= 0x1f || code === 0x7f) return null;
|
|
60
|
+
}
|
|
61
|
+
const result = canonicalize(raw, stripTrailingSlash);
|
|
62
|
+
if (!result.ok) return null;
|
|
63
|
+
return result.pathname;
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
// ─── Phase: Proxy ──────────────────────────────────────────────────────────
|
|
47
67
|
|
|
48
68
|
/**
|
|
@@ -203,7 +223,11 @@ export async function handleRequest(
|
|
|
203
223
|
): Promise<Response> {
|
|
204
224
|
const stripTrailingSlash = config.stripTrailingSlash ?? true;
|
|
205
225
|
|
|
206
|
-
// Stage 1: URL canonicalization
|
|
226
|
+
// Stage 1: URL canonicalization (belt-and-suspenders).
|
|
227
|
+
// The primary canonicalization runs in createPipeline before proxy.ts
|
|
228
|
+
// (design/07-routing.md §"URL Canonicalization & Security"). This second
|
|
229
|
+
// pass is a safety net for direct callers (tests, non-pipeline usage) —
|
|
230
|
+
// on already-canonical paths from createPipeline it's a cheap no-op.
|
|
207
231
|
const url = new URL(req.url);
|
|
208
232
|
const result = canonicalize(url.pathname, stripTrailingSlash);
|
|
209
233
|
if (!result.ok) {
|
|
@@ -322,22 +346,27 @@ export async function handleRequest(
|
|
|
322
346
|
let interception: InterceptionContext | undefined;
|
|
323
347
|
|
|
324
348
|
// Stage 2a: Intercepting route resolution (modal pattern).
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
349
|
+
// Only honored on RSC client-navigation requests (Accept: text/x-component)
|
|
350
|
+
// to prevent spoofing from plain HTML navigations or raw HTTP clients.
|
|
351
|
+
// The header value must be a valid relative pathname — reject schemes,
|
|
352
|
+
// authority, and control characters. Canonicalize before matching.
|
|
353
|
+
if (isRscRequest && config.interceptionRewrites?.length) {
|
|
354
|
+
const rawSourceUrl = req.headers.get('X-Timber-URL');
|
|
355
|
+
const validatedSourceUrl = rawSourceUrl
|
|
356
|
+
? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash)
|
|
357
|
+
: null;
|
|
358
|
+
if (validatedSourceUrl) {
|
|
359
|
+
const intercepted = findInterceptionMatch(
|
|
360
|
+
canonicalPathname,
|
|
361
|
+
validatedSourceUrl,
|
|
362
|
+
config.interceptionRewrites
|
|
363
|
+
);
|
|
364
|
+
if (intercepted) {
|
|
365
|
+
const sourceMatch = config.matchRoute(intercepted.sourcePathname);
|
|
366
|
+
if (sourceMatch) {
|
|
367
|
+
match = sourceMatch;
|
|
368
|
+
interception = { targetPathname: canonicalPathname };
|
|
369
|
+
}
|
|
341
370
|
}
|
|
342
371
|
}
|
|
343
372
|
}
|
package/src/server/pipeline.ts
CHANGED
|
@@ -33,6 +33,8 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
33
33
|
import { makeProxyResolver } from './pipeline-helpers.js';
|
|
34
34
|
import { handleRequest, runProxyPhase } from './pipeline-phases.js';
|
|
35
35
|
import { outcomeToResponse } from './pipeline-outcome.js';
|
|
36
|
+
import { canonicalize } from './canonicalize.js';
|
|
37
|
+
import { isDebug } from './debug.js';
|
|
36
38
|
|
|
37
39
|
// ─── Route Match Result ────────────────────────────────────────────────────
|
|
38
40
|
|
|
@@ -282,12 +284,50 @@ export function createPipeline(config: PipelineConfig): (req: Request) => Promis
|
|
|
282
284
|
replaceTraceId(otelIds.traceId, otelIds.spanId);
|
|
283
285
|
}
|
|
284
286
|
|
|
287
|
+
// Stage 0: Canonicalize URL at the outer boundary — before
|
|
288
|
+
// proxy.ts sees the request. Every layer (proxy, middleware,
|
|
289
|
+
// access, render) must see the same canonical path.
|
|
290
|
+
// See design/07-routing.md §"URL Canonicalization & Security".
|
|
291
|
+
const stripTrailingSlash = config.stripTrailingSlash ?? true;
|
|
292
|
+
const canonResult = canonicalize(url.pathname, stripTrailingSlash);
|
|
293
|
+
if (!canonResult.ok) {
|
|
294
|
+
if (isDebug()) {
|
|
295
|
+
console.warn(
|
|
296
|
+
`[timber] URL canonicalization rejected ${method} ${url.pathname} — responding with ${canonResult.status}\n` +
|
|
297
|
+
` This usually means the URL contains encoded separators (%2f, %5c),\n` +
|
|
298
|
+
` null bytes (%00), path traversal (..), or malformed percent-encoding.`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return new Response(null, { status: canonResult.status });
|
|
302
|
+
}
|
|
303
|
+
const canonicalPath = canonResult.pathname;
|
|
304
|
+
|
|
305
|
+
// Construct a Request with the canonical URL so proxy.ts sees
|
|
306
|
+
// the same path that route matching will use. This prevents
|
|
307
|
+
// auth bypass via /admin/, /%61dmin, etc.
|
|
308
|
+
let canonicalReq = req;
|
|
309
|
+
if (url.pathname !== canonicalPath) {
|
|
310
|
+
const canonicalUrl = new URL(req.url);
|
|
311
|
+
canonicalUrl.pathname = canonicalPath;
|
|
312
|
+
canonicalReq = new Request(canonicalUrl.toString(), req);
|
|
313
|
+
}
|
|
314
|
+
|
|
285
315
|
let result: Response;
|
|
286
316
|
if (proxyResolver) {
|
|
287
|
-
const outcome = await runProxyPhase(
|
|
288
|
-
|
|
317
|
+
const outcome = await runProxyPhase(
|
|
318
|
+
config,
|
|
319
|
+
proxyResolver,
|
|
320
|
+
canonicalReq,
|
|
321
|
+
method,
|
|
322
|
+
canonicalPath
|
|
323
|
+
);
|
|
324
|
+
result = await outcomeToResponse(config, outcome, {
|
|
325
|
+
req: canonicalReq,
|
|
326
|
+
method,
|
|
327
|
+
path: canonicalPath,
|
|
328
|
+
});
|
|
289
329
|
} else {
|
|
290
|
-
result = await handleRequest(config,
|
|
330
|
+
result = await handleRequest(config, canonicalReq, method, canonicalPath);
|
|
291
331
|
}
|
|
292
332
|
|
|
293
333
|
// Set response status on the root span before it ends —
|
|
@@ -20,7 +20,7 @@ import { logRenderError } from '../logger.js';
|
|
|
20
20
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
21
21
|
import { DenySignal, RenderError } from '../primitives.js';
|
|
22
22
|
import type { ClientBootstrapConfig } from '../html-injectors.js';
|
|
23
|
-
import { flightInitScript } from '../flight-scripts.js';
|
|
23
|
+
import { flightInitScript, htmlEscapeJsonString } from '../flight-scripts.js';
|
|
24
24
|
import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
|
|
25
25
|
import type { LayoutEntry } from '../deny-renderer.js';
|
|
26
26
|
import type { NavContext } from '../ssr-bridge-types.js';
|
|
@@ -284,7 +284,7 @@ async function renderErrorPageViaRsc(
|
|
|
284
284
|
const paramsScript =
|
|
285
285
|
Object.keys(match.segmentParams).length === 0
|
|
286
286
|
? ''
|
|
287
|
-
: `<script>self.__timber_params=${JSON.stringify(match.segmentParams)}</script>`;
|
|
287
|
+
: `<script>self.__timber_params=${htmlEscapeJsonString(JSON.stringify(match.segmentParams))}</script>`;
|
|
288
288
|
|
|
289
289
|
const navContext: NavContext = {
|
|
290
290
|
pathname: url.pathname,
|
|
@@ -22,6 +22,7 @@ import type { LayoutComponentEntry } from '../route-element-builder.js';
|
|
|
22
22
|
import type { ManifestSegmentNode } from '../route-matcher.js';
|
|
23
23
|
import type { NavContext } from '../ssr-bridge-types.js';
|
|
24
24
|
|
|
25
|
+
import { htmlEscapeJsonString } from '../flight-scripts.js';
|
|
25
26
|
import {
|
|
26
27
|
buildRedirectResponse,
|
|
27
28
|
buildSegmentInfo,
|
|
@@ -138,14 +139,14 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
|
|
|
138
139
|
// Skipped when client JS is disabled — no client JS to consume it.
|
|
139
140
|
const segmentScript = clientJsDisabled
|
|
140
141
|
? ''
|
|
141
|
-
: `<script>self.__timber_segments=${JSON.stringify(buildSegmentInfo(segments, layoutComponents))}</script>`;
|
|
142
|
+
: `<script>self.__timber_segments=${htmlEscapeJsonString(JSON.stringify(buildSegmentInfo(segments, layoutComponents)))}</script>`;
|
|
142
143
|
|
|
143
144
|
// Embed route params in HTML so useSegmentParams() works on initial hydration.
|
|
144
145
|
// Without this, useSegmentParams() returns {} until the first client navigation.
|
|
145
146
|
const paramsScript =
|
|
146
147
|
clientJsDisabled || Object.keys(match.segmentParams).length === 0
|
|
147
148
|
? ''
|
|
148
|
-
: `<script>self.__timber_params=${JSON.stringify(match.segmentParams)}</script>`;
|
|
149
|
+
: `<script>self.__timber_params=${htmlEscapeJsonString(JSON.stringify(match.segmentParams))}</script>`;
|
|
149
150
|
|
|
150
151
|
const navContext: NavContext = {
|
|
151
152
|
pathname: new URL(req.url).pathname,
|
package/LICENSE
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
DONTFUCKINGUSE LICENSE
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Daniel Saewitz
|
|
4
|
-
|
|
5
|
-
This software may not be used, copied, modified, merged, published,
|
|
6
|
-
distributed, sublicensed, or sold by anyone other than the copyright holder.
|
|
7
|
-
|
|
8
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|