toiljs 0.0.7 → 0.0.9
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/build/backend/.tsbuildinfo +1 -1
- package/build/cli/.tsbuildinfo +1 -1
- package/build/cli/configure.d.ts +1 -0
- package/build/cli/configure.js +85 -20
- package/build/cli/create.d.ts +1 -0
- package/build/cli/create.js +18 -7
- package/build/cli/features.d.ts +2 -0
- package/build/cli/features.js +22 -0
- package/build/cli/index.js +8 -0
- package/build/client/.tsbuildinfo +1 -1
- package/build/client/components/Form.d.ts +12 -0
- package/build/client/components/Form.js +23 -0
- package/build/client/components/Image.d.ts +13 -0
- package/build/client/components/Image.js +22 -0
- package/build/client/components/Script.d.ts +13 -0
- package/build/client/components/Script.js +68 -0
- package/build/client/components/Slot.d.ts +6 -0
- package/build/client/components/Slot.js +6 -0
- package/build/client/dev/error-overlay.d.ts +20 -0
- package/build/client/dev/error-overlay.js +123 -0
- package/build/client/head/head.d.ts +2 -0
- package/build/client/head/head.js +17 -2
- package/build/client/head/metadata.d.ts +29 -0
- package/build/client/head/metadata.js +38 -0
- package/build/client/index.d.ts +15 -3
- package/build/client/index.js +8 -2
- package/build/client/navigation/navigation.d.ts +3 -0
- package/build/client/navigation/navigation.js +42 -1
- package/build/client/routing/Router.d.ts +1 -0
- package/build/client/routing/Router.js +56 -34
- package/build/client/routing/action.d.ts +17 -0
- package/build/client/routing/action.js +55 -0
- package/build/client/routing/hooks.d.ts +1 -0
- package/build/client/routing/hooks.js +6 -7
- package/build/client/routing/loader.d.ts +10 -2
- package/build/client/routing/loader.js +83 -24
- package/build/client/routing/mount.d.ts +1 -1
- package/build/client/routing/mount.js +12 -4
- package/build/client/routing/slot-context.d.ts +2 -0
- package/build/client/routing/slot-context.js +2 -0
- package/build/client/types.d.ts +1 -0
- package/build/compiler/.tsbuildinfo +1 -1
- package/build/compiler/config.d.ts +10 -0
- package/build/compiler/config.js +5 -1
- package/build/compiler/docs.js +26 -26
- package/build/compiler/fonts.d.ts +4 -0
- package/build/compiler/fonts.js +64 -0
- package/build/compiler/generate.js +67 -32
- package/build/compiler/image-report.d.ts +2 -0
- package/build/compiler/image-report.js +62 -0
- package/build/compiler/plugin.js +1 -1
- package/build/compiler/prerender.d.ts +7 -0
- package/build/compiler/prerender.js +111 -0
- package/build/compiler/routes.d.ts +3 -0
- package/build/compiler/routes.js +50 -5
- package/build/compiler/seo.d.ts +70 -0
- package/build/compiler/seo.js +221 -0
- package/build/compiler/vite.js +13 -1
- package/build/io/.tsbuildinfo +1 -1
- package/build/shared/.tsbuildinfo +1 -1
- package/examples/basic/client/404.tsx +1 -1
- package/examples/basic/client/components/Header.tsx +38 -0
- package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
- package/examples/basic/client/global-error.tsx +3 -3
- package/examples/basic/client/layout.tsx +2 -33
- package/examples/basic/client/public/images/test_image.webp +0 -0
- package/examples/basic/client/routes/about.tsx +8 -0
- package/examples/basic/client/routes/get-started.tsx +1 -1
- package/examples/basic/client/routes/index.tsx +8 -1
- package/examples/basic/client/routes/io.tsx +1 -1
- package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
- package/examples/basic/client/routes/test.tsx +8 -0
- package/examples/basic/client/styles/main.css +48 -1
- package/package.json +8 -6
- package/presets/eslint.js +7 -4
- package/presets/tsconfig.json +1 -1
- package/src/backend/index.ts +1 -1
- package/src/cli/configure.ts +102 -21
- package/src/cli/create.ts +25 -9
- package/src/cli/features.ts +33 -1
- package/src/cli/index.ts +10 -1
- package/src/cli/ui.ts +1 -1
- package/src/cli/validate.ts +1 -1
- package/src/client/components/Form.tsx +65 -0
- package/src/client/components/Image.tsx +89 -0
- package/src/client/components/Script.tsx +113 -0
- package/src/client/components/Slot.tsx +21 -0
- package/src/client/dev/error-overlay.tsx +197 -0
- package/src/client/head/head.ts +28 -3
- package/src/client/head/metadata.ts +92 -0
- package/src/client/index.ts +20 -3
- package/src/client/navigation/Link.tsx +1 -1
- package/src/client/navigation/navigation.ts +74 -4
- package/src/client/navigation/prefetch.ts +2 -2
- package/src/client/routing/Router.tsx +128 -62
- package/src/client/routing/action.ts +122 -0
- package/src/client/routing/error-boundary.tsx +1 -1
- package/src/client/routing/hooks.ts +17 -23
- package/src/client/routing/loader.ts +158 -35
- package/src/client/routing/mount.tsx +25 -3
- package/src/client/routing/slot-context.ts +7 -0
- package/src/client/types.ts +6 -4
- package/src/compiler/config.ts +40 -3
- package/src/compiler/docs.ts +26 -26
- package/src/compiler/fonts.ts +87 -0
- package/src/compiler/generate.ts +69 -31
- package/src/compiler/image-report.ts +85 -0
- package/src/compiler/plugin.ts +2 -2
- package/src/compiler/prerender.ts +130 -0
- package/src/compiler/routes.ts +62 -7
- package/src/compiler/seo.ts +356 -0
- package/src/compiler/vite.ts +21 -4
- package/src/io/FastSet.ts +1 -1
- package/src/io/index.ts +1 -1
- package/src/io/types.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/main.ts +1 -1
- package/src/shared/index.ts +1 -1
- package/test/dom/Image.test.tsx +46 -0
- package/test/dom/Script.test.tsx +45 -0
- package/test/dom/action.test.tsx +129 -0
- package/test/dom/error-overlay.test.tsx +44 -0
- package/test/dom/loader.test.tsx +121 -0
- package/test/dom/revalidate.test.tsx +38 -0
- package/test/dom/route-head.test.tsx +34 -0
- package/test/dom/router-loading.test.tsx +44 -0
- package/test/dom/slot.test.tsx +109 -0
- package/test/dom/view-transitions.test.tsx +51 -0
- package/test/features.test.ts +31 -0
- package/test/fonts.test.ts +26 -0
- package/test/metadata.test.ts +41 -0
- package/test/prerender.test.ts +46 -0
- package/test/routes.test.ts +20 -1
- package/test/seo.test.ts +142 -0
- package/examples/basic/client/template.tsx +0 -7
|
@@ -2,12 +2,19 @@
|
|
|
2
2
|
* Route data loaders. A route file may export a `loader` alongside its default component; the
|
|
3
3
|
* loader runs on navigation, in parallel with the route's chunk load, and the page suspends until
|
|
4
4
|
* it resolves (so `loading.tsx` shows and `useNavigationPending` is true). The page reads the
|
|
5
|
-
* result with `useLoaderData()`.
|
|
6
|
-
*
|
|
5
|
+
* result with `useLoaderData<typeof loader>()`.
|
|
6
|
+
*
|
|
7
|
+
* Caching: results are cached by URL (`pathname + search`). Re-renders reuse the cached result (the
|
|
8
|
+
* loader does not re-run). Across navigations, the cache policy comes from the route's optional
|
|
9
|
+
* `export const revalidate` ({@link Revalidate}): by default the loader re-runs on every navigation;
|
|
10
|
+
* a number keeps data fresh for that many seconds; `false` caches until manual invalidation.
|
|
11
|
+
* `revalidate()` / `router.refresh()` bust the cache to force a refetch.
|
|
7
12
|
*/
|
|
8
13
|
import { createContext, useContext, type ComponentType } from 'react';
|
|
9
14
|
|
|
10
|
-
import {
|
|
15
|
+
import type { HeadSpec } from '../head/head.js';
|
|
16
|
+
import { resolveMetadata, type GenerateMetadata, type Metadata } from '../head/metadata.js';
|
|
17
|
+
import { refresh as rerender } from '../navigation/navigation.js';
|
|
11
18
|
import type { RouteDef } from '../types.js';
|
|
12
19
|
import type { RouteParams } from './match.js';
|
|
13
20
|
|
|
@@ -20,72 +27,140 @@ export interface LoaderArgs {
|
|
|
20
27
|
/** A route `loader`: `export const loader = ({ params }) => …` (sync or async). */
|
|
21
28
|
export type LoaderFunction<T = unknown> = (args: LoaderArgs) => T | Promise<T>;
|
|
22
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Per-route cache policy, set with `export const revalidate` in a route file:
|
|
32
|
+
* - `0` (default): re-run the loader on every navigation to the route.
|
|
33
|
+
* - a positive number: reuse cached data across navigations for that many **seconds**, then refetch.
|
|
34
|
+
* - `false`: cache indefinitely until manually invalidated (`revalidate()` / `router.refresh()`).
|
|
35
|
+
*/
|
|
36
|
+
export type Revalidate = number | false;
|
|
37
|
+
|
|
38
|
+
/** Resolves the data type for {@link useLoaderData}: `typeof loader` → its (awaited) return, else `T`. */
|
|
39
|
+
export type LoaderData<T> = T extends (...args: never[]) => infer R ? Awaited<R> : T;
|
|
40
|
+
|
|
23
41
|
interface RouteModule {
|
|
24
42
|
default: ComponentType;
|
|
25
43
|
loader?: LoaderFunction;
|
|
44
|
+
revalidate?: Revalidate;
|
|
45
|
+
metadata?: Metadata;
|
|
46
|
+
generateMetadata?: GenerateMetadata;
|
|
26
47
|
}
|
|
27
48
|
interface RouteData {
|
|
28
49
|
Component: ComponentType;
|
|
29
50
|
data: unknown;
|
|
51
|
+
/** Resolved baseline head from the route's `metadata` / `generateMetadata`, if any. */
|
|
52
|
+
head?: HeadSpec;
|
|
30
53
|
}
|
|
31
54
|
interface Entry {
|
|
32
55
|
status: 'pending' | 'done' | 'error';
|
|
33
56
|
promise: Promise<void>;
|
|
34
57
|
value?: RouteData;
|
|
35
58
|
error?: unknown;
|
|
59
|
+
/** `Date.now()` when the fetch settled (`0` while pending). */
|
|
60
|
+
loadedAt: number;
|
|
61
|
+
/** Cache policy captured from the route module once loaded. */
|
|
62
|
+
revalidate: Revalidate;
|
|
63
|
+
/** Navigation epoch at which this entry was (re)fetched. */
|
|
64
|
+
epoch: number;
|
|
65
|
+
/** Whether the route exports a `loader`, a route without one has no data that can change. */
|
|
66
|
+
hasLoader: boolean;
|
|
36
67
|
}
|
|
37
68
|
|
|
38
69
|
const cache = new Map<string, Entry>();
|
|
39
|
-
const MAX_ENTRIES =
|
|
70
|
+
const MAX_ENTRIES = 32;
|
|
71
|
+
|
|
72
|
+
/** Cache key for a URL: path + query (hash is ignored, it never changes loader data). */
|
|
73
|
+
export function loaderKey(pathname: string, search: string): string {
|
|
74
|
+
return `${pathname}${search}`;
|
|
75
|
+
}
|
|
40
76
|
|
|
41
77
|
/** Loads the route module and runs its loader (if any), in parallel where possible. */
|
|
42
|
-
async function loadRoute(
|
|
78
|
+
async function loadRoute(
|
|
79
|
+
route: RouteDef,
|
|
80
|
+
params: RouteParams,
|
|
81
|
+
): Promise<{ data: RouteData; revalidate: Revalidate; hasLoader: boolean }> {
|
|
43
82
|
const mod: RouteModule = await route.load();
|
|
44
83
|
const searchParams = new URLSearchParams(
|
|
45
84
|
typeof window === 'undefined' ? '' : window.location.search,
|
|
46
85
|
);
|
|
47
86
|
const data = mod.loader ? await mod.loader({ params, searchParams }) : undefined;
|
|
48
|
-
|
|
87
|
+
let head: HeadSpec | undefined;
|
|
88
|
+
if (mod.generateMetadata) {
|
|
89
|
+
head = resolveMetadata(await mod.generateMetadata({ params, searchParams, data }));
|
|
90
|
+
} else if (mod.metadata) {
|
|
91
|
+
head = resolveMetadata(mod.metadata);
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
data: { Component: mod.default, data, head },
|
|
95
|
+
revalidate: mod.revalidate ?? 0,
|
|
96
|
+
hasLoader: mod.loader != null,
|
|
97
|
+
};
|
|
49
98
|
}
|
|
50
99
|
|
|
51
|
-
/**
|
|
52
|
-
function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
100
|
+
/** Whether a settled entry must be refetched for the current navigation. */
|
|
101
|
+
function isStale(entry: Entry, epoch: number): boolean {
|
|
102
|
+
if (entry.status === 'error') return true; // always retry a failed load
|
|
103
|
+
// A route with no loader has no data that can change, keep it cached so repeat navigations
|
|
104
|
+
// render synchronously (instant) instead of re-suspending and remounting on every switch.
|
|
105
|
+
if (!entry.hasLoader) return false;
|
|
106
|
+
if (entry.revalidate === false) return false; // cache forever
|
|
107
|
+
if (entry.revalidate === 0) return entry.epoch !== epoch; // refetch once per navigation
|
|
108
|
+
return Date.now() - entry.loadedAt >= entry.revalidate * 1000; // time-based staleness
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Starts (and caches) a fresh fetch for `key`. */
|
|
112
|
+
function startFetch(route: RouteDef, params: RouteParams, key: string, epoch: number): Entry {
|
|
113
|
+
const created: Entry = {
|
|
114
|
+
status: 'pending',
|
|
115
|
+
promise: Promise.resolve(),
|
|
116
|
+
loadedAt: 0,
|
|
117
|
+
revalidate: 0,
|
|
118
|
+
epoch,
|
|
119
|
+
hasLoader: false,
|
|
120
|
+
};
|
|
121
|
+
created.promise = loadRoute(route, params).then(
|
|
122
|
+
(result) => {
|
|
123
|
+
created.value = result.data;
|
|
124
|
+
created.revalidate = result.revalidate;
|
|
125
|
+
created.hasLoader = result.hasLoader;
|
|
126
|
+
created.loadedAt = Date.now();
|
|
127
|
+
created.status = 'done';
|
|
128
|
+
},
|
|
129
|
+
(error: unknown) => {
|
|
130
|
+
created.error = error;
|
|
131
|
+
created.loadedAt = Date.now();
|
|
132
|
+
created.status = 'error';
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
cache.set(key, created);
|
|
57
136
|
while (cache.size > MAX_ENTRIES) {
|
|
58
137
|
const oldest = cache.keys().next().value;
|
|
59
|
-
if (oldest === undefined) break;
|
|
138
|
+
if (oldest === undefined || oldest === key) break;
|
|
60
139
|
cache.delete(oldest);
|
|
61
140
|
}
|
|
141
|
+
return created;
|
|
62
142
|
}
|
|
63
143
|
|
|
64
144
|
/**
|
|
65
|
-
* Reads (or starts) the route's module + loader data for `key`, suspending until ready.
|
|
66
|
-
* loader's error so the route's `error.tsx` boundary can catch it.
|
|
145
|
+
* Reads (or starts) the route's module + loader data for the URL `key`, suspending until ready.
|
|
146
|
+
* Throws the loader's error so the route's `error.tsx` boundary can catch it. `epoch` is the current
|
|
147
|
+
* navigation epoch, used to refetch once-per-navigation under the default cache policy.
|
|
67
148
|
*/
|
|
68
|
-
export function readRouteData(
|
|
149
|
+
export function readRouteData(
|
|
150
|
+
route: RouteDef,
|
|
151
|
+
params: RouteParams,
|
|
152
|
+
key: string,
|
|
153
|
+
epoch: number,
|
|
154
|
+
): RouteData {
|
|
69
155
|
let entry = cache.get(key);
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
created.promise = loadRoute(route, params).then(
|
|
73
|
-
(value) => {
|
|
74
|
-
created.value = value;
|
|
75
|
-
created.status = 'done';
|
|
76
|
-
},
|
|
77
|
-
(error: unknown) => {
|
|
78
|
-
created.error = error;
|
|
79
|
-
created.status = 'error';
|
|
80
|
-
},
|
|
81
|
-
);
|
|
82
|
-
cache.set(key, created);
|
|
83
|
-
prune();
|
|
84
|
-
entry = created;
|
|
156
|
+
if (entry && entry.status !== 'pending' && isStale(entry, epoch)) {
|
|
157
|
+
entry = undefined; // stale → drop and refetch below
|
|
85
158
|
}
|
|
159
|
+
entry ??= startFetch(route, params, key, epoch);
|
|
86
160
|
if (entry.status === 'pending') throw entry.promise;
|
|
87
161
|
if (entry.status === 'error') throw entry.error;
|
|
88
|
-
|
|
162
|
+
if (!entry.value) throw entry.promise;
|
|
163
|
+
return entry.value;
|
|
89
164
|
}
|
|
90
165
|
|
|
91
166
|
/** Clears all cached loader data, so the next render re-runs loaders (used by router.refresh). */
|
|
@@ -93,10 +168,58 @@ export function clearLoaderData(): void {
|
|
|
93
168
|
cache.clear();
|
|
94
169
|
}
|
|
95
170
|
|
|
171
|
+
/** Cache key for an href (relative or absolute), matching {@link loaderKey}. */
|
|
172
|
+
function keyForHref(href: string): string | undefined {
|
|
173
|
+
if (typeof window === 'undefined') return undefined;
|
|
174
|
+
try {
|
|
175
|
+
const url = new URL(href, window.location.href);
|
|
176
|
+
return loaderKey(url.pathname, url.search);
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Invalidates cached loader data so it refetches on the next render. With no argument, clears every
|
|
184
|
+
* route; with an `href`, clears just that route's entry. Pair with a re-render (the active route
|
|
185
|
+
* refetches and suspends), see {@link revalidate}.
|
|
186
|
+
*/
|
|
187
|
+
export function invalidateLoaderData(href?: string): void {
|
|
188
|
+
if (href === undefined) {
|
|
189
|
+
cache.clear();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const key = keyForHref(href);
|
|
193
|
+
if (key !== undefined) cache.delete(key);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Invalidates loader data and re-renders so the active route refetches. Call after a mutation to
|
|
198
|
+
* refresh the current route (`revalidate()`), or target another route by href
|
|
199
|
+
* (`revalidate('/posts')`). Usable outside React (e.g. in an event handler after a `fetch`).
|
|
200
|
+
*/
|
|
201
|
+
export function revalidate(href?: string): void {
|
|
202
|
+
invalidateLoaderData(href);
|
|
203
|
+
rerender();
|
|
204
|
+
}
|
|
205
|
+
|
|
96
206
|
/** Holds the active route's loader data; provided by the Router, read by {@link useLoaderData}. */
|
|
97
207
|
export const LoaderDataContext = createContext<unknown>(undefined);
|
|
98
208
|
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
209
|
+
/**
|
|
210
|
+
* The data returned by the active route's `loader`. Three ways to type it, easiest first:
|
|
211
|
+
*
|
|
212
|
+
* 1. **Pass the loader**, zero generics, fully inferred from your loader's return:
|
|
213
|
+
* `const data = useLoaderData(loader);`
|
|
214
|
+
* 2. Pass `typeof loader` as a type argument: `useLoaderData<typeof loader>();`
|
|
215
|
+
* 3. Pass an explicit shape: `useLoaderData<Post>();`
|
|
216
|
+
*
|
|
217
|
+
* With no argument and no type, it returns `unknown` (never `any`), so the data is there at runtime,
|
|
218
|
+
* but you must annotate or narrow before using it. There's no way to infer the type from a bare call:
|
|
219
|
+
* TypeScript can't tell which file (and so which `loader`) the call belongs to, hence option 1.
|
|
220
|
+
*/
|
|
221
|
+
export function useLoaderData<L extends LoaderFunction>(loader: L): Awaited<ReturnType<L>>;
|
|
222
|
+
export function useLoaderData<T = unknown>(): LoaderData<T>;
|
|
223
|
+
export function useLoaderData(_loader?: LoaderFunction): unknown {
|
|
224
|
+
return useContext(LoaderDataContext);
|
|
102
225
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { createRoot } from 'react-dom/client';
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
DevErrorBoundary,
|
|
5
|
+
DevErrorOverlay,
|
|
6
|
+
initDevErrorOverlay,
|
|
7
|
+
isDevMode,
|
|
8
|
+
} from '../dev/error-overlay.js';
|
|
3
9
|
import { initNavigation } from '../navigation/navigation.js';
|
|
4
10
|
import { startPrefetcher } from '../navigation/prefetch.js';
|
|
5
11
|
import { Router } from './Router.js';
|
|
@@ -14,17 +20,33 @@ export function mount(
|
|
|
14
20
|
layout: LayoutLoader = null,
|
|
15
21
|
notFound: NotFoundLoader = null,
|
|
16
22
|
globalError: ErrorComponentLoader = null,
|
|
23
|
+
slots: Record<string, RouteDef[]> = {},
|
|
17
24
|
): void {
|
|
18
25
|
const el = document.getElementById('root');
|
|
19
26
|
if (!el) throw new Error('toil: #root element not found');
|
|
20
27
|
initNavigation();
|
|
21
|
-
|
|
28
|
+
const app = (
|
|
22
29
|
<Router
|
|
23
30
|
routes={routes}
|
|
24
31
|
layout={layout}
|
|
25
32
|
notFound={notFound}
|
|
26
33
|
globalError={globalError}
|
|
27
|
-
|
|
34
|
+
slots={slots}
|
|
35
|
+
/>
|
|
28
36
|
);
|
|
29
|
-
|
|
37
|
+
// In dev, wrap the app in the error overlay so uncaught render/async errors surface on screen
|
|
38
|
+
// (not a blank page). In production it's omitted entirely.
|
|
39
|
+
if (isDevMode()) {
|
|
40
|
+
initDevErrorOverlay();
|
|
41
|
+
createRoot(el).render(
|
|
42
|
+
<>
|
|
43
|
+
<DevErrorBoundary>{app}</DevErrorBoundary>
|
|
44
|
+
<DevErrorOverlay />
|
|
45
|
+
</>,
|
|
46
|
+
);
|
|
47
|
+
} else {
|
|
48
|
+
createRoot(el).render(app);
|
|
49
|
+
}
|
|
50
|
+
// Prefetch across the main tree and every slot tree (one prefetcher owns the whole table).
|
|
51
|
+
startPrefetcher([...routes, ...Object.values(slots).flat()]);
|
|
30
52
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context carrying the rendered element for each parallel-route slot (`@slot`), keyed by name.
|
|
3
|
+
* Provided by the Router for the current URL, consumed by {@link Slot}.
|
|
4
|
+
*/
|
|
5
|
+
import { createContext, type ReactNode } from 'react';
|
|
6
|
+
|
|
7
|
+
export const SlotContext = createContext<Record<string, ReactNode>>({});
|
package/src/client/types.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { ComponentType, ReactNode } from 'react';
|
|
|
12
12
|
export interface Register {}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* Union of the project's route paths
|
|
15
|
+
* Union of the project's route paths, static routes as literals, dynamic/catch-all as
|
|
16
16
|
* `` `…/${string}` `` templates. Falls back to `string` before the types are generated.
|
|
17
17
|
*/
|
|
18
18
|
export type RoutePath = Register extends { routePath: infer P }
|
|
@@ -56,12 +56,14 @@ export interface RouteDef {
|
|
|
56
56
|
readonly pattern: string;
|
|
57
57
|
readonly load: () => Promise<{ default: ComponentType }>;
|
|
58
58
|
readonly layouts?: readonly LayoutComponentLoader[];
|
|
59
|
-
/** `template.tsx` chain (root → nested)
|
|
59
|
+
/** `template.tsx` chain (root → nested), like layouts, but re-mounted on each navigation. */
|
|
60
60
|
readonly templates?: readonly LayoutComponentLoader[];
|
|
61
|
-
/** Nearest `loading.tsx
|
|
61
|
+
/** Nearest `loading.tsx`, shown as the Suspense fallback while this route loads. */
|
|
62
62
|
readonly loading?: () => Promise<{ default: ComponentType }>;
|
|
63
|
-
/** Nearest `error.tsx
|
|
63
|
+
/** Nearest `error.tsx`, rendered by an error boundary around this route. */
|
|
64
64
|
readonly errorComponent?: () => Promise<{ default: ComponentType<RouteErrorProps> }>;
|
|
65
|
+
/** Intercepting route (`(.)`/`(..)`/`(...)`), matched in its slot only on soft navigation. */
|
|
66
|
+
readonly intercept?: boolean;
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** Optional root layout loader (wraps every page). `null` when the project defines no layout. */
|
package/src/compiler/config.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
4
4
|
|
|
5
5
|
import { type InlineConfig } from 'vite';
|
|
6
6
|
|
|
7
|
+
import { type SeoConfig } from './seo.js';
|
|
8
|
+
|
|
9
|
+
export type { SeoConfig } from './seo.js';
|
|
10
|
+
|
|
7
11
|
/**
|
|
8
12
|
* Client-side (TSX/React/Vite) configuration. All fields optional; sensible defaults applied.
|
|
9
13
|
*/
|
|
@@ -24,9 +28,31 @@ export interface ClientConfig {
|
|
|
24
28
|
readonly base?: string;
|
|
25
29
|
/** Dev server port. Default `3000`. */
|
|
26
30
|
readonly port?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Optimize imported images at build time (resize/convert via `vite-imagetools` + sharp): an
|
|
33
|
+
* import like `logo.png?w=400;800&format=webp&as=srcset` emits resized, compressed variants.
|
|
34
|
+
* Default `true`. Set `false` to disable the pipeline (images are then served as-is).
|
|
35
|
+
*/
|
|
36
|
+
readonly images?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Preload bundled fonts at build time: injects `<link rel="preload" as="font">` for each
|
|
39
|
+
* `@font-face` font so it loads in parallel with the CSS (faster text paint). Default `true`.
|
|
40
|
+
*/
|
|
41
|
+
readonly fonts?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Animate cross-page navigations with the browser View Transitions API (a crossfade by default;
|
|
44
|
+
* add `view-transition-name` in CSS for shared-element transitions). Respects
|
|
45
|
+
* `prefers-reduced-motion`. Default `false`.
|
|
46
|
+
*/
|
|
47
|
+
readonly viewTransitions?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Build-time SEO: bakes site-level metadata into the HTML `<head>` (so JS-less crawlers and AI
|
|
50
|
+
* bots see real tags) and generates `robots.txt`, `sitemap.xml`, and `llms.txt`. Omit to skip.
|
|
51
|
+
*/
|
|
52
|
+
readonly seo?: SeoConfig;
|
|
27
53
|
/**
|
|
28
54
|
* Raw Vite escape hatch, deep-merged over the framework's opinionated config.
|
|
29
|
-
* This is NOT the client config itself
|
|
55
|
+
* This is NOT the client config itself, toil owns the Vite setup; use this only
|
|
30
56
|
* to override specific Vite options.
|
|
31
57
|
*/
|
|
32
58
|
readonly vite?: InlineConfig;
|
|
@@ -68,6 +94,14 @@ export interface ResolvedToilConfig {
|
|
|
68
94
|
readonly outDir: string;
|
|
69
95
|
readonly base: string;
|
|
70
96
|
readonly port: number;
|
|
97
|
+
/** Whether build-time image optimization (`vite-imagetools`) is enabled. */
|
|
98
|
+
readonly images: boolean;
|
|
99
|
+
/** Whether build-time font preloading is enabled. */
|
|
100
|
+
readonly fonts: boolean;
|
|
101
|
+
/** Whether animated View Transitions are enabled for navigation. */
|
|
102
|
+
readonly viewTransitions: boolean;
|
|
103
|
+
/** Build-time SEO config, or `null` when not configured. */
|
|
104
|
+
readonly seo: SeoConfig | null;
|
|
71
105
|
/** Absolute path to the framework client runtime (`toiljs/client`). */
|
|
72
106
|
readonly runtimePath: string;
|
|
73
107
|
readonly vite: InlineConfig;
|
|
@@ -104,8 +138,7 @@ export async function loadConfig(
|
|
|
104
138
|
for (const name of CONFIG_NAMES) {
|
|
105
139
|
const candidate = path.join(root, name);
|
|
106
140
|
if (fs.existsSync(candidate)) {
|
|
107
|
-
|
|
108
|
-
const loaded: { default?: ToilConfig } = await import(pathToFileURL(candidate).href);
|
|
141
|
+
const loaded = (await import(pathToFileURL(candidate).href)) as { default?: ToilConfig };
|
|
109
142
|
if (loaded.default) user = loaded.default;
|
|
110
143
|
break;
|
|
111
144
|
}
|
|
@@ -128,6 +161,10 @@ export async function loadConfig(
|
|
|
128
161
|
outDir: client.outDir ?? 'build/client',
|
|
129
162
|
base: client.base ?? '/',
|
|
130
163
|
port: opts.port ?? client.port ?? 3000,
|
|
164
|
+
images: client.images ?? true,
|
|
165
|
+
fonts: client.fonts ?? true,
|
|
166
|
+
viewTransitions: client.viewTransitions ?? false,
|
|
167
|
+
seo: client.seo ?? null,
|
|
131
168
|
runtimePath: resolveRuntimePath(),
|
|
132
169
|
vite: client.vite ?? {},
|
|
133
170
|
};
|
package/src/compiler/docs.ts
CHANGED
|
@@ -10,18 +10,18 @@ import path from 'node:path';
|
|
|
10
10
|
/** Shared body for the per-tool pointer files. */
|
|
11
11
|
const POINTER_BODY = `# toiljs · AI assistant guide
|
|
12
12
|
|
|
13
|
-
This is a **toiljs** project
|
|
13
|
+
This is a **toiljs** project, a full-stack React framework (React + Vite client, file-based
|
|
14
14
|
routing, and a toilscript→WebAssembly server).
|
|
15
15
|
|
|
16
16
|
**Before editing this project, read the generated documentation in \`.toil/docs/\`.** It describes
|
|
17
17
|
the conventions you must follow:
|
|
18
18
|
|
|
19
|
-
- \`.toil/docs/index.md
|
|
20
|
-
- \`.toil/docs/routing.md
|
|
21
|
-
- \`.toil/docs/client.md
|
|
22
|
-
- \`.toil/docs/styling.md
|
|
23
|
-
- \`.toil/docs/server.md
|
|
24
|
-
- \`.toil/docs/cli.md
|
|
19
|
+
- \`.toil/docs/index.md\`, overview and project layout
|
|
20
|
+
- \`.toil/docs/routing.md\`, file-based routing, nested layouts, loading / error files
|
|
21
|
+
- \`.toil/docs/client.md\`, the \`Toil\` global, Link / NavLink, router hooks
|
|
22
|
+
- \`.toil/docs/styling.md\`, CSS / Sass / Less / Stylus / Tailwind (via \`toiljs configure\`)
|
|
23
|
+
- \`.toil/docs/server.md\`, the toilscript server target
|
|
24
|
+
- \`.toil/docs/cli.md\`, toiljs CLI commands
|
|
25
25
|
|
|
26
26
|
\`.toil/docs/\` is regenerated by toiljs; do not edit it by hand. This pointer file is yours to edit.
|
|
27
27
|
`;
|
|
@@ -82,10 +82,10 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
82
82
|
'',
|
|
83
83
|
'## Project layout',
|
|
84
84
|
'',
|
|
85
|
-
'- `client
|
|
85
|
+
'- `client/`, the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,',
|
|
86
86
|
' `public/`, and `toil.tsx` (the entry that calls `Toil.mount`).',
|
|
87
|
-
'- `server
|
|
88
|
-
'- `toil.config.ts
|
|
87
|
+
'- `server/`, the toilscript → WASM target (`@main` entry), compiled by `toilscript`.',
|
|
88
|
+
'- `toil.config.ts`, configuration via `defineConfig` (`toiljs.config.ts` also works).',
|
|
89
89
|
'- Generated, gitignored, do not edit: `.toil/` (working dir), `toil-env.d.ts` (ambient',
|
|
90
90
|
' globals), `toil-routes.d.ts` (typed routes).',
|
|
91
91
|
'',
|
|
@@ -106,17 +106,17 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
106
106
|
'## Route files',
|
|
107
107
|
'',
|
|
108
108
|
'- `index.tsx` → `/`, `about.tsx` → `/about`, `blog/index.tsx` → `/blog`',
|
|
109
|
-
'- `[id].tsx` → dynamic `/:id
|
|
109
|
+
'- `[id].tsx` → dynamic `/:id`, read with `Toil.useParams<{ id: string }>()`',
|
|
110
110
|
'- `[...slug].tsx` → catch-all (1+ segments); `[[...slug]].tsx` → optional catch-all (0+)',
|
|
111
111
|
'- `(group)/` → route group: groups files / scopes a layout, adds no URL segment',
|
|
112
112
|
'',
|
|
113
113
|
'## Special files',
|
|
114
114
|
'',
|
|
115
|
-
'- `layout.tsx
|
|
115
|
+
'- `layout.tsx`, wraps the routes beneath it. Root `client/layout.tsx` wraps everything;',
|
|
116
116
|
' nested `routes/**/layout.tsx` compose inside it.',
|
|
117
|
-
'- `loading.tsx
|
|
118
|
-
'- `error.tsx
|
|
119
|
-
'- `client/404.tsx
|
|
117
|
+
'- `loading.tsx`, Suspense fallback shown while a route (chunk + loader) loads',
|
|
118
|
+
'- `error.tsx`, error boundary; receives `{ error, reset }` (`Toil.RouteErrorProps`)',
|
|
119
|
+
'- `client/404.tsx`, shown when no route matches',
|
|
120
120
|
'',
|
|
121
121
|
'## Data loaders',
|
|
122
122
|
'',
|
|
@@ -138,12 +138,12 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
138
138
|
'- `Toil.Link` / `Toil.NavLink` (adds an active class) / `Toil.useRouter()`',
|
|
139
139
|
' (push / replace / back / forward / refresh / prefetch)',
|
|
140
140
|
'- `Toil.useParams`, `usePathname`, `useSearchParams`, `useNavigationPending`',
|
|
141
|
-
'- Hrefs are type-checked against your routes
|
|
141
|
+
'- Hrefs are type-checked against your routes, a typo is a compile error.',
|
|
142
142
|
]),
|
|
143
143
|
'client.md': doc([
|
|
144
144
|
'# Client runtime',
|
|
145
145
|
'',
|
|
146
|
-
'Everything is on the `Toil` global
|
|
146
|
+
'Everything is on the `Toil` global, no imports needed in route files.',
|
|
147
147
|
'',
|
|
148
148
|
'## Entry',
|
|
149
149
|
'',
|
|
@@ -159,7 +159,7 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
159
159
|
'- Navigation: `navigate`, `useRouter`, `useNavigate`',
|
|
160
160
|
'- Location: `usePathname`, `useSearchParams`, `useParams`, `useNavigationPending`',
|
|
161
161
|
'- Data: `useLoaderData` (see `routing.md`)',
|
|
162
|
-
'- Head: `useHead`, `useTitle`, `<Head
|
|
162
|
+
'- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route',
|
|
163
163
|
'- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)',
|
|
164
164
|
'- IO globals (no `Toil.` prefix): `BinaryWriter`, `BinaryReader`, `FastMap`, `FastSet`',
|
|
165
165
|
'',
|
|
@@ -198,9 +198,9 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
198
198
|
'',
|
|
199
199
|
'`server/` is the toilscript source, compiled to WebAssembly by `toilscript`.',
|
|
200
200
|
'',
|
|
201
|
-
'- `server/main.ts
|
|
202
|
-
'- `server/index.ts
|
|
203
|
-
'- `server/tsconfig.json
|
|
201
|
+
'- `server/main.ts`, the `@main` entry, exported as the WASM `main`.',
|
|
202
|
+
'- `server/index.ts`, your functions.',
|
|
203
|
+
'- `server/tsconfig.json`, extends `toilscript/std/assembly.json` (AssemblyScript/toilscript',
|
|
204
204
|
' globals like `i32`, not the DOM), so editors resolve server types correctly.',
|
|
205
205
|
'- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm`.',
|
|
206
206
|
'',
|
|
@@ -209,12 +209,12 @@ export const TOIL_DOCS: Record<string, string> = {
|
|
|
209
209
|
'cli.md': doc([
|
|
210
210
|
'# CLI',
|
|
211
211
|
'',
|
|
212
|
-
'- `toiljs create [name]
|
|
212
|
+
'- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,',
|
|
213
213
|
' `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.',
|
|
214
|
-
'- `toiljs dev
|
|
215
|
-
'- `toiljs build
|
|
216
|
-
'- `toiljs start
|
|
217
|
-
'- `toiljs configure
|
|
214
|
+
'- `toiljs dev`, dev server with HMR (`--port`, `--root`).',
|
|
215
|
+
'- `toiljs build`, production build → `build/client` (chain `toilscript` for the server).',
|
|
216
|
+
'- `toiljs start`, self-host the built app (hyper-express) with a `/_toil` WebSocket channel.',
|
|
217
|
+
'- `toiljs configure`, toggle styling features on an existing project (see `styling.md`).',
|
|
218
218
|
]),
|
|
219
219
|
};
|
|
220
220
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { HtmlTagDescriptor, Logger, Plugin } from 'vite';
|
|
2
|
+
|
|
3
|
+
import { type ResolvedToilConfig } from './config.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build-time font optimization. Bundled font files (`@font-face` `url(...)` imports) are emitted +
|
|
7
|
+
* hashed by Vite, but without a hint the browser only discovers them after parsing CSS, delaying
|
|
8
|
+
* text paint. This injects a `<link rel="preload" as="font" crossorigin>` for each bundled font so
|
|
9
|
+
* it loads in parallel with the CSS, and logs what it preloaded (mirrors the image-optimization log).
|
|
10
|
+
*/
|
|
11
|
+
const FONT_RE = /\.(woff2|woff|ttf|otf)$/i;
|
|
12
|
+
const FONT_TYPE: Record<string, string> = {
|
|
13
|
+
woff2: 'font/woff2',
|
|
14
|
+
woff: 'font/woff',
|
|
15
|
+
ttf: 'font/ttf',
|
|
16
|
+
otf: 'font/otf',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function kb(bytes: number): string {
|
|
20
|
+
return `${(bytes / 1000).toFixed(2)} kB`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Builds the `<link rel="preload">` head tags for a set of bundled font file names. */
|
|
24
|
+
export function fontPreloadTags(fileNames: readonly string[], base: string): HtmlTagDescriptor[] {
|
|
25
|
+
const prefix = base.endsWith('/') ? base : `${base}/`;
|
|
26
|
+
return fileNames
|
|
27
|
+
.filter((name) => FONT_RE.test(name))
|
|
28
|
+
.map((name) => {
|
|
29
|
+
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
30
|
+
return {
|
|
31
|
+
tag: 'link',
|
|
32
|
+
attrs: {
|
|
33
|
+
rel: 'preload',
|
|
34
|
+
as: 'font',
|
|
35
|
+
type: FONT_TYPE[ext] ?? `font/${ext}`,
|
|
36
|
+
href: `${prefix}${name}`,
|
|
37
|
+
crossorigin: '',
|
|
38
|
+
},
|
|
39
|
+
injectTo: 'head',
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Build-only plugin that preloads bundled fonts and logs them. Disabled by `client.fonts: false`. */
|
|
45
|
+
export function fontPreloadPlugin(cfg: ResolvedToilConfig): Plugin {
|
|
46
|
+
let logger: Logger | undefined;
|
|
47
|
+
let logged = false;
|
|
48
|
+
return {
|
|
49
|
+
name: 'toil:font-preload',
|
|
50
|
+
apply: 'build',
|
|
51
|
+
configResolved(config) {
|
|
52
|
+
logger = config.logger;
|
|
53
|
+
},
|
|
54
|
+
transformIndexHtml: {
|
|
55
|
+
order: 'post',
|
|
56
|
+
handler(html, ctx) {
|
|
57
|
+
const bundle = ctx.bundle ?? {};
|
|
58
|
+
const fonts = Object.values(bundle).filter(
|
|
59
|
+
(file) => file.type === 'asset' && FONT_RE.test(file.fileName),
|
|
60
|
+
);
|
|
61
|
+
if (fonts.length === 0) return html;
|
|
62
|
+
|
|
63
|
+
// Log once (the same template's HTML is transformed per emitted page).
|
|
64
|
+
if (!logged && logger) {
|
|
65
|
+
logged = true;
|
|
66
|
+
logger.info('');
|
|
67
|
+
logger.info(` ✓ preloaded ${String(fonts.length)} font${fonts.length === 1 ? '' : 's'}`);
|
|
68
|
+
for (const file of fonts) {
|
|
69
|
+
const size =
|
|
70
|
+
file.type === 'asset' && typeof file.source !== 'string'
|
|
71
|
+
? kb(file.source.byteLength)
|
|
72
|
+
: '';
|
|
73
|
+
logger.info(` → ${file.fileName} ${size}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
html,
|
|
79
|
+
tags: fontPreloadTags(
|
|
80
|
+
fonts.map((f) => f.fileName),
|
|
81
|
+
cfg.base,
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|