@void/svelte 1.4.0 → 1.6.0
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/plugin.d.mts +2 -10
- package/dist/plugin.mjs +40 -98
- package/package.json +6 -6
- package/src/runtime/App.svelte +1 -1
- package/src/runtime/Link.svelte +41 -113
- package/src/runtime/link-props.ts +16 -0
- package/src/runtime/use-form.svelte.ts +33 -47
- package/src/runtime/use-island-form.svelte.ts +11 -21
package/dist/plugin.d.mts
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
2
|
+
import { PrefetchConfig } from "void/pages";
|
|
2
3
|
import { Plugin } from "vite";
|
|
3
|
-
//#region src/plugin-pages.d.ts
|
|
4
|
-
type PrefetchConfig = {
|
|
5
|
-
hoverDelay?: number;
|
|
6
|
-
cacheFor?: number | string | [string, string];
|
|
7
|
-
};
|
|
8
|
-
//#endregion
|
|
9
|
-
//#region src/plugin.d.ts
|
|
10
4
|
export type VoidSvelteOptions = Readonly<{
|
|
11
5
|
svelte?: Parameters<typeof svelte>[0];
|
|
12
6
|
viewTransitions?: boolean;
|
|
13
7
|
prefetch?: PrefetchConfig;
|
|
14
8
|
}>;
|
|
15
|
-
export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
|
|
16
|
-
//#endregion
|
|
17
|
-
export {};
|
|
9
|
+
export declare function voidSvelte(options?: VoidSvelteOptions): Array<Plugin>;
|
package/dist/plugin.mjs
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
2
|
-
import { createPagesPlugin, generateComponentManifest,
|
|
2
|
+
import { createPagesPlugin, generateComponentManifest, resolvePagesDir, resolvePrefetchConfig, scanPages } from "void/pages";
|
|
3
3
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import nodePath from "node:path";
|
|
5
5
|
import { preprocess } from "svelte/compiler";
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
//#region ../../node_modules/.pnpm/pathslash@0.1.0/node_modules/pathslash/dist/index.mjs
|
|
6
|
+
import { ISLANDS_CLIENT_ENTRY_ID, ISLANDS_RENDERER_ID, computePagesNeedingClientJS, createIslandManifest, editIslandSource, findIslandImports, generateIslandClientEntry, islandCssFiles, islandStylesPlugin, islandsBuildConfig, mdClientScriptIds, recordIslandStyles, recordedIslandStyles, watchPagesForRescan } from "void/pages-islands-plugin";
|
|
7
|
+
// ../../node_modules/.pnpm/pathslash@0.1.0/node_modules/pathslash/dist/index.mjs
|
|
9
8
|
const BACKSLASH_RE = /\\/g;
|
|
10
9
|
const isWindows = process.platform === "win32";
|
|
11
10
|
/**
|
|
@@ -43,13 +42,24 @@ const posix = { ...nodePath.posix };
|
|
|
43
42
|
posix.posix = win32.posix = posix;
|
|
44
43
|
posix.win32 = win32.win32 = win32;
|
|
45
44
|
const { basename, delimiter, dirname, extname, format, isAbsolute, join, matchesGlob, normalize, parse, relative, resolve, sep, toNamespacedPath } = isWindows ? win32 : posix;
|
|
46
|
-
|
|
47
|
-
//#region src/plugin-islands.ts
|
|
45
|
+
// src/plugin-islands.ts
|
|
48
46
|
const COMPONENT_EXTENSIONS = [
|
|
49
47
|
".svelte",
|
|
50
48
|
".ts",
|
|
51
49
|
".js"
|
|
52
50
|
];
|
|
51
|
+
const HYDRATE_ISLAND = `
|
|
52
|
+
async function hydrateIsland(el, mod, props, frontmatter) {
|
|
53
|
+
const { hydrate } = await import("svelte");
|
|
54
|
+
const Component = mod.default || mod;
|
|
55
|
+
const context = new Map([["__void_frontmatter", frontmatter]]);
|
|
56
|
+
hydrate(Component, { target: el, props, context });
|
|
57
|
+
el.setAttribute("data-hydrated", "true");
|
|
58
|
+
}`;
|
|
59
|
+
/** A Svelte component has at most one top-level `<style>`. */
|
|
60
|
+
function styleBlockImports(file, content) {
|
|
61
|
+
return /<style\b/.test(content) ? [`import ${JSON.stringify(file + ".css?island-style")};`] : [];
|
|
62
|
+
}
|
|
53
63
|
/**
|
|
54
64
|
* Prefix for virtual island wrapper modules. Must NOT use the `\0` prefix
|
|
55
65
|
* because `@sveltejs/vite-plugin-svelte` skips `\0`-prefixed modules in
|
|
@@ -59,27 +69,10 @@ const WRAPPER_PREFIX = "/@void-island-wrap/";
|
|
|
59
69
|
function islandsPlugin(pagesDir, options) {
|
|
60
70
|
let pageScan = null;
|
|
61
71
|
const root = options?.root ?? dirname(pagesDir);
|
|
62
|
-
const islandManifest =
|
|
63
|
-
|
|
72
|
+
const islandManifest = createIslandManifest(root, COMPONENT_EXTENSIONS);
|
|
73
|
+
let cssFiles = /* @__PURE__ */ new Set();
|
|
64
74
|
let isDev = false;
|
|
65
75
|
let resolvedConfig = null;
|
|
66
|
-
function registerIsland(spec, importerDir, hydrate) {
|
|
67
|
-
const absPath = resolveSpecifier(spec, importerDir, COMPONENT_EXTENSIONS);
|
|
68
|
-
if (!absPath) return {
|
|
69
|
-
absPath,
|
|
70
|
-
islandId: spec
|
|
71
|
-
};
|
|
72
|
-
const islandId = normalizePath(relative(root, absPath)).replace(/\.[^.]+$/, "");
|
|
73
|
-
islandManifest.set(islandId, {
|
|
74
|
-
componentPath: absPath,
|
|
75
|
-
islandId,
|
|
76
|
-
hydrate
|
|
77
|
-
});
|
|
78
|
-
return {
|
|
79
|
-
absPath,
|
|
80
|
-
islandId
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
76
|
return [
|
|
84
77
|
{
|
|
85
78
|
name: "void-svelte:islands",
|
|
@@ -99,33 +92,8 @@ function islandsPlugin(pagesDir, options) {
|
|
|
99
92
|
pagesDir,
|
|
100
93
|
environment: isDev ? "dev" : "prod"
|
|
101
94
|
});
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
filePath: page.componentPath,
|
|
105
|
-
island: page.island
|
|
106
|
-
})),
|
|
107
|
-
...pageScan.layouts.map((layout) => ({
|
|
108
|
-
filePath: layout.filePath,
|
|
109
|
-
island: layout.island
|
|
110
|
-
})),
|
|
111
|
-
...pageScan.namedLayouts.map((namedLayout) => ({
|
|
112
|
-
filePath: namedLayout.filePath,
|
|
113
|
-
island: namedLayout.island
|
|
114
|
-
}))
|
|
115
|
-
];
|
|
116
|
-
for (const entry of filesToScan) {
|
|
117
|
-
if (!entry.island) continue;
|
|
118
|
-
const filePath = resolve(pagesDir, entry.filePath);
|
|
119
|
-
const code = readFileSync(filePath, "utf-8");
|
|
120
|
-
const importerDir = dirname(filePath);
|
|
121
|
-
for (const islandImport of findIslandImports(code, filePath)) registerIsland(islandImport.specifier, importerDir, islandImport.hydrate);
|
|
122
|
-
}
|
|
123
|
-
for (const page of pageScan.pages) {
|
|
124
|
-
if (!page.island) continue;
|
|
125
|
-
if (page.componentPath.endsWith(".svelte")) islandCssFiles.add(normalizePath(resolve(pagesDir, page.componentPath)));
|
|
126
|
-
const layoutChain = resolveEffectiveLayoutChain(page, pageScan.layouts, pageScan.namedLayouts);
|
|
127
|
-
for (const layout of layoutChain) islandCssFiles.add(normalizePath(resolve(pagesDir, layout.filePath)));
|
|
128
|
-
}
|
|
95
|
+
islandManifest.registerPages(pageScan, pagesDir);
|
|
96
|
+
cssFiles = islandCssFiles(pageScan, pagesDir, /\.svelte$/);
|
|
129
97
|
}
|
|
130
98
|
},
|
|
131
99
|
buildEnd() {
|
|
@@ -143,14 +111,21 @@ function islandsPlugin(pagesDir, options) {
|
|
|
143
111
|
handler(id) {
|
|
144
112
|
if (id === "\0" + ISLANDS_RENDERER_ID) {
|
|
145
113
|
if (!pageScan) return "export function renderIslandPage() { throw new Error('No pages found'); }";
|
|
146
|
-
const
|
|
147
|
-
const mdScriptIds = new Set(mdPlugin?.api?.getClientScripts?.()?.keys() ?? []);
|
|
114
|
+
const mdScriptIds = mdClientScriptIds(resolvedConfig);
|
|
148
115
|
const needsJS = computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig);
|
|
149
116
|
return generateIslandRenderer(pageScan, pagesDir, mdScriptIds, needsJS);
|
|
150
117
|
}
|
|
151
118
|
if (id === "\0" + ISLANDS_CLIENT_ENTRY_ID) {
|
|
152
119
|
if (!pageScan) return "";
|
|
153
|
-
return generateIslandClientEntry(
|
|
120
|
+
return generateIslandClientEntry({
|
|
121
|
+
manifest: islandManifest,
|
|
122
|
+
cssFiles,
|
|
123
|
+
styles: recordedIslandStyles(pagesDir),
|
|
124
|
+
needsRuntime: islandManifest.entries.size > 0 || computePagesNeedingClientJS(pageScan, pagesDir, resolvedConfig).size > 0,
|
|
125
|
+
isDev,
|
|
126
|
+
styleBlockImports,
|
|
127
|
+
hydrateIsland: HYDRATE_ISLAND
|
|
128
|
+
});
|
|
154
129
|
}
|
|
155
130
|
if (id.startsWith(WRAPPER_PREFIX)) return generateWrapperComponent(id);
|
|
156
131
|
}
|
|
@@ -165,7 +140,7 @@ function islandsPlugin(pagesDir, options) {
|
|
|
165
140
|
const name = islandImport.importName;
|
|
166
141
|
const spec = islandImport.specifier;
|
|
167
142
|
const strategy = islandImport.hydrate;
|
|
168
|
-
const { absPath, islandId } =
|
|
143
|
+
const { absPath, islandId } = islandManifest.register(spec, importerDir, strategy);
|
|
169
144
|
const wrapperUrl = WRAPPER_PREFIX + encodeURIComponent(islandId) + ".svelte?src=" + encodeURIComponent(absPath || spec) + "&island=" + encodeURIComponent(islandId) + "&hydrate=" + encodeURIComponent(strategy);
|
|
170
145
|
const replacement = `import ${name} from ${JSON.stringify(wrapperUrl)}`;
|
|
171
146
|
edits.push({
|
|
@@ -180,10 +155,7 @@ function islandsPlugin(pagesDir, options) {
|
|
|
180
155
|
async config(_, env) {
|
|
181
156
|
if (!existsSync(pagesDir)) return;
|
|
182
157
|
if (env.command === "serve") return { optimizeDeps: { include: ["svelte"] } };
|
|
183
|
-
|
|
184
|
-
manifest: true,
|
|
185
|
-
rollupOptions: { input: { "islands-client": ISLANDS_CLIENT_ENTRY_ID } }
|
|
186
|
-
} } } };
|
|
158
|
+
return islandsBuildConfig(root, pagesDir);
|
|
187
159
|
}
|
|
188
160
|
},
|
|
189
161
|
{
|
|
@@ -298,37 +270,7 @@ export async function renderIslandPage(pageObj, assetTags) {
|
|
|
298
270
|
}
|
|
299
271
|
`;
|
|
300
272
|
}
|
|
301
|
-
|
|
302
|
-
const entries = [...manifest.values()];
|
|
303
|
-
const uniqueIslands = /* @__PURE__ */ new Map();
|
|
304
|
-
for (const entry of entries) uniqueIslands.set(entry.islandId, entry.componentPath);
|
|
305
|
-
const lines = [];
|
|
306
|
-
if (!isDev && styles) for (const id of styles) lines.push(`import ${JSON.stringify(id)};`);
|
|
307
|
-
else if (!isDev) for (const file of cssFiles) {
|
|
308
|
-
const content = readFileSync(file, "utf-8");
|
|
309
|
-
if (/<style\b/.test(content)) lines.push(`import ${JSON.stringify(file + ".css?island-style")};`);
|
|
310
|
-
for (const item of findStaticCssImports(content, file)) lines.push(rewriteCssImportForVirtualModule(item.statement, item.specifier, file));
|
|
311
|
-
}
|
|
312
|
-
if (!needsRuntime && !isDev) return lines.join("\n");
|
|
313
|
-
lines.push("import { initIslands } from \"void/pages-client\";");
|
|
314
|
-
lines.push("const islands = {");
|
|
315
|
-
for (const [id, path] of uniqueIslands) lines.push(` ${JSON.stringify(id)}: () => import(${JSON.stringify(path)}),`);
|
|
316
|
-
lines.push("};");
|
|
317
|
-
lines.push(`
|
|
318
|
-
async function hydrateIsland(el, mod, props, frontmatter) {
|
|
319
|
-
const { hydrate } = await import("svelte");
|
|
320
|
-
const Component = mod.default || mod;
|
|
321
|
-
const context = new Map([["__void_frontmatter", frontmatter]]);
|
|
322
|
-
hydrate(Component, { target: el, props, context });
|
|
323
|
-
el.setAttribute("data-hydrated", "true");
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
initIslands(islands, hydrateIsland);
|
|
327
|
-
`);
|
|
328
|
-
return lines.join("\n");
|
|
329
|
-
}
|
|
330
|
-
//#endregion
|
|
331
|
-
//#region src/plugin-pages.ts
|
|
273
|
+
// src/plugin-pages.ts
|
|
332
274
|
const CLIENT_ENTRY_ID = "virtual:void-pages-client";
|
|
333
275
|
const SVELTE_OPTIMIZE_DEPS = {
|
|
334
276
|
exclude: ["@void/svelte"],
|
|
@@ -387,7 +329,7 @@ import { serializePageData } from "void/pages-server";
|
|
|
387
329
|
|
|
388
330
|
${generateComponentManifest(scan, pagesDir)}
|
|
389
331
|
|
|
390
|
-
export async function renderPage(pageObj, assetTags) {
|
|
332
|
+
export async function renderPage(pageObj, assetTags, requestUrl) {
|
|
391
333
|
const PageComponent = (await components[pageObj.component]()).default;
|
|
392
334
|
const layoutIds = layoutTree[pageObj.component] || [];
|
|
393
335
|
const layoutComponents = await Promise.all(
|
|
@@ -401,7 +343,7 @@ export async function renderPage(pageObj, assetTags) {
|
|
|
401
343
|
pageProps: pageObj.props,
|
|
402
344
|
shared: pageObj.shared || {},
|
|
403
345
|
params: pageObj.params || {},
|
|
404
|
-
router: createSsrRouter(pageObj.url || "/", pageObj.params || {}),
|
|
346
|
+
router: createSsrRouter(pageObj.url || "/", pageObj.params || {}, requestUrl),
|
|
405
347
|
routeUrl: pageObj.url || "/",
|
|
406
348
|
navigation: idleNavigationState(),
|
|
407
349
|
stores: null,
|
|
@@ -426,6 +368,7 @@ export async function renderPage(pageObj, assetTags) {
|
|
|
426
368
|
`;
|
|
427
369
|
}
|
|
428
370
|
function generateClientEntry(scan, pagesDir, viewTransitions, prefetch, staticPageData) {
|
|
371
|
+
const { defaultStrategy, hoverDelay, cacheFor } = resolvePrefetchConfig(prefetch);
|
|
429
372
|
return `
|
|
430
373
|
import { hydrate, mount, tick } from "svelte";
|
|
431
374
|
import { writable } from "svelte/store";
|
|
@@ -469,8 +412,9 @@ const { router, initialPageData, initialComponent, initialLayouts, start, commit
|
|
|
469
412
|
staticPageData: ${!!staticPageData?.enabled},
|
|
470
413
|
staticPageDataFastPath: ${!!staticPageData?.fastPath},
|
|
471
414
|
viewTransitions: ${!!viewTransitions},
|
|
472
|
-
prefetchHoverDelay: ${
|
|
473
|
-
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(
|
|
415
|
+
prefetchHoverDelay: ${hoverDelay},
|
|
416
|
+
prefetchDefaultCacheFor: parseCacheFor(${JSON.stringify(cacheFor)}),
|
|
417
|
+
prefetchDefaultStrategy: ${JSON.stringify(defaultStrategy)},
|
|
474
418
|
onRouteChange(url) {
|
|
475
419
|
routeUrl = url;
|
|
476
420
|
routeStore.set(url);
|
|
@@ -528,8 +472,7 @@ commitInitialRender();
|
|
|
528
472
|
appEl.setAttribute("data-hydrated", "true");
|
|
529
473
|
`;
|
|
530
474
|
}
|
|
531
|
-
|
|
532
|
-
//#region src/plugin.ts
|
|
475
|
+
// src/plugin.ts
|
|
533
476
|
function voidSvelte(options) {
|
|
534
477
|
const root = process.cwd();
|
|
535
478
|
const pagesDir = resolvePagesDir(root);
|
|
@@ -543,5 +486,4 @@ function voidSvelte(options) {
|
|
|
543
486
|
...islandsPlugin(pagesDir, { root })
|
|
544
487
|
];
|
|
545
488
|
}
|
|
546
|
-
//#endregion
|
|
547
489
|
export { voidSvelte };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@void/svelte",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Svelte pages, server rendering, forms, and navigation for Void.",
|
|
5
5
|
"homepage": "https://void.cloud",
|
|
6
6
|
"bugs": {
|
|
@@ -38,17 +38,17 @@
|
|
|
38
38
|
"@sveltejs/vite-plugin-svelte": "^7.3.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@types/node": "^26.6.
|
|
41
|
+
"@types/node": "^26.6.6",
|
|
42
42
|
"pathslash": "^0.1.0",
|
|
43
43
|
"svelte": "^5.57.2",
|
|
44
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@1.
|
|
45
|
-
"vite-plus": "1.
|
|
46
|
-
"void": "1.
|
|
44
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.4.0",
|
|
45
|
+
"vite-plus": "1.4.0",
|
|
46
|
+
"void": "1.6.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"svelte": "^5.0.0",
|
|
50
50
|
"vite": "^8.0.0",
|
|
51
|
-
"void": "1.
|
|
51
|
+
"void": "1.6.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
54
54
|
"build": "vp pack",
|
package/src/runtime/App.svelte
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
let params = $state(init.params || {});
|
|
29
29
|
let routeUrl = $state(init.routeUrl || "/");
|
|
30
30
|
let navigation = $state(init.navigation || idleNavigationState());
|
|
31
|
-
const router = createRouterFacade(init.router, () => routeUrl, () => params);
|
|
31
|
+
const router = init.router ? createRouterFacade(init.router, () => routeUrl, () => params) : null;
|
|
32
32
|
|
|
33
33
|
// Pages and layouts keep the objects returned by useShared() and useParams()
|
|
34
34
|
// across navigations, so update them in place.
|
package/src/runtime/Link.svelte
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { getContext, onDestroy, onMount
|
|
2
|
+
import { getContext, onDestroy, onMount } from "svelte";
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
handleLinkClick,
|
|
5
|
+
linkActiveAttributes,
|
|
6
|
+
linkPendingAttributes,
|
|
7
|
+
linkPrefetchStrategies,
|
|
8
|
+
observeLinkVisible,
|
|
9
|
+
prefetchLink,
|
|
10
|
+
resolveLink,
|
|
11
|
+
type NavigationState,
|
|
7
12
|
type VoidRouter,
|
|
8
13
|
} from "void/pages-client";
|
|
9
|
-
|
|
10
|
-
type PrefetchStrategy = "hover" | "click" | "mount" | "visible";
|
|
11
|
-
type LinkMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
12
|
-
type NavigateEvent = { preventDefault(): void };
|
|
13
|
-
type EventHandler<T extends Event> = (event: T) => void;
|
|
14
|
+
import type { LinkProps } from "./link-props.ts";
|
|
14
15
|
|
|
15
16
|
let {
|
|
16
17
|
href,
|
|
@@ -21,8 +22,9 @@
|
|
|
21
22
|
replace = false,
|
|
22
23
|
reloadDocument = false,
|
|
23
24
|
viewTransition = undefined,
|
|
24
|
-
prefetch: prefetchProp =
|
|
25
|
+
prefetch: prefetchProp = undefined,
|
|
25
26
|
cacheFor = undefined,
|
|
27
|
+
activeMatch = undefined,
|
|
26
28
|
onNavigate = undefined,
|
|
27
29
|
children,
|
|
28
30
|
onclick: onclickProp = undefined,
|
|
@@ -33,104 +35,30 @@
|
|
|
33
35
|
ontouchstart: onTouchStartProp = undefined,
|
|
34
36
|
style = undefined,
|
|
35
37
|
...rest
|
|
36
|
-
}:
|
|
37
|
-
href: string;
|
|
38
|
-
method?: LinkMethod | Lowercase<LinkMethod>;
|
|
39
|
-
data?: Record<string, unknown>;
|
|
40
|
-
preserveScroll?: boolean;
|
|
41
|
-
preserveState?: boolean;
|
|
42
|
-
replace?: boolean;
|
|
43
|
-
reloadDocument?: boolean;
|
|
44
|
-
viewTransition?: boolean;
|
|
45
|
-
prefetch?: boolean | PrefetchStrategy | Array<PrefetchStrategy>;
|
|
46
|
-
cacheFor?: number | string | [string, string];
|
|
47
|
-
onNavigate?: (event: NavigateEvent) => void;
|
|
48
|
-
children?: Snippet;
|
|
49
|
-
onclick?: EventHandler<MouseEvent>;
|
|
50
|
-
onmouseenter?: EventHandler<MouseEvent>;
|
|
51
|
-
onmouseleave?: EventHandler<MouseEvent>;
|
|
52
|
-
onfocus?: EventHandler<FocusEvent>;
|
|
53
|
-
onmousedown?: EventHandler<MouseEvent>;
|
|
54
|
-
ontouchstart?: EventHandler<TouchEvent>;
|
|
55
|
-
style?: string;
|
|
56
|
-
[key: string]: unknown;
|
|
57
|
-
} = $props();
|
|
38
|
+
}: LinkProps = $props();
|
|
58
39
|
|
|
59
40
|
const router = getContext<VoidRouter>("__void_router");
|
|
60
|
-
|
|
61
|
-
function getStrategies(): Array<PrefetchStrategy> {
|
|
62
|
-
if (prefetchProp !== false && !isGet) {
|
|
63
|
-
throw new Error(
|
|
64
|
-
'Link: prefetch only supports GET links. Remove `prefetch` or use method="GET".',
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
if (reloadDocument && !isGet) {
|
|
68
|
-
throw new Error("Link: reloadDocument only supports GET links.");
|
|
69
|
-
}
|
|
70
|
-
if (prefetchProp === false || reloadDocument) {
|
|
71
|
-
return [];
|
|
72
|
-
}
|
|
73
|
-
if (prefetchProp === true) {
|
|
74
|
-
return ["hover"];
|
|
75
|
-
}
|
|
76
|
-
if (typeof prefetchProp === "string") {
|
|
77
|
-
return [prefetchProp];
|
|
78
|
-
}
|
|
79
|
-
return prefetchProp;
|
|
80
|
-
}
|
|
41
|
+
const navigation = getContext<{ value: NavigationState } | undefined>("__void_navigation");
|
|
81
42
|
|
|
82
43
|
function doPrefetch() {
|
|
83
|
-
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
const current = window.location.pathname + window.location.search;
|
|
87
|
-
if (hrefWithData === current) {
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
const options: PrefetchOptions = { method: normalizedMethod };
|
|
91
|
-
if (cacheFor !== undefined) {
|
|
92
|
-
options.cacheFor = cacheFor;
|
|
93
|
-
}
|
|
94
|
-
router.prefetch(hrefWithData, options);
|
|
44
|
+
prefetchLink(router, link.href, cacheFor);
|
|
95
45
|
}
|
|
96
46
|
|
|
97
47
|
function onClick(e: MouseEvent) {
|
|
98
48
|
onclickProp?.(e);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
if ((e.currentTarget as HTMLElement).hasAttribute("download")) {
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
const url = new URL(hrefWithData, window.location.origin);
|
|
106
|
-
if (url.origin !== window.location.origin) {
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
let navigatePrevented = false;
|
|
111
|
-
onNavigate?.({
|
|
112
|
-
preventDefault() {
|
|
113
|
-
navigatePrevented = true;
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
if (navigatePrevented) {
|
|
117
|
-
e.preventDefault();
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
e.preventDefault();
|
|
122
|
-
router.visit(hrefWithData, {
|
|
123
|
-
method: normalizedMethod,
|
|
124
|
-
data: isGet ? undefined : data,
|
|
49
|
+
handleLinkClick(e, router, link, {
|
|
50
|
+
data,
|
|
125
51
|
preserveScroll,
|
|
126
52
|
preserveState,
|
|
127
53
|
replace,
|
|
54
|
+
reloadDocument,
|
|
128
55
|
viewTransition,
|
|
56
|
+
onNavigate,
|
|
129
57
|
});
|
|
130
58
|
}
|
|
131
59
|
|
|
132
60
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
133
|
-
let
|
|
61
|
+
let stopObserving: (() => void) | null = null;
|
|
134
62
|
let el = $state<HTMLElement | null>(null);
|
|
135
63
|
|
|
136
64
|
function onMouseEnter(e: MouseEvent) {
|
|
@@ -178,17 +106,7 @@
|
|
|
178
106
|
doPrefetch();
|
|
179
107
|
}
|
|
180
108
|
if (strategies.includes("visible") && el) {
|
|
181
|
-
|
|
182
|
-
(entries) => {
|
|
183
|
-
if (entries.some((e) => e.isIntersecting)) {
|
|
184
|
-
doPrefetch();
|
|
185
|
-
observer?.disconnect();
|
|
186
|
-
observer = null;
|
|
187
|
-
}
|
|
188
|
-
},
|
|
189
|
-
{ rootMargin: "0px" },
|
|
190
|
-
);
|
|
191
|
-
observer.observe(el);
|
|
109
|
+
stopObserving = observeLinkVisible(el, doPrefetch);
|
|
192
110
|
}
|
|
193
111
|
});
|
|
194
112
|
|
|
@@ -196,25 +114,34 @@
|
|
|
196
114
|
if (hoverTimer) {
|
|
197
115
|
clearTimeout(hoverTimer);
|
|
198
116
|
}
|
|
199
|
-
|
|
200
|
-
observer.disconnect();
|
|
201
|
-
observer = null;
|
|
202
|
-
}
|
|
117
|
+
stopObserving?.();
|
|
203
118
|
});
|
|
204
119
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
const strategies = $derived(
|
|
120
|
+
const link = $derived(
|
|
121
|
+
resolveLink({ href, method, data, prefetch: prefetchProp, reloadDocument }),
|
|
122
|
+
);
|
|
123
|
+
const strategies = $derived(
|
|
124
|
+
linkPrefetchStrategies(
|
|
125
|
+
{ prefetch: prefetchProp, reloadDocument },
|
|
126
|
+
link,
|
|
127
|
+
router?._prefetchDefaultStrategy,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
// Reading router.url tracks the route URL, so this follows navigations.
|
|
131
|
+
const activeAttributes = $derived(linkActiveAttributes(router, link, activeMatch));
|
|
132
|
+
// Reading navigation.value tracks visits as they start and end.
|
|
133
|
+
const pendingAttributes = $derived(linkPendingAttributes(router, navigation?.value, link));
|
|
209
134
|
const resolvedStyle = $derived(
|
|
210
135
|
style ? `touch-action: manipulation; ${style}` : "touch-action: manipulation",
|
|
211
136
|
);
|
|
212
137
|
</script>
|
|
213
138
|
|
|
214
|
-
{#if isGet}
|
|
139
|
+
{#if link.isGet}
|
|
215
140
|
<a
|
|
216
141
|
bind:this={el}
|
|
217
|
-
href={
|
|
142
|
+
href={link.href}
|
|
143
|
+
{...activeAttributes}
|
|
144
|
+
{...pendingAttributes}
|
|
218
145
|
{...rest}
|
|
219
146
|
style={resolvedStyle}
|
|
220
147
|
onclick={onClick}
|
|
@@ -232,6 +159,7 @@
|
|
|
232
159
|
<button
|
|
233
160
|
bind:this={el}
|
|
234
161
|
type="button"
|
|
162
|
+
{...pendingAttributes}
|
|
235
163
|
{...rest}
|
|
236
164
|
style={resolvedStyle}
|
|
237
165
|
onclick={onClick}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { BaseLinkProps } from 'void/pages-client';
|
|
3
|
+
|
|
4
|
+
type EventHandler<T extends Event> = (event: T) => void;
|
|
5
|
+
|
|
6
|
+
export type LinkProps = BaseLinkProps & {
|
|
7
|
+
children?: Snippet;
|
|
8
|
+
onclick?: EventHandler<MouseEvent>;
|
|
9
|
+
onmouseenter?: EventHandler<MouseEvent>;
|
|
10
|
+
onmouseleave?: EventHandler<MouseEvent>;
|
|
11
|
+
onfocus?: EventHandler<FocusEvent>;
|
|
12
|
+
onmousedown?: EventHandler<MouseEvent>;
|
|
13
|
+
ontouchstart?: EventHandler<TouchEvent>;
|
|
14
|
+
style?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
@@ -9,65 +9,32 @@ import {
|
|
|
9
9
|
parseFormResetArgs,
|
|
10
10
|
submitAction,
|
|
11
11
|
type FormResetOptions,
|
|
12
|
-
type
|
|
12
|
+
type FormState,
|
|
13
|
+
type FormSubmitOptions,
|
|
13
14
|
type VoidRouter,
|
|
14
15
|
} from 'void/pages-client';
|
|
15
16
|
import type { ActionUrl, ResolveActionBody, ActionFormOptions } from 'void/routes';
|
|
16
17
|
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
readonly pending: boolean;
|
|
25
|
-
readonly hasChanges: boolean;
|
|
26
|
-
readonly wasSuccessful: boolean;
|
|
27
|
-
readonly recentlySuccessful: boolean;
|
|
28
|
-
post(opts?: FormSubmitOptions): Promise<void>;
|
|
29
|
-
put(opts?: FormSubmitOptions): Promise<void>;
|
|
30
|
-
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
31
|
-
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
32
|
-
reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
|
|
33
|
-
reset(...fields: ReadonlyArray<string>): void;
|
|
34
|
-
setError(field: string, message: string): void;
|
|
35
|
-
clearErrors(...fields: ReadonlyArray<string>): void;
|
|
36
|
-
clearError(): void;
|
|
37
|
-
};
|
|
18
|
+
type FormReturn<TData> = FormState<TData, 'in-place'> &
|
|
19
|
+
Readonly<{
|
|
20
|
+
post(opts?: FormSubmitOptions): Promise<void>;
|
|
21
|
+
put(opts?: FormSubmitOptions): Promise<void>;
|
|
22
|
+
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
23
|
+
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
24
|
+
}>;
|
|
38
25
|
|
|
39
26
|
// Typed overload — full inference from PageActionMap (requires codegen)
|
|
40
27
|
export function useForm<U extends ActionUrl & string>(
|
|
41
28
|
url: U,
|
|
42
29
|
defaults: ResolveActionBody<U>,
|
|
43
30
|
options?: ActionFormOptions<U>,
|
|
44
|
-
):
|
|
45
|
-
readonly data: ResolveActionBody<U>;
|
|
46
|
-
readonly errors: Partial<Record<keyof ResolveActionBody<U> & string, string>>;
|
|
47
|
-
readonly error: VoidActionError | null;
|
|
48
|
-
readonly pending: boolean;
|
|
49
|
-
readonly hasChanges: boolean;
|
|
50
|
-
readonly wasSuccessful: boolean;
|
|
51
|
-
readonly recentlySuccessful: boolean;
|
|
52
|
-
post(opts?: FormSubmitOptions): Promise<void>;
|
|
53
|
-
put(opts?: FormSubmitOptions): Promise<void>;
|
|
54
|
-
patch(opts?: FormSubmitOptions): Promise<void>;
|
|
55
|
-
delete(opts?: FormSubmitOptions): Promise<void>;
|
|
56
|
-
reset(
|
|
57
|
-
options: FormResetOptions,
|
|
58
|
-
...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
|
|
59
|
-
): void;
|
|
60
|
-
reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
|
|
61
|
-
setError(field: keyof ResolveActionBody<U> & string, message: string): void;
|
|
62
|
-
clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
|
|
63
|
-
clearError(): void;
|
|
64
|
-
};
|
|
31
|
+
): FormReturn<ResolveActionBody<U>>;
|
|
65
32
|
|
|
66
33
|
export function useForm(
|
|
67
34
|
url: string,
|
|
68
35
|
defaults: Record<string, unknown>,
|
|
69
36
|
options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
|
|
70
|
-
):
|
|
37
|
+
): FormReturn<Record<string, unknown>> {
|
|
71
38
|
const router = getContext<VoidRouter>('__void_router');
|
|
72
39
|
|
|
73
40
|
const initialDefaults = cloneFormValue(defaults);
|
|
@@ -95,6 +62,26 @@ export function useForm(
|
|
|
95
62
|
});
|
|
96
63
|
});
|
|
97
64
|
|
|
65
|
+
// An input handler may validate its new value before the change effect runs.
|
|
66
|
+
function snapshotField(field: string) {
|
|
67
|
+
untrack(() => {
|
|
68
|
+
dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Handlers may also write `form.errors.field` directly, which needs the same snapshot.
|
|
73
|
+
// The view is derived because setError and clearErrors replace `errors`.
|
|
74
|
+
const errorsView = $derived(
|
|
75
|
+
new Proxy(errors, {
|
|
76
|
+
set(target, field, message) {
|
|
77
|
+
if (typeof field === 'string') {
|
|
78
|
+
snapshotField(field);
|
|
79
|
+
}
|
|
80
|
+
return Reflect.set(target, field, message);
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
|
|
98
85
|
const hasChanges = $derived(
|
|
99
86
|
Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
|
|
100
87
|
);
|
|
@@ -123,8 +110,7 @@ export function useForm(
|
|
|
123
110
|
}
|
|
124
111
|
|
|
125
112
|
function setError(field: string, message: string) {
|
|
126
|
-
|
|
127
|
-
dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
|
|
113
|
+
snapshotField(field);
|
|
128
114
|
errors = { ...errors, [field]: message };
|
|
129
115
|
}
|
|
130
116
|
|
|
@@ -189,7 +175,7 @@ export function useForm(
|
|
|
189
175
|
return data;
|
|
190
176
|
},
|
|
191
177
|
get errors() {
|
|
192
|
-
return
|
|
178
|
+
return errorsView;
|
|
193
179
|
},
|
|
194
180
|
get error() {
|
|
195
181
|
return error;
|
|
@@ -1,29 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
isEqualFormValue,
|
|
3
|
+
submitIslandAction,
|
|
4
|
+
type IslandFormState,
|
|
5
|
+
type VoidActionError,
|
|
6
|
+
} from 'void/pages-client';
|
|
2
7
|
|
|
3
|
-
type
|
|
4
|
-
|
|
5
|
-
readonly errors: Record<string, string>;
|
|
6
|
-
readonly error: VoidActionError | null;
|
|
7
|
-
readonly pending: boolean;
|
|
8
|
-
readonly hasChanges: boolean;
|
|
9
|
-
readonly wasSuccessful: boolean;
|
|
10
|
-
readonly recentlySuccessful: boolean;
|
|
11
|
-
submit(url: string, method: string): Promise<void>;
|
|
12
|
-
post(url: string): Promise<void>;
|
|
13
|
-
put(url: string): Promise<void>;
|
|
14
|
-
patch(url: string): Promise<void>;
|
|
15
|
-
delete(url: string): Promise<void>;
|
|
16
|
-
reset(...fields: ReadonlyArray<string>): void;
|
|
17
|
-
clearErrors(...fields: ReadonlyArray<string>): void;
|
|
18
|
-
clearError(): void;
|
|
19
|
-
};
|
|
8
|
+
export type IslandFormReturn<T extends Record<string, unknown>> = IslandFormState<T, 'in-place'> &
|
|
9
|
+
Readonly<{ submit(url: string, method: string): Promise<void> }>;
|
|
20
10
|
|
|
21
11
|
/**
|
|
22
12
|
* Form composable for island pages. Uses fetch + page reload instead of the
|
|
23
13
|
* Void Router (which is not available in island mode).
|
|
24
14
|
*/
|
|
25
|
-
export function useIslandForm
|
|
26
|
-
let data:
|
|
15
|
+
export function useIslandForm<T extends Record<string, unknown>>(defaults: T): IslandFormReturn<T> {
|
|
16
|
+
let data: T = $state({ ...defaults });
|
|
27
17
|
let errors: Record<string, string> = $state({});
|
|
28
18
|
let error: VoidActionError | null = $state(null);
|
|
29
19
|
let pending = $state(false);
|
|
@@ -37,7 +27,7 @@ export function useIslandForm(defaults: Record<string, unknown>): UntypedIslandF
|
|
|
37
27
|
Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
|
|
38
28
|
);
|
|
39
29
|
|
|
40
|
-
function reset(...fields: ReadonlyArray<string>) {
|
|
30
|
+
function reset(...fields: ReadonlyArray<keyof T & string>) {
|
|
41
31
|
if (fields.length === 0) {
|
|
42
32
|
data = { ...defaults_ };
|
|
43
33
|
} else {
|