@void/svelte 1.2.1 → 1.4.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.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { svelte } from "@sveltejs/vite-plugin-svelte";
2
- import { generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir, resolveStaticPageDataBuildOptions, scanPages } from "void/pages";
2
+ import { createPagesPlugin, generateComponentManifest, resolveEffectiveLayoutChain, resolvePagesDir, 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";
@@ -95,7 +95,10 @@ function islandsPlugin(pagesDir, options) {
95
95
  },
96
96
  async buildStart() {
97
97
  if (existsSync(pagesDir)) {
98
- pageScan = await scanPages(root, { pagesDir });
98
+ pageScan = await scanPages(root, {
99
+ pagesDir,
100
+ environment: isDev ? "dev" : "prod"
101
+ });
99
102
  const filesToScan = [
100
103
  ...pageScan.pages.map((page) => ({
101
104
  filePath: page.componentPath,
@@ -326,7 +329,6 @@ initIslands(islands, hydrateIsland);
326
329
  }
327
330
  //#endregion
328
331
  //#region src/plugin-pages.ts
329
- const RENDERER_ID = "virtual:void-pages-renderer";
330
332
  const CLIENT_ENTRY_ID = "virtual:void-pages-client";
331
333
  const SVELTE_OPTIMIZE_DEPS = {
332
334
  exclude: ["@void/svelte"],
@@ -353,92 +355,27 @@ function hasNonIslandPages(dir) {
353
355
  return false;
354
356
  }
355
357
  function pagesPlugin(pagesDir, options) {
356
- let pageScan = null;
357
- let isDev = false;
358
- let viteConfig;
359
- const root = options?.root ?? dirname(pagesDir);
360
- const normalizedPagesDir = normalizePath(pagesDir);
361
- let pendingRescan = null;
362
- const rescanPages = async () => {
363
- pageScan = existsSync(pagesDir) ? await scanPages(root, { pagesDir }) : null;
364
- };
365
- const runRescan = () => {
366
- pendingRescan ??= rescanPages().finally(() => {
367
- pendingRescan = null;
368
- });
369
- return pendingRescan;
370
- };
371
- const invalidatePageModules = (server) => {
372
- for (const env of Object.values(server.environments)) for (const id of ["\0" + RENDERER_ID, "\0" + CLIENT_ENTRY_ID]) {
373
- const mod = env.moduleGraph.getModuleById(id);
374
- if (mod) env.moduleGraph.invalidateModule(mod);
375
- }
376
- };
377
- return {
358
+ return createPagesPlugin(pagesDir, {
378
359
  name: "void-svelte:pages",
379
- async buildStart() {
380
- await runRescan();
381
- },
382
- resolveId: {
383
- filter: { id: /^virtual:void-pages-(renderer|client)$/ },
384
- handler(id) {
385
- return "\0" + id;
386
- }
360
+ root: options?.root,
361
+ generateRenderer(scan) {
362
+ return generateRendererModule(scan, pagesDir);
387
363
  },
388
- load: {
389
- filter: { id: /^\0virtual:void-pages-(renderer|client)$/ },
390
- async handler(id) {
391
- if (isDev && pendingRescan) await pendingRescan;
392
- if (!pageScan && existsSync(pagesDir)) await runRescan();
393
- if (id === "\0" + RENDERER_ID) {
394
- if (!pageScan) return "export function renderPage() { throw new Error('No pages found'); }";
395
- return generateRendererModule(pageScan, pagesDir);
396
- }
397
- if (id === "\0" + CLIENT_ENTRY_ID) {
398
- if (!pageScan) return "";
399
- return generateClientEntry(pageScan, pagesDir, options?.viewTransitions, options?.prefetch, resolveStaticPageDataBuildOptions(root, { publicDir: viteConfig?.publicDir }));
400
- }
401
- }
364
+ generateClient(scan, staticPageData) {
365
+ return generateClientEntry(scan, pagesDir, options?.viewTransitions, options?.prefetch, staticPageData);
402
366
  },
403
- configureServer(server) {
404
- const handlePagesChange = async (path, event) => {
405
- const normalizedPath = normalizePath(path);
406
- if (normalizedPath !== normalizedPagesDir && !normalizedPath.startsWith(normalizedPagesDir + "/")) return;
407
- if (event === "change" && !normalizedPath.endsWith(".md")) return;
408
- try {
409
- await runRescan();
410
- } catch (error) {
411
- server.config.logger.error(error instanceof Error ? error.message : String(error), {
412
- error: error instanceof Error ? error : void 0,
413
- timestamp: true
414
- });
415
- return;
416
- }
417
- invalidatePageModules(server);
367
+ config({ isDev }) {
368
+ if (isDev) return {
369
+ optimizeDeps: SVELTE_OPTIMIZE_DEPS,
370
+ environments: { void_worker: { optimizeDeps: { exclude: ["@void/svelte"] } } }
418
371
  };
419
- server.watcher.on("add", (path) => handlePagesChange(path, "add"));
420
- server.watcher.on("change", (path) => handlePagesChange(path, "change"));
421
- server.watcher.on("unlink", (path) => handlePagesChange(path, "unlink"));
422
- },
423
- configResolved(config) {
424
- viteConfig = config;
425
- },
426
- config(_, env) {
427
- if (!existsSync(pagesDir)) return;
428
- if (env.command === "serve") {
429
- isDev = true;
430
- return {
431
- optimizeDeps: SVELTE_OPTIMIZE_DEPS,
432
- environments: { void_worker: { optimizeDeps: { exclude: ["@void/svelte"] } } }
433
- };
434
- }
435
372
  if (!hasNonIslandPages(pagesDir)) return;
436
373
  return { build: {
437
374
  manifest: true,
438
375
  rollupOptions: { input: { "pages-client": CLIENT_ENTRY_ID } }
439
376
  } };
440
377
  }
441
- };
378
+ });
442
379
  }
443
380
  function generateRendererModule(scan, pagesDir) {
444
381
  return `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/svelte",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Svelte pages, server rendering, forms, and navigation for Void.",
5
5
  "homepage": "https://void.cloud",
6
6
  "bugs": {
@@ -43,12 +43,12 @@
43
43
  "svelte": "^5.57.2",
44
44
  "vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
45
45
  "vite-plus": "1.3.0",
46
- "void": "1.2.1"
46
+ "void": "1.4.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "svelte": "^5.0.0",
50
50
  "vite": "^8.0.0",
51
- "void": "1.2.1"
51
+ "void": "1.4.0"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "vp pack",
@@ -30,12 +30,13 @@
30
30
  let navigation = $state(init.navigation || idleNavigationState());
31
31
  const router = createRouterFacade(init.router, () => routeUrl, () => params);
32
32
 
33
- // Layouts can keep the object returned by useShared() across navigations.
34
- function replaceShared(next) {
35
- for (const key of Object.keys(shared)) {
36
- delete shared[key];
33
+ // Pages and layouts keep the objects returned by useShared() and useParams()
34
+ // across navigations, so update them in place.
35
+ function replaceInPlace(target, next) {
36
+ for (const key of Object.keys(target)) {
37
+ delete target[key];
37
38
  }
38
- Object.assign(shared, next || {});
39
+ Object.assign(target, next || {});
39
40
  }
40
41
 
41
42
  // Capitalized alias for dynamic component rendering
@@ -65,8 +66,8 @@
65
66
  init.stores.page.subscribe((v) => { currentPage = v; }),
66
67
  init.stores.props.subscribe((v) => { currentPageProps = v; }),
67
68
  init.stores.layouts.subscribe((v) => { currentLayouts = v; }),
68
- init.stores.shared.subscribe(replaceShared),
69
- init.stores.params.subscribe((v) => { params = v; }),
69
+ init.stores.shared.subscribe((v) => replaceInPlace(shared, v)),
70
+ init.stores.params.subscribe((v) => replaceInPlace(params, v)),
70
71
  init.stores.route.subscribe((v) => { routeUrl = v; }),
71
72
  init.stores.navigation.subscribe((v) => { navigation = v; }),
72
73
  ];
@@ -1,11 +1,14 @@
1
- import { getContext } from 'svelte';
1
+ import { getContext, untrack } from 'svelte';
2
2
  import {
3
3
  VoidActionError,
4
+ cloneFormValue,
4
5
  getValidationErrors,
5
6
  interpolateRouteParams,
6
7
  isAbortError,
7
8
  isEqualFormValue,
9
+ parseFormResetArgs,
8
10
  submitAction,
11
+ type FormResetOptions,
9
12
  type VisitOptions,
10
13
  type VoidRouter,
11
14
  } from 'void/pages-client';
@@ -26,7 +29,9 @@ type UntypedForm = {
26
29
  put(opts?: FormSubmitOptions): Promise<void>;
27
30
  patch(opts?: FormSubmitOptions): Promise<void>;
28
31
  delete(opts?: FormSubmitOptions): Promise<void>;
32
+ reset(options: FormResetOptions, ...fields: ReadonlyArray<string>): void;
29
33
  reset(...fields: ReadonlyArray<string>): void;
34
+ setError(field: string, message: string): void;
30
35
  clearErrors(...fields: ReadonlyArray<string>): void;
31
36
  clearError(): void;
32
37
  };
@@ -48,7 +53,12 @@ export function useForm<U extends ActionUrl & string>(
48
53
  put(opts?: FormSubmitOptions): Promise<void>;
49
54
  patch(opts?: FormSubmitOptions): Promise<void>;
50
55
  delete(opts?: FormSubmitOptions): Promise<void>;
56
+ reset(
57
+ options: FormResetOptions,
58
+ ...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>
59
+ ): void;
51
60
  reset(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
61
+ setError(field: keyof ResolveActionBody<U> & string, message: string): void;
52
62
  clearErrors(...fields: ReadonlyArray<keyof ResolveActionBody<U> & string>): void;
53
63
  clearError(): void;
54
64
  };
@@ -56,32 +66,50 @@ export function useForm<U extends ActionUrl & string>(
56
66
  export function useForm(
57
67
  url: string,
58
68
  defaults: Record<string, unknown>,
59
- options?: { params?: Record<string, string> },
69
+ options?: { params?: Record<string, string>; resetOnSuccess?: boolean },
60
70
  ): UntypedForm {
61
71
  const router = getContext<VoidRouter>('__void_router');
62
72
 
63
- let data = $state({ ...defaults });
73
+ const initialDefaults = cloneFormValue(defaults);
74
+ let data = $state(cloneFormValue(initialDefaults));
64
75
  let errors: Record<string, string> = $state({});
65
76
  let error: VoidActionError | null = $state(null);
66
77
  let pending = $state(false);
67
78
  let wasSuccessful = $state(false);
68
79
  let recentlySuccessful = $state(false);
69
80
 
70
- let defaults_ = { ...defaults };
81
+ let defaults_ = $state.raw(initialDefaults);
71
82
  let successTimeout: ReturnType<typeof setTimeout> | null = null;
83
+ let dataSnapshot = cloneFormValue(initialDefaults);
84
+
85
+ $effect(() => {
86
+ // Read nested values even after an earlier edit, without subscribing to errors.
87
+ const current = cloneFormValue(data);
88
+ untrack(() => {
89
+ for (const key of new Set([...Object.keys(dataSnapshot), ...Object.keys(current)])) {
90
+ if (!isEqualFormValue(current[key], dataSnapshot[key])) {
91
+ delete errors[key];
92
+ }
93
+ }
94
+ dataSnapshot = current;
95
+ });
96
+ });
72
97
 
73
98
  const hasChanges = $derived(
74
99
  Object.keys(defaults_).some((key) => !isEqualFormValue(data[key], defaults_[key])),
75
100
  );
76
101
 
77
- function reset(...fields: ReadonlyArray<string>) {
102
+ function reset(...args: ReadonlyArray<string | FormResetOptions>) {
103
+ const { initial, fields } = parseFormResetArgs(args);
104
+ const source = initial ? initialDefaults : defaults_;
78
105
  if (fields.length === 0) {
79
- data = { ...defaults_ };
106
+ data = cloneFormValue(source);
80
107
  } else {
81
108
  for (const field of fields) {
82
- data[field] = defaults_[field];
109
+ data[field] = cloneFormValue(source[field]);
83
110
  }
84
111
  }
112
+ dataSnapshot = cloneFormValue(data);
85
113
  }
86
114
 
87
115
  function clearErrors(...fields: ReadonlyArray<string>) {
@@ -94,6 +122,12 @@ export function useForm(
94
122
  }
95
123
  }
96
124
 
125
+ function setError(field: string, message: string) {
126
+ // An input handler may validate its new value before the change effect runs.
127
+ dataSnapshot = { ...dataSnapshot, [field]: cloneFormValue(data[field]) };
128
+ errors = { ...errors, [field]: message };
129
+ }
130
+
97
131
  function clearError() {
98
132
  error = null;
99
133
  }
@@ -120,7 +154,9 @@ export function useForm(
120
154
  if (!result.ok) {
121
155
  const validationErrors = getValidationErrors(result.error.body);
122
156
  if (validationErrors) {
123
- Object.assign(errors, validationErrors);
157
+ for (const [field, message] of Object.entries(validationErrors)) {
158
+ setError(field, message);
159
+ }
124
160
  } else {
125
161
  error = result.error;
126
162
  }
@@ -129,7 +165,12 @@ export function useForm(
129
165
 
130
166
  wasSuccessful = true;
131
167
  recentlySuccessful = true;
132
- defaults_ = { ...data };
168
+ if (options?.resetOnSuccess) {
169
+ data = cloneFormValue(initialDefaults);
170
+ defaults_ = cloneFormValue(initialDefaults);
171
+ } else {
172
+ defaults_ = cloneFormValue(data);
173
+ }
133
174
  successTimeout = setTimeout(() => {
134
175
  recentlySuccessful = false;
135
176
  }, 2000);
@@ -166,6 +207,7 @@ export function useForm(
166
207
  return recentlySuccessful;
167
208
  },
168
209
  reset,
210
+ setError,
169
211
  clearErrors,
170
212
  clearError,
171
213
  post: (opts?: FormSubmitOptions) => submit('POST', opts),
@@ -1,9 +1,4 @@
1
- import {
2
- VoidActionError,
3
- categorizeActionError,
4
- getValidationErrors,
5
- isEqualFormValue,
6
- } from 'void/pages-client';
1
+ import { isEqualFormValue, submitIslandAction, type VoidActionError } from 'void/pages-client';
7
2
 
8
3
  type UntypedIslandForm = {
9
4
  readonly data: Record<string, unknown>;
@@ -23,19 +18,6 @@ type UntypedIslandForm = {
23
18
  clearError(): void;
24
19
  };
25
20
 
26
- async function readActionErrorBody(response: Response): Promise<unknown> {
27
- const contentType = response.headers.get('content-type') || '';
28
- try {
29
- if (contentType.includes('application/json')) {
30
- return await response.json();
31
- }
32
- const text = await response.text();
33
- return text || null;
34
- } catch {
35
- return null;
36
- }
37
- }
38
-
39
21
  /**
40
22
  * Form composable for island pages. Uses fetch + page reload instead of the
41
23
  * Void Router (which is not available in island mode).
@@ -90,18 +72,14 @@ export function useIslandForm(defaults: Record<string, unknown>): UntypedIslandF
90
72
  clearError();
91
73
 
92
74
  try {
93
- const response = await fetch(url, {
94
- method,
95
- headers: { 'Content-Type': 'application/json' },
96
- body: JSON.stringify(data),
97
- });
75
+ const result = await submitIslandAction(url, method, data);
98
76
 
99
- if (response.redirected) {
100
- window.location.href = response.url;
77
+ if (result.kind === 'redirect') {
78
+ window.location.href = result.url;
101
79
  return;
102
80
  }
103
81
 
104
- if (response.ok) {
82
+ if (result.kind === 'success') {
105
83
  wasSuccessful = true;
106
84
  recentlySuccessful = true;
107
85
  defaults_ = { ...data };
@@ -109,23 +87,10 @@ export function useIslandForm(defaults: Record<string, unknown>): UntypedIslandF
109
87
  recentlySuccessful = false;
110
88
  }, 2000);
111
89
  window.location.reload();
90
+ } else if (result.kind === 'validation') {
91
+ Object.assign(errors, result.errors);
112
92
  } else {
113
- const body = await readActionErrorBody(response);
114
- const actionError = new VoidActionError({
115
- body,
116
- status: response.status,
117
- statusText: response.statusText,
118
- url,
119
- });
120
- const validationErrors = response.status === 422 ? getValidationErrors(body) : null;
121
- if (validationErrors) {
122
- Object.assign(errors, validationErrors);
123
- return;
124
- }
125
- if (categorizeActionError(response.status) === 'boundary') {
126
- throw actionError;
127
- }
128
- error = actionError;
93
+ error = result.error;
129
94
  }
130
95
  } finally {
131
96
  pending = false;