@timber-js/app 0.1.1 → 0.1.3
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/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +420 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +391 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +214 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- package/src/utils/startup-timer.ts +102 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usePathname() — client-side hook for reading the current pathname.
|
|
3
|
+
*
|
|
4
|
+
* Returns the pathname portion of the current URL (e.g. '/dashboard/settings').
|
|
5
|
+
* Updates when client-side navigation changes the URL.
|
|
6
|
+
*
|
|
7
|
+
* This is a thin wrapper over window.location.pathname, provided for
|
|
8
|
+
* Next.js API compatibility (libraries like nuqs import usePathname
|
|
9
|
+
* from next/navigation).
|
|
10
|
+
*
|
|
11
|
+
* During SSR, reads the request pathname from the SSR ALS context
|
|
12
|
+
* (populated by ssr-entry.ts) instead of window.location.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useSyncExternalStore } from 'react';
|
|
16
|
+
import { getSsrData } from './ssr-data.js';
|
|
17
|
+
|
|
18
|
+
function getPathname(): string {
|
|
19
|
+
if (typeof window !== 'undefined') return window.location.pathname;
|
|
20
|
+
return getSsrData()?.pathname ?? '/';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getServerPathname(): string {
|
|
24
|
+
return getSsrData()?.pathname ?? '/';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function subscribe(callback: () => void): () => void {
|
|
28
|
+
// Listen for popstate (back/forward) and timber's custom navigation events.
|
|
29
|
+
// pushState/replaceState don't fire popstate, but timber's router calls
|
|
30
|
+
// onPendingChange listeners after navigation — components re-render
|
|
31
|
+
// naturally via React's tree update from the new RSC payload.
|
|
32
|
+
window.addEventListener('popstate', callback);
|
|
33
|
+
return () => window.removeEventListener('popstate', callback);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read the current URL pathname.
|
|
38
|
+
*
|
|
39
|
+
* Compatible with Next.js's `usePathname()` from `next/navigation`.
|
|
40
|
+
*/
|
|
41
|
+
export function usePathname(): string {
|
|
42
|
+
return useSyncExternalStore(subscribe, getPathname, getServerPathname);
|
|
43
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useQueryStates — client-side hook for URL-synced search params.
|
|
3
|
+
*
|
|
4
|
+
* Delegates to nuqs for URL synchronization, batching, React 19 transitions,
|
|
5
|
+
* and throttled URL writes. Bridges timber's SearchParamCodec protocol to
|
|
6
|
+
* nuqs-compatible parsers.
|
|
7
|
+
*
|
|
8
|
+
* Design doc: design/23-search-params.md §"Codec Bridge"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use client';
|
|
12
|
+
|
|
13
|
+
import { useQueryStates as nuqsUseQueryStates } from 'nuqs';
|
|
14
|
+
import type { SingleParser } from 'nuqs';
|
|
15
|
+
import type {
|
|
16
|
+
SearchParamCodec,
|
|
17
|
+
SearchParamsDefinition,
|
|
18
|
+
SetParams,
|
|
19
|
+
QueryStatesOptions,
|
|
20
|
+
} from '#/search-params/create.js';
|
|
21
|
+
import { getSearchParams } from '#/search-params/registry.js';
|
|
22
|
+
|
|
23
|
+
// ─── Codec Bridge ─────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Bridge a timber SearchParamCodec to a nuqs-compatible SingleParser.
|
|
27
|
+
*
|
|
28
|
+
* nuqs parsers: { parse(string) → T|null, serialize?(T) → string, eq?, defaultValue? }
|
|
29
|
+
* timber codecs: { parse(string|string[]|undefined) → T, serialize(T) → string|null }
|
|
30
|
+
*/
|
|
31
|
+
function bridgeCodec<T>(codec: SearchParamCodec<T>): SingleParser<T> & { defaultValue: T } {
|
|
32
|
+
return {
|
|
33
|
+
parse: (v: string) => codec.parse(v),
|
|
34
|
+
serialize: (v: T) => codec.serialize(v) ?? '',
|
|
35
|
+
defaultValue: codec.parse(undefined) as T,
|
|
36
|
+
eq: (a: T, b: T) => codec.serialize(a) === codec.serialize(b),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Bridge an entire codec map to nuqs-compatible parsers.
|
|
42
|
+
*/
|
|
43
|
+
function bridgeCodecs<T extends Record<string, unknown>>(codecs: {
|
|
44
|
+
[K in keyof T]: SearchParamCodec<T[K]>;
|
|
45
|
+
}) {
|
|
46
|
+
const result: Record<string, SingleParser<unknown> & { defaultValue: unknown }> = {};
|
|
47
|
+
for (const key of Object.keys(codecs)) {
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
result[key] = bridgeCodec(codecs[key as keyof T]) as any;
|
|
50
|
+
}
|
|
51
|
+
return result as { [K in keyof T]: SingleParser<T[K]> & { defaultValue: T[K] } };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Hook ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read and write typed search params from/to the URL.
|
|
58
|
+
*
|
|
59
|
+
* Delegates to nuqs internally. The timber nuqs adapter (auto-injected in
|
|
60
|
+
* browser-entry.ts) handles RSC navigation on non-shallow updates.
|
|
61
|
+
*
|
|
62
|
+
* Usage:
|
|
63
|
+
* ```ts
|
|
64
|
+
* // Via a SearchParamsDefinition
|
|
65
|
+
* const [params, setParams] = definition.useQueryStates()
|
|
66
|
+
*
|
|
67
|
+
* // Standalone with inline codecs
|
|
68
|
+
* const [params, setParams] = useQueryStates({
|
|
69
|
+
* page: fromSchema(z.coerce.number().int().min(1).default(1)),
|
|
70
|
+
* })
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function useQueryStates<T extends Record<string, unknown>>(
|
|
74
|
+
codecsOrRoute: { [K in keyof T]: SearchParamCodec<T[K]> } | string,
|
|
75
|
+
_options?: QueryStatesOptions,
|
|
76
|
+
urlKeys?: Readonly<Record<string, string>>
|
|
77
|
+
): [T, SetParams<T>] {
|
|
78
|
+
// Route-string overload: resolve codecs from the registry
|
|
79
|
+
let codecs: { [K in keyof T]: SearchParamCodec<T[K]> };
|
|
80
|
+
let resolvedUrlKeys = urlKeys;
|
|
81
|
+
if (typeof codecsOrRoute === 'string') {
|
|
82
|
+
const definition = getSearchParams(codecsOrRoute);
|
|
83
|
+
if (!definition) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`useQueryStates('${codecsOrRoute}'): no search params registered for this route. ` +
|
|
86
|
+
`Either the route has no search-params.ts file, or it hasn't been loaded yet. ` +
|
|
87
|
+
`For cross-route usage, import the definition explicitly.`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
codecs = definition.codecs as { [K in keyof T]: SearchParamCodec<T[K]> };
|
|
91
|
+
resolvedUrlKeys = definition.urlKeys;
|
|
92
|
+
} else {
|
|
93
|
+
codecs = codecsOrRoute;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const bridged = bridgeCodecs(codecs);
|
|
97
|
+
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
const nuqsOptions: any = {};
|
|
100
|
+
if (resolvedUrlKeys && Object.keys(resolvedUrlKeys).length > 0) {
|
|
101
|
+
nuqsOptions.urlKeys = resolvedUrlKeys;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const [values, setValues] = nuqsUseQueryStates(bridged, nuqsOptions);
|
|
105
|
+
|
|
106
|
+
// Wrap the nuqs setter to match timber's SetParams<T> signature.
|
|
107
|
+
// nuqs's setter accepts Partial<Nullable<Values>> | UpdaterFn | null.
|
|
108
|
+
// timber's setter accepts Partial<T> with optional SetParamsOptions.
|
|
109
|
+
const setParams: SetParams<T> = (partial, setOptions?) => {
|
|
110
|
+
const nuqsSetOptions: Record<string, unknown> = {};
|
|
111
|
+
if (setOptions?.shallow !== undefined) nuqsSetOptions.shallow = setOptions.shallow;
|
|
112
|
+
if (setOptions?.scroll !== undefined) nuqsSetOptions.scroll = setOptions.scroll;
|
|
113
|
+
if (setOptions?.history !== undefined) nuqsSetOptions.history = setOptions.history;
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
115
|
+
void setValues(partial as any, nuqsSetOptions);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return [values as T, setParams];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Definition binding ───────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Create a useQueryStates binding for a SearchParamsDefinition.
|
|
125
|
+
* This is used internally by SearchParamsDefinition.useQueryStates().
|
|
126
|
+
*/
|
|
127
|
+
export function bindUseQueryStates<T extends Record<string, unknown>>(
|
|
128
|
+
definition: SearchParamsDefinition<T>
|
|
129
|
+
): (options?: QueryStatesOptions) => [T, SetParams<T>] {
|
|
130
|
+
return (options?: QueryStatesOptions) => {
|
|
131
|
+
return useQueryStates<T>(definition.codecs, options, definition.urlKeys);
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useRouter() — client-side hook for programmatic navigation.
|
|
3
|
+
*
|
|
4
|
+
* Returns a router instance with push, replace, refresh, back, forward,
|
|
5
|
+
* and prefetch methods. Compatible with Next.js's `useRouter()` from
|
|
6
|
+
* `next/navigation` (App Router).
|
|
7
|
+
*
|
|
8
|
+
* This wraps timber's internal RouterInstance in the Next.js-compatible
|
|
9
|
+
* AppRouterInstance shape that ecosystem libraries expect.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getRouter } from './router-ref.js';
|
|
13
|
+
|
|
14
|
+
export interface AppRouterInstance {
|
|
15
|
+
/** Navigate to a URL, pushing a new history entry */
|
|
16
|
+
push(href: string, options?: { scroll?: boolean }): void;
|
|
17
|
+
/** Navigate to a URL, replacing the current history entry */
|
|
18
|
+
replace(href: string, options?: { scroll?: boolean }): void;
|
|
19
|
+
/** Refresh the current page (re-fetch RSC payload) */
|
|
20
|
+
refresh(): void;
|
|
21
|
+
/** Navigate back in history */
|
|
22
|
+
back(): void;
|
|
23
|
+
/** Navigate forward in history */
|
|
24
|
+
forward(): void;
|
|
25
|
+
/** Prefetch an RSC payload for a URL */
|
|
26
|
+
prefetch(href: string): void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** No-op router returned during SSR or before bootstrap. All methods are safe no-ops. */
|
|
30
|
+
const SSR_NOOP_ROUTER: AppRouterInstance = {
|
|
31
|
+
push() {},
|
|
32
|
+
replace() {},
|
|
33
|
+
refresh() {},
|
|
34
|
+
back() {},
|
|
35
|
+
forward() {},
|
|
36
|
+
prefetch() {},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get a router instance for programmatic navigation.
|
|
41
|
+
*
|
|
42
|
+
* Compatible with Next.js's `useRouter()` from `next/navigation`.
|
|
43
|
+
*
|
|
44
|
+
* Returns a no-op router during SSR or before the client router is bootstrapped,
|
|
45
|
+
* so components that call useRouter() at the function level (e.g. TransitionLink)
|
|
46
|
+
* do not crash during server-side rendering.
|
|
47
|
+
*/
|
|
48
|
+
export function useRouter(): AppRouterInstance {
|
|
49
|
+
let router;
|
|
50
|
+
try {
|
|
51
|
+
router = getRouter();
|
|
52
|
+
} catch {
|
|
53
|
+
// Router not yet bootstrapped — SSR or early client render before bootstrap().
|
|
54
|
+
return SSR_NOOP_ROUTER;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
push(href: string, options?: { scroll?: boolean }) {
|
|
59
|
+
void router.navigate(href, { scroll: options?.scroll });
|
|
60
|
+
},
|
|
61
|
+
replace(href: string, options?: { scroll?: boolean }) {
|
|
62
|
+
void router.navigate(href, { scroll: options?.scroll, replace: true });
|
|
63
|
+
},
|
|
64
|
+
refresh() {
|
|
65
|
+
void router.refresh();
|
|
66
|
+
},
|
|
67
|
+
back() {
|
|
68
|
+
window.history.back();
|
|
69
|
+
},
|
|
70
|
+
forward() {
|
|
71
|
+
window.history.forward();
|
|
72
|
+
},
|
|
73
|
+
prefetch(href: string) {
|
|
74
|
+
router.prefetch(href);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSearchParams() — client-side hook for reading URL search params.
|
|
3
|
+
*
|
|
4
|
+
* Returns a read-only URLSearchParams instance reflecting the current
|
|
5
|
+
* URL's query string. Updates when client-side navigation changes the URL.
|
|
6
|
+
*
|
|
7
|
+
* This is a thin wrapper over window.location.search, provided for
|
|
8
|
+
* Next.js API compatibility (libraries like nuqs import useSearchParams
|
|
9
|
+
* from next/navigation).
|
|
10
|
+
*
|
|
11
|
+
* Unlike Next.js's ReadonlyURLSearchParams, this returns a standard
|
|
12
|
+
* URLSearchParams. Mutation methods (set, delete, append) work on the
|
|
13
|
+
* local copy but do NOT affect the URL — use the router or nuqs for that.
|
|
14
|
+
*
|
|
15
|
+
* During SSR, reads the request search params from the SSR ALS context
|
|
16
|
+
* (populated by ssr-entry.ts) instead of window.location.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useSyncExternalStore } from 'react';
|
|
20
|
+
import { getSsrData } from './ssr-data.js';
|
|
21
|
+
|
|
22
|
+
function getSearch(): string {
|
|
23
|
+
if (typeof window !== 'undefined') return window.location.search;
|
|
24
|
+
const data = getSsrData();
|
|
25
|
+
if (!data) return '';
|
|
26
|
+
const sp = new URLSearchParams(data.searchParams);
|
|
27
|
+
const str = sp.toString();
|
|
28
|
+
return str ? `?${str}` : '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getServerSearch(): string {
|
|
32
|
+
const data = getSsrData();
|
|
33
|
+
if (!data) return '';
|
|
34
|
+
const sp = new URLSearchParams(data.searchParams);
|
|
35
|
+
const str = sp.toString();
|
|
36
|
+
return str ? `?${str}` : '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function subscribe(callback: () => void): () => void {
|
|
40
|
+
window.addEventListener('popstate', callback);
|
|
41
|
+
return () => window.removeEventListener('popstate', callback);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Cache the last search string and its parsed URLSearchParams to avoid
|
|
45
|
+
// creating a new object on every render when the URL hasn't changed.
|
|
46
|
+
let cachedSearch = '';
|
|
47
|
+
let cachedParams = new URLSearchParams();
|
|
48
|
+
|
|
49
|
+
function getSearchParams(): URLSearchParams {
|
|
50
|
+
const search = getSearch();
|
|
51
|
+
if (search !== cachedSearch) {
|
|
52
|
+
cachedSearch = search;
|
|
53
|
+
cachedParams = new URLSearchParams(search);
|
|
54
|
+
}
|
|
55
|
+
return cachedParams;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function getServerSearchParams(): URLSearchParams {
|
|
59
|
+
const data = getSsrData();
|
|
60
|
+
return data ? new URLSearchParams(data.searchParams) : new URLSearchParams();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the current URL search params.
|
|
65
|
+
*
|
|
66
|
+
* Compatible with Next.js's `useSearchParams()` from `next/navigation`.
|
|
67
|
+
*/
|
|
68
|
+
export function useSearchParams(): URLSearchParams {
|
|
69
|
+
// useSyncExternalStore needs a primitive snapshot for comparison.
|
|
70
|
+
// We use the raw search string as the snapshot, then return the
|
|
71
|
+
// parsed URLSearchParams.
|
|
72
|
+
useSyncExternalStore(subscribe, getSearch, getServerSearch);
|
|
73
|
+
return typeof window !== 'undefined' ? getSearchParams() : getServerSearchParams();
|
|
74
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useSelectedLayoutSegment / useSelectedLayoutSegments — client-side hooks
|
|
3
|
+
* for reading the active segment(s) below the current layout.
|
|
4
|
+
*
|
|
5
|
+
* These hooks are used by navigation UIs to highlight active sections.
|
|
6
|
+
* They match Next.js's API from next/navigation.
|
|
7
|
+
*
|
|
8
|
+
* How they work:
|
|
9
|
+
* 1. Each layout is wrapped with a SegmentProvider that records its depth
|
|
10
|
+
* (the URL segments from root to that layout level).
|
|
11
|
+
* 2. The hooks read the current URL pathname via usePathname().
|
|
12
|
+
* 3. They compare the layout's segment depth against the full URL segments
|
|
13
|
+
* to determine which child segments are "selected" below.
|
|
14
|
+
*
|
|
15
|
+
* Example: For URL "/dashboard/settings/profile"
|
|
16
|
+
* - Root layout (depth 0, segments: ['']): selected segment = "dashboard"
|
|
17
|
+
* - Dashboard layout (depth 1, segments: ['', 'dashboard']): selected = "settings"
|
|
18
|
+
* - Settings layout (depth 2, segments: ['', 'dashboard', 'settings']): selected = "profile"
|
|
19
|
+
*
|
|
20
|
+
* Design docs: design/19-client-navigation.md, design/14-ecosystem.md
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use client';
|
|
24
|
+
|
|
25
|
+
import { useSegmentContext } from './segment-context.js';
|
|
26
|
+
import { usePathname } from './use-pathname.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Split a pathname into URL segments.
|
|
30
|
+
* "/" → [""]
|
|
31
|
+
* "/dashboard" → ["", "dashboard"]
|
|
32
|
+
* "/dashboard/settings" → ["", "dashboard", "settings"]
|
|
33
|
+
*/
|
|
34
|
+
export function pathnameToSegments(pathname: string): string[] {
|
|
35
|
+
return pathname.split('/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Pure function: compute the selected child segment given a layout's segment
|
|
40
|
+
* depth and the current URL pathname.
|
|
41
|
+
*
|
|
42
|
+
* @param contextSegments — segments from root to the calling layout, or null if no context
|
|
43
|
+
* @param pathname — current URL pathname
|
|
44
|
+
* @returns the active child segment one level below, or null if at the leaf
|
|
45
|
+
*/
|
|
46
|
+
export function getSelectedSegment(
|
|
47
|
+
contextSegments: string[] | null,
|
|
48
|
+
pathname: string
|
|
49
|
+
): string | null {
|
|
50
|
+
const urlSegments = pathnameToSegments(pathname);
|
|
51
|
+
|
|
52
|
+
if (!contextSegments) {
|
|
53
|
+
return urlSegments[1] || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const depth = contextSegments.length;
|
|
57
|
+
return urlSegments[depth] || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Pure function: compute all selected segments below a layout's depth.
|
|
62
|
+
*
|
|
63
|
+
* @param contextSegments — segments from root to the calling layout, or null if no context
|
|
64
|
+
* @param pathname — current URL pathname
|
|
65
|
+
* @returns all active segments below the layout
|
|
66
|
+
*/
|
|
67
|
+
export function getSelectedSegments(contextSegments: string[] | null, pathname: string): string[] {
|
|
68
|
+
const urlSegments = pathnameToSegments(pathname);
|
|
69
|
+
|
|
70
|
+
if (!contextSegments) {
|
|
71
|
+
return urlSegments.slice(1).filter(Boolean);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const depth = contextSegments.length;
|
|
75
|
+
return urlSegments.slice(depth).filter(Boolean);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns the active child segment one level below the layout where this
|
|
80
|
+
* hook is called. Returns `null` if the layout is the leaf (no child segment).
|
|
81
|
+
*
|
|
82
|
+
* Compatible with Next.js's `useSelectedLayoutSegment()` from `next/navigation`.
|
|
83
|
+
*
|
|
84
|
+
* @param parallelRouteKey — Optional parallel route key. Currently unused
|
|
85
|
+
* (parallel route segment tracking is not yet implemented). Accepted for
|
|
86
|
+
* API compatibility with Next.js.
|
|
87
|
+
*/
|
|
88
|
+
export function useSelectedLayoutSegment(parallelRouteKey?: string): string | null {
|
|
89
|
+
void parallelRouteKey;
|
|
90
|
+
const context = useSegmentContext();
|
|
91
|
+
const pathname = usePathname();
|
|
92
|
+
return getSelectedSegment(context?.segments ?? null, pathname);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Returns all active segments below the layout where this hook is called.
|
|
97
|
+
* Returns an empty array if the layout is the leaf (no child segments).
|
|
98
|
+
*
|
|
99
|
+
* Compatible with Next.js's `useSelectedLayoutSegments()` from `next/navigation`.
|
|
100
|
+
*
|
|
101
|
+
* @param parallelRouteKey — Optional parallel route key. Currently unused
|
|
102
|
+
* (parallel route segment tracking is not yet implemented). Accepted for
|
|
103
|
+
* API compatibility with Next.js.
|
|
104
|
+
*/
|
|
105
|
+
export function useSelectedLayoutSegments(parallelRouteKey?: string): string[] {
|
|
106
|
+
void parallelRouteKey;
|
|
107
|
+
const context = useSegmentContext();
|
|
108
|
+
const pathname = usePathname();
|
|
109
|
+
return getSelectedSegments(context?.segments ?? null, pathname);
|
|
110
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @timber/app/content — Public API for content collections.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports from content-collections and provides timber-specific utilities.
|
|
5
|
+
* Users can import directly from 'content-collections' for generated types,
|
|
6
|
+
* or use this module for the re-exports.
|
|
7
|
+
*
|
|
8
|
+
* Design doc: 20-content-collections.md §"Querying Collections"
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Re-export defineCollection and defineConfig for convenience.
|
|
12
|
+
// Users can also import these directly from @content-collections/core.
|
|
13
|
+
export { defineCollection, defineConfig } from '@content-collections/core';
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* defineCookie — typed cookie definitions.
|
|
3
|
+
*
|
|
4
|
+
* Bundles name + codec + options into a reusable CookieDefinition<T>
|
|
5
|
+
* with .get(), .set(), .delete() server methods and a .useCookie() client hook.
|
|
6
|
+
*
|
|
7
|
+
* Reuses the SearchParamCodec protocol via fromSchema() bridge.
|
|
8
|
+
* Validation on read returns the codec default (never throws).
|
|
9
|
+
*
|
|
10
|
+
* See design/29-cookies.md §"Typed Cookies with Schema Validation"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { cookies } from '#/server/request-context.js';
|
|
14
|
+
import type { CookieOptions } from '#/server/request-context.js';
|
|
15
|
+
import { useCookie as useRawCookie } from '#/client/use-cookie.js';
|
|
16
|
+
import type { ClientCookieOptions } from '#/client/use-cookie.js';
|
|
17
|
+
|
|
18
|
+
// ─── Types ────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A codec that converts between string cookie values and typed values.
|
|
22
|
+
* Intentionally identical to SearchParamCodec<T>.
|
|
23
|
+
*/
|
|
24
|
+
export interface CookieCodec<T> {
|
|
25
|
+
parse(value: string | string[] | undefined): T;
|
|
26
|
+
serialize(value: T): string | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options for defineCookie: codec + CookieOptions merged. */
|
|
30
|
+
export interface DefineCookieOptions<T> extends Omit<CookieOptions, 'signed'> {
|
|
31
|
+
/** Codec for parsing/serializing the cookie value. */
|
|
32
|
+
codec: CookieCodec<T>;
|
|
33
|
+
/** Sign the cookie with HMAC-SHA256. */
|
|
34
|
+
signed?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A fully typed cookie definition with server and client methods. */
|
|
38
|
+
export interface CookieDefinition<T> {
|
|
39
|
+
/** The cookie name. */
|
|
40
|
+
readonly name: string;
|
|
41
|
+
/** The resolved cookie options (without codec). */
|
|
42
|
+
readonly options: CookieOptions;
|
|
43
|
+
/** The codec used for parsing/serializing. */
|
|
44
|
+
readonly codec: CookieCodec<T>;
|
|
45
|
+
|
|
46
|
+
/** Server: read the typed value from the current request. */
|
|
47
|
+
get(): T;
|
|
48
|
+
/** Server: set the typed value on the response. */
|
|
49
|
+
set(value: T): void;
|
|
50
|
+
/** Server: delete the cookie. */
|
|
51
|
+
delete(): void;
|
|
52
|
+
|
|
53
|
+
/** Client: React hook for reading/writing this cookie. Returns [value, setter, deleter]. */
|
|
54
|
+
useCookie(): [T, (value: T) => void, () => void];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Factory ──────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Define a typed cookie.
|
|
61
|
+
*
|
|
62
|
+
* ```ts
|
|
63
|
+
* import { defineCookie } from '@timber/app/cookies';
|
|
64
|
+
* import { fromSchema } from '@timber/app/search-params';
|
|
65
|
+
* import { z } from 'zod/v4';
|
|
66
|
+
*
|
|
67
|
+
* export const themeCookie = defineCookie('theme', {
|
|
68
|
+
* codec: fromSchema(z.enum(['light', 'dark', 'system']).default('system')),
|
|
69
|
+
* httpOnly: false,
|
|
70
|
+
* maxAge: 60 * 60 * 24 * 365,
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
export function defineCookie<T>(
|
|
75
|
+
name: string,
|
|
76
|
+
options: DefineCookieOptions<T>
|
|
77
|
+
): CookieDefinition<T> {
|
|
78
|
+
const { codec, ...cookieOpts } = options;
|
|
79
|
+
const resolvedOptions: CookieOptions = { ...cookieOpts };
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
name,
|
|
83
|
+
options: resolvedOptions,
|
|
84
|
+
codec,
|
|
85
|
+
|
|
86
|
+
get(): T {
|
|
87
|
+
const jar = cookies();
|
|
88
|
+
const raw = resolvedOptions.signed ? jar.getSigned(name) : jar.get(name);
|
|
89
|
+
return codec.parse(raw);
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
set(value: T): void {
|
|
93
|
+
const serialized = codec.serialize(value);
|
|
94
|
+
if (serialized === null) {
|
|
95
|
+
cookies().delete(name, {
|
|
96
|
+
path: resolvedOptions.path,
|
|
97
|
+
domain: resolvedOptions.domain,
|
|
98
|
+
});
|
|
99
|
+
} else {
|
|
100
|
+
cookies().set(name, serialized, resolvedOptions);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
delete(): void {
|
|
105
|
+
cookies().delete(name, {
|
|
106
|
+
path: resolvedOptions.path,
|
|
107
|
+
domain: resolvedOptions.domain,
|
|
108
|
+
});
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
useCookie(): [T, (value: T) => void, () => void] {
|
|
112
|
+
// Extract client-safe options (no httpOnly — client cookies can't be httpOnly)
|
|
113
|
+
const clientOpts: ClientCookieOptions = {
|
|
114
|
+
path: resolvedOptions.path,
|
|
115
|
+
domain: resolvedOptions.domain,
|
|
116
|
+
maxAge: resolvedOptions.maxAge,
|
|
117
|
+
expires: resolvedOptions.expires,
|
|
118
|
+
sameSite: resolvedOptions.sameSite,
|
|
119
|
+
secure: resolvedOptions.secure,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const [raw, setRaw, deleteRaw] = useRawCookie(name, clientOpts);
|
|
123
|
+
const parsed = codec.parse(raw);
|
|
124
|
+
|
|
125
|
+
const setTyped = (value: T): void => {
|
|
126
|
+
const serialized = codec.serialize(value);
|
|
127
|
+
if (serialized === null) {
|
|
128
|
+
deleteRaw();
|
|
129
|
+
} else {
|
|
130
|
+
setRaw(serialized);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return [parsed, setTyped, deleteRaw];
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// @timber/app/cookies — Typed cookie definitions
|
|
2
|
+
// See design/29-cookies.md §"Typed Cookies with Schema Validation"
|
|
3
|
+
|
|
4
|
+
export { defineCookie } from './define-cookie.js';
|
|
5
|
+
export type {
|
|
6
|
+
CookieDefinition,
|
|
7
|
+
CookieCodec,
|
|
8
|
+
DefineCookieOptions,
|
|
9
|
+
} from './define-cookie.js';
|