netjes 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +54 -2
- package/bin/netjes.mjs +13 -0
- package/cli/agents.mjs +66 -0
- package/cli/commands.mjs +467 -0
- package/cli/help.mjs +89 -0
- package/cli/info.mjs +454 -0
- package/cli/main.mjs +167 -0
- package/cli/packages.mjs +19 -0
- package/cli/process-io.mjs +163 -0
- package/cli/routes.mjs +31 -0
- package/cli/scaffold.mjs +193 -0
- package/cli/suggest.mjs +50 -0
- package/cli/terminal.mjs +40 -0
- package/dist/actions.d.ts +98 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +176 -0
- package/dist/actions.js.map +1 -0
- package/dist/client-entry.d.ts +2 -0
- package/dist/client-entry.d.ts.map +1 -0
- package/dist/client-entry.js +126 -0
- package/dist/client-entry.js.map +1 -0
- package/dist/deferred.d.ts +39 -0
- package/dist/deferred.d.ts.map +1 -0
- package/dist/deferred.js +123 -0
- package/dist/deferred.js.map +1 -0
- package/dist/define-page.d.ts +15 -0
- package/dist/define-page.d.ts.map +1 -0
- package/dist/define-page.js +27 -0
- package/dist/define-page.js.map +1 -0
- package/dist/document.d.ts +19 -0
- package/dist/document.d.ts.map +1 -0
- package/dist/document.js +17 -0
- package/dist/document.js.map +1 -0
- package/dist/draft-mode.d.ts +5 -0
- package/dist/draft-mode.d.ts.map +1 -0
- package/dist/draft-mode.js +6 -0
- package/dist/draft-mode.js.map +1 -0
- package/dist/dynamic.d.ts +20 -0
- package/dist/dynamic.d.ts.map +1 -0
- package/dist/dynamic.js +37 -0
- package/dist/dynamic.js.map +1 -0
- package/dist/form-data.d.ts +10 -0
- package/dist/form-data.d.ts.map +1 -0
- package/dist/form-data.js +10 -0
- package/dist/form-data.js.map +1 -0
- package/dist/form.d.ts +35 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +80 -0
- package/dist/form.js.map +1 -0
- package/dist/href.d.ts +25 -0
- package/dist/href.d.ts.map +1 -0
- package/dist/href.js +71 -0
- package/dist/href.js.map +1 -0
- package/dist/image.d.ts +36 -0
- package/dist/image.d.ts.map +1 -0
- package/dist/image.js +60 -0
- package/dist/image.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/link.d.ts +17 -0
- package/dist/link.d.ts.map +1 -0
- package/dist/link.js +46 -0
- package/dist/link.js.map +1 -0
- package/dist/locale.d.ts +3 -0
- package/dist/locale.d.ts.map +1 -0
- package/dist/locale.js +6 -0
- package/dist/locale.js.map +1 -0
- package/dist/metadata.d.ts +83 -0
- package/dist/metadata.d.ts.map +1 -0
- package/dist/metadata.js +100 -0
- package/dist/metadata.js.map +1 -0
- package/dist/modules.d.ts +74 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +85 -0
- package/dist/modules.js.map +1 -0
- package/dist/nonce.d.ts +8 -0
- package/dist/nonce.d.ts.map +1 -0
- package/dist/nonce.js +12 -0
- package/dist/nonce.js.map +1 -0
- package/dist/payload.d.ts +171 -0
- package/dist/payload.d.ts.map +1 -0
- package/dist/payload.js +273 -0
- package/dist/payload.js.map +1 -0
- package/dist/public-config.d.ts +11 -0
- package/dist/public-config.d.ts.map +1 -0
- package/dist/public-config.js +10 -0
- package/dist/public-config.js.map +1 -0
- package/dist/query.d.ts +22 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +68 -0
- package/dist/query.js.map +1 -0
- package/dist/render-cleanup.d.ts +6 -0
- package/dist/render-cleanup.d.ts.map +1 -0
- package/dist/render-cleanup.js +7 -0
- package/dist/render-cleanup.js.map +1 -0
- package/dist/root.d.ts +12 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +26 -0
- package/dist/root.js.map +1 -0
- package/dist/route-tree.d.ts +56 -0
- package/dist/route-tree.d.ts.map +1 -0
- package/dist/route-tree.js +86 -0
- package/dist/route-tree.js.map +1 -0
- package/dist/router.d.ts +118 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +938 -0
- package/dist/router.js.map +1 -0
- package/dist/schema.d.ts +35 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +74 -0
- package/dist/schema.js.map +1 -0
- package/dist/script.d.ts +23 -0
- package/dist/script.d.ts.map +1 -0
- package/dist/script.js +89 -0
- package/dist/script.js.map +1 -0
- package/dist/server-entry.d.ts +2 -0
- package/dist/server-entry.d.ts.map +1 -0
- package/dist/server-entry.js +4 -0
- package/dist/server-entry.js.map +1 -0
- package/dist/server-render.d.ts +19 -0
- package/dist/server-render.d.ts.map +1 -0
- package/dist/server-render.js +193 -0
- package/dist/server-render.js.map +1 -0
- package/dist/testing-setup.d.ts +2 -0
- package/dist/testing-setup.d.ts.map +1 -0
- package/dist/testing-setup.js +8 -0
- package/dist/testing-setup.js.map +1 -0
- package/dist/testing.d.ts +76 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +106 -0
- package/dist/testing.js.map +1 -0
- package/dist/vitals-config.d.ts +4 -0
- package/dist/vitals-config.d.ts.map +1 -0
- package/dist/vitals-config.js +5 -0
- package/dist/vitals-config.js.map +1 -0
- package/dist/vitals.d.ts +13 -0
- package/dist/vitals.d.ts.map +1 -0
- package/dist/vitals.js +60 -0
- package/dist/vitals.js.map +1 -0
- package/dist/zod.d.ts +19 -0
- package/dist/zod.d.ts.map +1 -0
- package/dist/zod.js +65 -0
- package/dist/zod.js.map +1 -0
- package/package.json +147 -4
- package/sidecar/server.mjs +325 -0
- package/skills/netjes/SKILL.md +93 -0
- package/skills/netjes/references/caching-and-streaming.md +68 -0
- package/skills/netjes/references/data-and-actions.md +98 -0
- package/skills/netjes/references/deployment.md +57 -0
- package/skills/netjes/references/routing.md +65 -0
- package/skills/netjes/references/testing.md +57 -0
- package/src/actions.tsx +241 -0
- package/src/client-entry.tsx +126 -0
- package/src/deferred.ts +134 -0
- package/src/define-page.tsx +41 -0
- package/src/document.tsx +49 -0
- package/src/draft-mode.ts +6 -0
- package/src/dynamic.tsx +64 -0
- package/src/form-data.ts +10 -0
- package/src/form.tsx +122 -0
- package/src/href.ts +79 -0
- package/src/image.tsx +91 -0
- package/src/index.ts +40 -0
- package/src/link.tsx +73 -0
- package/src/locale.ts +6 -0
- package/src/metadata.tsx +181 -0
- package/src/modules.ts +133 -0
- package/src/nonce.tsx +14 -0
- package/src/payload.ts +405 -0
- package/src/public-config.tsx +19 -0
- package/src/query.tsx +82 -0
- package/src/render-cleanup.ts +7 -0
- package/src/root.tsx +39 -0
- package/src/route-tree.tsx +139 -0
- package/src/router.tsx +1188 -0
- package/src/schema.ts +98 -0
- package/src/script.tsx +103 -0
- package/src/server-entry.tsx +4 -0
- package/src/server-render.tsx +223 -0
- package/src/testing-setup.ts +8 -0
- package/src/testing.tsx +177 -0
- package/src/virtual.d.ts +12 -0
- package/src/vitals-config.ts +6 -0
- package/src/vitals.tsx +68 -0
- package/src/zod.ts +72 -0
- package/template/.dockerignore +11 -0
- package/template/Dockerfile +58 -0
- package/template/Features/Home/HomePage.cs +32 -0
- package/template/Program.cs +28 -0
- package/template/Properties/launchSettings.json +13 -0
- package/template/README.md +24 -0
- package/template/SiteProps.cs +4 -0
- package/template/_AGENTS.md +64 -0
- package/template/_CLAUDE.md +1 -0
- package/template/__NAME__.csproj +11 -0
- package/template/_gitattributes +2 -0
- package/template/_gitignore +11 -0
- package/template/app/about/page.tsx +17 -0
- package/template/app/error.tsx +15 -0
- package/template/app/layout.tsx +33 -0
- package/template/app/not-found.tsx +14 -0
- package/template/app/page.tsx +14 -0
- package/template/appsettings.Development.json +3 -0
- package/template/appsettings.json +9 -0
- package/template/components/Greeting.test.tsx +25 -0
- package/template/components/Greeting.tsx +26 -0
- package/template/components/HelloForm.test.tsx +27 -0
- package/template/components/HelloForm.tsx +34 -0
- package/template/docker-compose.override.yml +15 -0
- package/template/docker-compose.prod.yml +26 -0
- package/template/docker-compose.yml +16 -0
- package/template/generated/netjes.ts +73 -0
- package/template/package.json +36 -0
- package/template/public/favicon.svg +6 -0
- package/template/styles/global.css +7 -0
- package/template/tsconfig.json +19 -0
- package/template/vite.config.ts +7 -0
- package/vite/plugin.d.ts +29 -0
- package/vite/plugin.mjs +291 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComponentType, FunctionComponent, ReactNode } from 'react';
|
|
2
|
+
import type { LayoutProps } from './modules.ts';
|
|
3
|
+
import { type StandardSchemaV1, parseWith } from './schema.ts';
|
|
4
|
+
|
|
5
|
+
export type { StandardSchemaV1 } from './schema.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Declares a page whose props (sent by the C# loader) are validated with a Standard Schema before rendering,
|
|
9
|
+
* so the component receives parsed, typed props instead of trusting the server's JSON.
|
|
10
|
+
*/
|
|
11
|
+
export function definePage<Schema extends StandardSchemaV1>(
|
|
12
|
+
schema: Schema,
|
|
13
|
+
Page: ComponentType<StandardSchemaV1.InferOutput<Schema>>,
|
|
14
|
+
): FunctionComponent<Record<string, unknown>> {
|
|
15
|
+
function ValidatedPage(props: Record<string, unknown>) {
|
|
16
|
+
const parsed = parseWith(schema, props, 'props') as StandardSchemaV1.InferOutput<Schema> & object;
|
|
17
|
+
return <Page {...parsed} />;
|
|
18
|
+
}
|
|
19
|
+
ValidatedPage.displayName = `definePage(${Page.displayName ?? Page.name ?? 'Page'})`;
|
|
20
|
+
return ValidatedPage;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Declares a layout whose loader props (from `MapLayoutLoader` in C#) are validated with a Standard Schema. The
|
|
25
|
+
* component gets the parsed props next to `children` and `params`.
|
|
26
|
+
*/
|
|
27
|
+
export function defineLayout<Schema extends StandardSchemaV1>(
|
|
28
|
+
schema: Schema,
|
|
29
|
+
Layout: ComponentType<StandardSchemaV1.InferOutput<Schema> & LayoutProps>,
|
|
30
|
+
): FunctionComponent<LayoutProps & Record<string, unknown>> {
|
|
31
|
+
function ValidatedLayout({ children, params, ...props }: LayoutProps & Record<string, unknown>): ReactNode {
|
|
32
|
+
const parsed = parseWith(schema, props, 'layout props') as StandardSchemaV1.InferOutput<Schema> & object;
|
|
33
|
+
return (
|
|
34
|
+
<Layout {...parsed} params={params}>
|
|
35
|
+
{children}
|
|
36
|
+
</Layout>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
ValidatedLayout.displayName = `defineLayout(${Layout.displayName ?? Layout.name ?? 'Layout'})`;
|
|
40
|
+
return ValidatedLayout;
|
|
41
|
+
}
|
package/src/document.tsx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { DEFERRED_BOOTSTRAP } from './deferred.ts';
|
|
3
|
+
import { DATA_ELEMENT_ID, type DocumentInfo, SHELL_END_ID } from './payload.ts';
|
|
4
|
+
|
|
5
|
+
type DocumentProps = { info: DocumentInfo; dataJson: string; children: ReactNode };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `<html>` shell around every page. React renders and hydrates the whole document, so `<title>`, `<meta>`
|
|
9
|
+
* and `<link>` rendered by pages and layouts end up in the head.
|
|
10
|
+
*
|
|
11
|
+
* Documents stream, so the client entry is `async`: it hydrates the shell as soon as the shell is in, not once the
|
|
12
|
+
* last Suspense boundary has arrived. Everything it reads comes before the content: the deferred-values bootstrap in
|
|
13
|
+
* the head, the payload first in the body. The empty element at the end of the body is the last thing in the shell,
|
|
14
|
+
* which the client entry waits for before it hydrates: a cached entry can run long before the parser gets there.
|
|
15
|
+
*/
|
|
16
|
+
export function Document({ info, dataJson, children }: DocumentProps) {
|
|
17
|
+
const { nonce } = info;
|
|
18
|
+
return (
|
|
19
|
+
<html lang={info.lang}>
|
|
20
|
+
<head>
|
|
21
|
+
<meta charSet="utf-8" />
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
23
|
+
{/* Vite reads the nonce from here for the styles it injects. */}
|
|
24
|
+
{nonce && <meta property="csp-nonce" nonce={nonce} />}
|
|
25
|
+
{/* Collects Page.Defer values streamed in before the client script runs. A client render finds it in the
|
|
26
|
+
document the server wrote; one React creates would never run. */}
|
|
27
|
+
{!info.clientRender && <script nonce={nonce} dangerouslySetInnerHTML={{ __html: DEFERRED_BOOTSTRAP }} />}
|
|
28
|
+
{info.assets.stylesheets.map((href) => (
|
|
29
|
+
<link key={href} rel="stylesheet" href={href} nonce={nonce} />
|
|
30
|
+
))}
|
|
31
|
+
{info.devStyles?.map((style) => (
|
|
32
|
+
<style key={style.id} data-vite-dev-id={style.id} nonce={nonce} dangerouslySetInnerHTML={{ __html: style.css }} />
|
|
33
|
+
))}
|
|
34
|
+
{info.assets.modulePreloads.map((href) => (
|
|
35
|
+
<link key={href} rel="modulepreload" href={href} nonce={nonce} />
|
|
36
|
+
))}
|
|
37
|
+
{info.assets.scripts.map((src) => (
|
|
38
|
+
<script key={src} type="module" src={src} async nonce={nonce} />
|
|
39
|
+
))}
|
|
40
|
+
</head>
|
|
41
|
+
<body>
|
|
42
|
+
{/* Already escaped for HTML by the server; the client reads it back verbatim to hydrate. */}
|
|
43
|
+
<script id={DATA_ELEMENT_ID} type="application/json" nonce={nonce} dangerouslySetInnerHTML={{ __html: dataJson }} />
|
|
44
|
+
{children}
|
|
45
|
+
<template id={SHELL_END_ID} />
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { usePagePayload } from './router.tsx';
|
|
2
|
+
|
|
3
|
+
/** Whether this page was rendered in draft mode (`IDraftMode.Enable()` on the server), like Next.js's `draftMode()`. */
|
|
4
|
+
export function useDraftMode(): { isEnabled: boolean } {
|
|
5
|
+
return { isEnabled: usePagePayload().draftMode === true };
|
|
6
|
+
}
|
package/src/dynamic.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ComponentType, type ReactNode, Suspense, lazy, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
const subscribe = () => () => {};
|
|
4
|
+
|
|
5
|
+
/** False on the server and while hydrating, true afterwards (and for client-only renders): hydration-safe. */
|
|
6
|
+
function useHydrated() {
|
|
7
|
+
return useSyncExternalStore(
|
|
8
|
+
subscribe,
|
|
9
|
+
() => true,
|
|
10
|
+
() => false,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ClientOnly({ fallback, children }: { fallback: ReactNode; children: ReactNode }) {
|
|
15
|
+
return useHydrated() ? children : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Options for `dynamic`. */
|
|
19
|
+
export type DynamicOptions = {
|
|
20
|
+
/** Server-render the component (default), or only render it in the browser (`false`), e.g. for code that needs `window`. */
|
|
21
|
+
ssr?: boolean;
|
|
22
|
+
/** Shown while the module loads, and on the server when `ssr` is false. */
|
|
23
|
+
loading?: ComponentType;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Like `next/dynamic`: a component whose module loads on demand, in its own chunk.
|
|
28
|
+
* `dynamic(() => import('./Chart.tsx'), { loading: Spinner })`.
|
|
29
|
+
*/
|
|
30
|
+
export function dynamic<P extends object>(
|
|
31
|
+
load: () => Promise<{ default: ComponentType<P> } | ComponentType<P>>,
|
|
32
|
+
options: DynamicOptions = {},
|
|
33
|
+
): ComponentType<P> {
|
|
34
|
+
const Lazy = lazy(async () => {
|
|
35
|
+
const module = await load();
|
|
36
|
+
return { default: typeof module === 'function' ? module : module.default };
|
|
37
|
+
});
|
|
38
|
+
const Loading = options.loading;
|
|
39
|
+
|
|
40
|
+
function Dynamic(props: P) {
|
|
41
|
+
const fallback = Loading ? <Loading /> : null;
|
|
42
|
+
const content = (
|
|
43
|
+
<Suspense fallback={fallback}>
|
|
44
|
+
<Lazy {...props} />
|
|
45
|
+
</Suspense>
|
|
46
|
+
);
|
|
47
|
+
return options.ssr === false ? <ClientOnly fallback={fallback}>{content}</ClientOnly> : content;
|
|
48
|
+
}
|
|
49
|
+
Dynamic.displayName = 'Dynamic';
|
|
50
|
+
return Dynamic;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A component that renders only in the browser, after hydration; `fallback` (default nothing) until then and on the server. */
|
|
54
|
+
export function clientOnly<P extends object>(Component: ComponentType<P>, options: { fallback?: ReactNode } = {}): ComponentType<P> {
|
|
55
|
+
function ClientOnlyComponent(props: P) {
|
|
56
|
+
return (
|
|
57
|
+
<ClientOnly fallback={options.fallback ?? null}>
|
|
58
|
+
<Component {...props} />
|
|
59
|
+
</ClientOnly>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
ClientOnlyComponent.displayName = `clientOnly(${Component.displayName ?? Component.name ?? 'Component'})`;
|
|
63
|
+
return ClientOnlyComponent;
|
|
64
|
+
}
|
package/src/form-data.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A form's fields as a request body: URL-encoded, or multipart when there are files or the form asks for it. */
|
|
2
|
+
export type EncodedForm = { body: string; contentType: string } | { body: FormData; contentType: null };
|
|
3
|
+
|
|
4
|
+
export function encodeForm(data: FormData, multipart = false): EncodedForm {
|
|
5
|
+
const entries = [...data.entries()];
|
|
6
|
+
if (multipart || entries.some(([, value]) => typeof value !== 'string')) return { body: data, contentType: null };
|
|
7
|
+
const params = new URLSearchParams();
|
|
8
|
+
for (const [name, value] of entries) params.append(name, String(value));
|
|
9
|
+
return { body: params.toString(), contentType: 'application/x-www-form-urlencoded;charset=UTF-8' };
|
|
10
|
+
}
|
package/src/form.tsx
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, FormHTMLAttributes, SubmitEvent } from 'react';
|
|
2
|
+
import { RETURN_FIELD, actionMetaOf } from './actions.tsx';
|
|
3
|
+
import { encodeForm } from './form-data.ts';
|
|
4
|
+
import type { Href } from './href.ts';
|
|
5
|
+
import { useBasePath, useInternalRouter, usePagePayload, withBasePath } from './router.tsx';
|
|
6
|
+
|
|
7
|
+
/** A React form action, e.g. the `formAction` from `useAction(serverAction('name'))`. */
|
|
8
|
+
export type FormAction = (formData: FormData) => void | Promise<void>;
|
|
9
|
+
|
|
10
|
+
/** The props of `<Form>`: a form's own, with client navigation and server action support. */
|
|
11
|
+
export type FormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'method' | 'action'> & {
|
|
12
|
+
method?: 'get' | 'post';
|
|
13
|
+
/**
|
|
14
|
+
* Where to submit. Default: the current URL, which is where `MapPageAction` handlers for this page listen. Or a
|
|
15
|
+
* form action function: a server action from `useAction` (a real form post without JavaScript), or any function.
|
|
16
|
+
*/
|
|
17
|
+
action?: string | FormAction;
|
|
18
|
+
replace?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Let the browser submit it the classic way, with a full page load: for responses that are not pages, such as
|
|
21
|
+
* large file downloads, which the browser then streams to disk instead of Netjes buffering them.
|
|
22
|
+
*/
|
|
23
|
+
reloadDocument?: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function formDataOf(form: HTMLFormElement, submitter: HTMLElement | null): FormData {
|
|
27
|
+
try {
|
|
28
|
+
return new FormData(form, submitter);
|
|
29
|
+
} catch {
|
|
30
|
+
const data = new FormData(form);
|
|
31
|
+
if (submitter instanceof HTMLButtonElement && submitter.name) data.append(submitter.name, submitter.value);
|
|
32
|
+
return data;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A `<form>` that submits without a full page load and handles the page action's result like a navigation:
|
|
38
|
+
* a redirect is followed, re-rendered props replace the page. Without JavaScript it is a normal form post.
|
|
39
|
+
* POST forms include the antiforgery token when the server has antiforgery enabled.
|
|
40
|
+
*
|
|
41
|
+
* With a function `action` React runs it (so `useFormStatus` works). For server actions the server renders a real
|
|
42
|
+
* `action`/`method`, the token and the return URL, so the same form also posts without JavaScript.
|
|
43
|
+
*/
|
|
44
|
+
export function Form({ method = 'get', action, replace = false, reloadDocument = false, onSubmit, children, ...rest }: FormProps) {
|
|
45
|
+
const router = useInternalRouter();
|
|
46
|
+
const payload = usePagePayload();
|
|
47
|
+
const basePath = useBasePath();
|
|
48
|
+
const isFunction = typeof action === 'function';
|
|
49
|
+
const serverAction = actionMetaOf(action);
|
|
50
|
+
const actionUrl = typeof action === 'string' ? withBasePath(action, basePath) : undefined;
|
|
51
|
+
const token = method === 'post' || serverAction ? payload.antiforgery : undefined;
|
|
52
|
+
const hidden = (
|
|
53
|
+
<>
|
|
54
|
+
{token?.requestToken && <input type="hidden" name={token.formFieldName} value={token.requestToken} />}
|
|
55
|
+
{serverAction && <input type="hidden" name={RETURN_FIELD} value={payload.url} />}
|
|
56
|
+
</>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (isFunction) {
|
|
60
|
+
// React renders function actions as an inert placeholder on the server; a server action gets its real URL
|
|
61
|
+
// there instead. React keeps the server's attributes when hydrating a function action.
|
|
62
|
+
const onServer = typeof document === 'undefined';
|
|
63
|
+
return (
|
|
64
|
+
<form
|
|
65
|
+
{...rest}
|
|
66
|
+
action={onServer && serverAction ? withBasePath(serverAction.url, basePath) : action}
|
|
67
|
+
method={onServer && serverAction ? 'post' : undefined}
|
|
68
|
+
onSubmit={onSubmit}
|
|
69
|
+
>
|
|
70
|
+
{hidden}
|
|
71
|
+
{children}
|
|
72
|
+
</form>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
|
|
77
|
+
onSubmit?.(event);
|
|
78
|
+
if (event.defaultPrevented || reloadDocument) return;
|
|
79
|
+
const form = event.currentTarget;
|
|
80
|
+
const submitter = event.submitter;
|
|
81
|
+
const target = submitter?.getAttribute('formtarget') ?? form.getAttribute('target');
|
|
82
|
+
if (target && target !== '_self') return;
|
|
83
|
+
const formAction = submitter?.getAttribute('formaction');
|
|
84
|
+
const url = new URL(formAction ? withBasePath(formAction, basePath) : (actionUrl ?? window.location.href), window.location.href);
|
|
85
|
+
if (url.origin !== window.location.origin) return;
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
|
|
88
|
+
const data = formDataOf(form, submitter);
|
|
89
|
+
const submitMethod = (submitter?.getAttribute('formmethod') ?? method).toLowerCase();
|
|
90
|
+
if (submitMethod === 'post') {
|
|
91
|
+
void router.submit(url, encodeForm(data, form.enctype === 'multipart/form-data'));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const query = new URLSearchParams();
|
|
95
|
+
for (const [name, value] of data.entries()) if (typeof value === 'string') query.append(name, value);
|
|
96
|
+
url.search = query.toString();
|
|
97
|
+
url.hash = '';
|
|
98
|
+
// Built from the form's own action, which is not one of the app's typed routes.
|
|
99
|
+
const href = url.href as Href;
|
|
100
|
+
void (replace ? router.replace(href) : router.push(href));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<form method={method} action={actionUrl} {...rest} onSubmit={handleSubmit}>
|
|
105
|
+
{hidden}
|
|
106
|
+
{children}
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The props of `<SubmitButton>`: a button's own, except `type`. */
|
|
112
|
+
export type SubmitButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A submit button whose `formAction` is rendered under the base path, so it posts to the right place with and
|
|
116
|
+
* without JavaScript (a plain `<button formAction="/x">` would miss the base without JavaScript).
|
|
117
|
+
*/
|
|
118
|
+
export function SubmitButton({ formAction, ...rest }: SubmitButtonProps) {
|
|
119
|
+
const basePath = useBasePath();
|
|
120
|
+
const action = typeof formAction === 'string' ? withBasePath(formAction, basePath) : formAction;
|
|
121
|
+
return <button type="submit" formAction={action} {...rest} />;
|
|
122
|
+
}
|
package/src/href.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apps register their generated route type here (the file `netjes generate` writes does it), which types the
|
|
3
|
+
* `href` of `<Link>` and `router.push`: `declare module 'netjes' { interface Register { href: Href } }`.
|
|
4
|
+
*/
|
|
5
|
+
// Empty on purpose: filled in by declaration merging.
|
|
6
|
+
export interface Register {}
|
|
7
|
+
|
|
8
|
+
/** A URL `<Link>` and the router accept: the app's routes once registered, any string before that. */
|
|
9
|
+
export type Href = Register extends { href: infer H extends string } ? H : string;
|
|
10
|
+
|
|
11
|
+
/** One value of a query string: `null` and `undefined` leave the key out. */
|
|
12
|
+
export type SearchValue = string | number | boolean | null | undefined;
|
|
13
|
+
/** A query string as an object: a value per key, or a list of values for a key that repeats. */
|
|
14
|
+
export type SearchParamsInput = Record<string, SearchValue | readonly SearchValue[]>;
|
|
15
|
+
|
|
16
|
+
function matches(pattern: readonly string[], path: readonly string[]): boolean {
|
|
17
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
18
|
+
const segment = pattern[i]!;
|
|
19
|
+
if (segment.startsWith('[[...')) return true;
|
|
20
|
+
if (segment.startsWith('[...')) return path.length > i;
|
|
21
|
+
if (i >= path.length) return false;
|
|
22
|
+
if (!segment.startsWith('[') && segment !== path[i]) return false;
|
|
23
|
+
}
|
|
24
|
+
return pattern.length === path.length;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a URL is one of the app's pages (by route pattern), a link within the page (`?…`, `#…`), or another
|
|
29
|
+
* site. The generated `isHref()` and `Href` schema call this to check URLs that come from data.
|
|
30
|
+
*/
|
|
31
|
+
export function isRouteHref(value: string, patterns: readonly string[]): boolean {
|
|
32
|
+
if (/^(https?:|mailto:|tel:)/i.test(value) || value.startsWith('?') || value.startsWith('#')) return true;
|
|
33
|
+
// "//host" and "/\host" are other hosts as far as browsers are concerned.
|
|
34
|
+
if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false;
|
|
35
|
+
const path = (value.split(/[?#]/, 1)[0] ?? '').split('/').filter(Boolean);
|
|
36
|
+
return patterns.some((pattern) => matches(pattern.split('/').filter(Boolean), path));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds the URL for a route pattern written in file-name syntax (`/blog/[slug]`, `/docs/[...path]`,
|
|
41
|
+
* `/shop/[[...filters]]`), encoding every segment value. The generated `route()` helper calls this with typed params.
|
|
42
|
+
*/
|
|
43
|
+
export function buildHref(pattern: string, params: Record<string, unknown>, search?: SearchParamsInput): string {
|
|
44
|
+
const missing = (name: string): never => {
|
|
45
|
+
throw new Error(`netjes: route '${pattern}' needs the parameter '${name}'`);
|
|
46
|
+
};
|
|
47
|
+
const segments = pattern
|
|
48
|
+
.split('/')
|
|
49
|
+
.filter(Boolean)
|
|
50
|
+
.flatMap((segment): string[] => {
|
|
51
|
+
const optional = /^\[\[\.\.\.(\w+)\]\]$/.exec(segment)?.[1];
|
|
52
|
+
if (optional) {
|
|
53
|
+
const values = params[optional];
|
|
54
|
+
return Array.isArray(values) ? values.map((v) => encodeURIComponent(String(v))) : [];
|
|
55
|
+
}
|
|
56
|
+
const catchAll = /^\[\.\.\.(\w+)\]$/.exec(segment)?.[1];
|
|
57
|
+
if (catchAll) {
|
|
58
|
+
const values = params[catchAll];
|
|
59
|
+
if (!Array.isArray(values) || values.length === 0) return missing(catchAll);
|
|
60
|
+
return values.map((v) => encodeURIComponent(String(v)));
|
|
61
|
+
}
|
|
62
|
+
const single = /^\[(\w+)\]$/.exec(segment)?.[1];
|
|
63
|
+
if (single) {
|
|
64
|
+
const value = params[single];
|
|
65
|
+
if (value === undefined || value === null || value === '') return missing(single);
|
|
66
|
+
return [encodeURIComponent(String(value))];
|
|
67
|
+
}
|
|
68
|
+
return [segment];
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const query = new URLSearchParams();
|
|
72
|
+
for (const [key, value] of Object.entries(search ?? {})) {
|
|
73
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
74
|
+
if (item !== undefined && item !== null) query.append(key, String(item));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const queryString = query.toString();
|
|
78
|
+
return `/${segments.join('/')}${queryString ? `?${queryString}` : ''}`;
|
|
79
|
+
}
|
package/src/image.tsx
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { type ImgHTMLAttributes, createContext, useContext } from 'react';
|
|
2
|
+
import { preload } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where the optimizer lives, and the widths and qualities it serves: Netjes:Images on the server, sent with the first
|
|
6
|
+
* document. Without `qualities`, any quality is requested as given.
|
|
7
|
+
*/
|
|
8
|
+
export type ImageConfig = { path: string; widths: readonly number[]; qualities?: readonly number[]; basePath?: string };
|
|
9
|
+
|
|
10
|
+
/** Next.js's device and image sizes; the server's Netjes:Images:Widths default to the same. */
|
|
11
|
+
const DEFAULT_CONFIG: ImageConfig = {
|
|
12
|
+
path: '/_netjes/image',
|
|
13
|
+
widths: [16, 32, 48, 64, 96, 128, 256, 384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ImageConfigContext = createContext<ImageConfig>(DEFAULT_CONFIG);
|
|
17
|
+
|
|
18
|
+
export const ImageConfigProvider = ImageConfigContext.Provider;
|
|
19
|
+
|
|
20
|
+
/** The props of `<Image>`: an `<img>`'s own, with a required size and `alt`, and the optimizer's options. */
|
|
21
|
+
export type ImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'width' | 'height' | 'loading' | 'alt'> & {
|
|
22
|
+
/** A path in `public/` (`/img/hero.png`) or a URL on a host in Netjes:Images:RemoteHosts. */
|
|
23
|
+
src: string;
|
|
24
|
+
/** Required, as for any image; `""` for decorative ones. */
|
|
25
|
+
alt: string;
|
|
26
|
+
/** The intrinsic size in CSS pixels; reserves space so the page does not shift as it loads. */
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
/** For responsive layouts, e.g. `(max-width: 768px) 100vw, 50vw`: the browser then picks from every width. */
|
|
30
|
+
sizes?: string;
|
|
31
|
+
/** 1–100. Default 75. Rounded to the nearest of Netjes:Images:Qualities. */
|
|
32
|
+
quality?: number;
|
|
33
|
+
/** Above the fold: load eagerly with high priority, and preload it from the document head. */
|
|
34
|
+
priority?: boolean;
|
|
35
|
+
/** Serve `src` as is. SVGs and data URLs always are. */
|
|
36
|
+
unoptimized?: boolean;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function optimizerUrl(config: ImageConfig, src: string, width: number, quality: number) {
|
|
40
|
+
return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The accepted quality nearest to the one asked for (the higher one on a tie). */
|
|
44
|
+
function qualityFor(qualities: readonly number[] | undefined, wanted: number) {
|
|
45
|
+
if (!qualities?.length) return wanted;
|
|
46
|
+
return qualities.reduce((best, q) => (Math.abs(q - wanted) < Math.abs(best - wanted) || (Math.abs(q - wanted) === Math.abs(best - wanted) && q > best) ? q : best));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The smallest configured width that covers `target`, or the largest there is. */
|
|
50
|
+
function widthFor(widths: readonly number[], target: number) {
|
|
51
|
+
return widths.find((w) => w >= target) ?? widths[widths.length - 1] ?? target;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function underBasePath(src: string, basePath: string | undefined) {
|
|
55
|
+
return basePath && src.startsWith('/') && !src.startsWith('//') && !src.startsWith(`${basePath}/`) ? basePath + src : src;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isUnoptimizable(src: string) {
|
|
59
|
+
return src.startsWith('data:') || src.startsWith('blob:') || /\.svg($|[?#])/i.test(src);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Like `next/image`: an `<img>` with width and height (no layout shift), lazy loading, and a `srcset` of resized
|
|
64
|
+
* copies from the `/_netjes/image` endpoint, so each screen downloads only what it needs.
|
|
65
|
+
*/
|
|
66
|
+
export function Image({ src, alt, width, height, sizes, quality = 75, priority = false, unoptimized = false, ...rest }: ImageProps) {
|
|
67
|
+
const config = useContext(ImageConfigContext);
|
|
68
|
+
const common = {
|
|
69
|
+
...rest,
|
|
70
|
+
alt,
|
|
71
|
+
width,
|
|
72
|
+
height,
|
|
73
|
+
decoding: 'async' as const,
|
|
74
|
+
loading: priority ? ('eager' as const) : ('lazy' as const),
|
|
75
|
+
...(priority ? { fetchPriority: 'high' as const } : {}),
|
|
76
|
+
};
|
|
77
|
+
// Served as is, from public/: under the app's base path. (The optimizer's `url` stays a public/ path.)
|
|
78
|
+
if (unoptimized || isUnoptimizable(src)) return <img {...common} src={underBasePath(src, config.basePath)} />;
|
|
79
|
+
|
|
80
|
+
const widths = [...config.widths].sort((a, b) => a - b);
|
|
81
|
+
const q = qualityFor(config.qualities, quality);
|
|
82
|
+
// Responsive: every width, the browser picks by `sizes`. Fixed: 1x and 2x, each once.
|
|
83
|
+
const chosen = sizes ? widths : [...new Set([widthFor(widths, width), widthFor(widths, width * 2)])];
|
|
84
|
+
const srcSet = chosen
|
|
85
|
+
.map((w, i) => `${optimizerUrl(config, src, w, q)} ${sizes ? `${w}w` : `${i + 1}x`}`)
|
|
86
|
+
.join(', ');
|
|
87
|
+
const fallback = optimizerUrl(config, src, chosen[chosen.length - 1] ?? width, q);
|
|
88
|
+
if (priority) preload(fallback, { as: 'image', imageSrcSet: srcSet, ...(sizes ? { imageSizes: sizes } : {}), fetchPriority: 'high' });
|
|
89
|
+
|
|
90
|
+
return <img {...common} sizes={sizes} srcSet={srcSet} src={fallback} />;
|
|
91
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export {
|
|
2
|
+
type ActionState,
|
|
3
|
+
type PageActionFailure,
|
|
4
|
+
type ServerAction,
|
|
5
|
+
ServerActionError,
|
|
6
|
+
type UseActionOptions,
|
|
7
|
+
serverAction,
|
|
8
|
+
useAction,
|
|
9
|
+
usePageActionState,
|
|
10
|
+
} from './actions.tsx';
|
|
11
|
+
export { defineLayout, definePage } from './define-page.tsx';
|
|
12
|
+
export { useDraftMode } from './draft-mode.ts';
|
|
13
|
+
export { type DynamicOptions, clientOnly, dynamic } from './dynamic.tsx';
|
|
14
|
+
export { Form, type FormAction, type FormProps, SubmitButton, type SubmitButtonProps } from './form.tsx';
|
|
15
|
+
export { type Href, type Register, type SearchParamsInput, type SearchValue, buildHref, isRouteHref } from './href.ts';
|
|
16
|
+
export { Image, type ImageProps } from './image.tsx';
|
|
17
|
+
export { Link, type LinkProps } from './link.tsx';
|
|
18
|
+
export { useLocale } from './locale.ts';
|
|
19
|
+
export type { ErrorPageProps, LayoutProps } from './modules.ts';
|
|
20
|
+
export { useNonce } from './nonce.tsx';
|
|
21
|
+
export type { PageKind, Params, SeededQuery, SegmentRef } from './payload.ts';
|
|
22
|
+
export { usePublicConfig } from './public-config.tsx';
|
|
23
|
+
export { Script, type ScriptProps, type ScriptStrategy } from './script.tsx';
|
|
24
|
+
export {
|
|
25
|
+
type NavigationState,
|
|
26
|
+
type SubmitError,
|
|
27
|
+
type Router,
|
|
28
|
+
useBasePath,
|
|
29
|
+
useHref,
|
|
30
|
+
useNavigation,
|
|
31
|
+
useParams,
|
|
32
|
+
usePathname,
|
|
33
|
+
useRouter,
|
|
34
|
+
useSearchParams,
|
|
35
|
+
useSelectedLayoutSegment,
|
|
36
|
+
useSelectedLayoutSegments,
|
|
37
|
+
useSharedProps,
|
|
38
|
+
} from './router.tsx';
|
|
39
|
+
export type { StandardSchemaV1 } from './schema.ts';
|
|
40
|
+
export type { Metadata, MetadataImage, MetadataTitle } from './metadata.tsx';
|
package/src/link.tsx
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes, MouseEvent } from 'react';
|
|
2
|
+
import type { Href } from './href.ts';
|
|
3
|
+
import { useBasePath, useInternalRouter, usePathname, withBasePath } from './router.tsx';
|
|
4
|
+
|
|
5
|
+
/** The props of `<Link>`: an anchor's own, with a typed `href` and the client navigation options. */
|
|
6
|
+
export type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {
|
|
7
|
+
/** Typed by the app's routes once `netjes generate` has registered them. */
|
|
8
|
+
href: Href;
|
|
9
|
+
replace?: boolean;
|
|
10
|
+
/** Scroll to the top (or the #fragment) after navigating. Default true. */
|
|
11
|
+
scroll?: boolean;
|
|
12
|
+
/** `intent` (default) fetches the next page on hover or focus. */
|
|
13
|
+
prefetch?: 'intent' | 'none';
|
|
14
|
+
/** Let the browser follow it with a full page load, e.g. for a file download or a page outside Netjes. */
|
|
15
|
+
reloadDocument?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const BASE = 'http://netjes.invalid';
|
|
19
|
+
|
|
20
|
+
function isCurrentPath(href: string, pathname: string) {
|
|
21
|
+
const url = new URL(href, BASE + pathname);
|
|
22
|
+
return url.origin === BASE && url.pathname === pathname;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>) {
|
|
26
|
+
const anchor = event.currentTarget;
|
|
27
|
+
return (
|
|
28
|
+
event.button === 0 &&
|
|
29
|
+
!event.metaKey &&
|
|
30
|
+
!event.ctrlKey &&
|
|
31
|
+
!event.shiftKey &&
|
|
32
|
+
!event.altKey &&
|
|
33
|
+
(!anchor.target || anchor.target === '_self') &&
|
|
34
|
+
!anchor.hasAttribute('download')
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** An `<a>` that navigates on the client. Ctrl/cmd-click, new tabs and external URLs behave like a normal link. */
|
|
39
|
+
export function Link({ href, replace = false, scroll = true, prefetch = 'intent', reloadDocument = false, onClick, onMouseEnter, onFocus, onTouchStart, ...rest }: LinkProps) {
|
|
40
|
+
const router = useInternalRouter();
|
|
41
|
+
const pathname = usePathname();
|
|
42
|
+
const basePath = useBasePath();
|
|
43
|
+
const warm = () => {
|
|
44
|
+
if (prefetch === 'intent' && !reloadDocument) router.prefetch(href);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<a
|
|
49
|
+
href={withBasePath(href, basePath)}
|
|
50
|
+
aria-current={isCurrentPath(href, pathname) ? 'page' : undefined}
|
|
51
|
+
{...rest}
|
|
52
|
+
onClick={(event) => {
|
|
53
|
+
onClick?.(event);
|
|
54
|
+
if (event.defaultPrevented || reloadDocument || !isPlainLeftClick(event)) return;
|
|
55
|
+
if (new URL(href, window.location.href).origin !== window.location.origin) return;
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
void (replace ? router.replace(href, { scroll }) : router.push(href, { scroll }));
|
|
58
|
+
}}
|
|
59
|
+
onMouseEnter={(event) => {
|
|
60
|
+
onMouseEnter?.(event);
|
|
61
|
+
warm();
|
|
62
|
+
}}
|
|
63
|
+
onFocus={(event) => {
|
|
64
|
+
onFocus?.(event);
|
|
65
|
+
warm();
|
|
66
|
+
}}
|
|
67
|
+
onTouchStart={(event) => {
|
|
68
|
+
onTouchStart?.(event);
|
|
69
|
+
warm();
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
package/src/locale.ts
ADDED