From f410ee8e42fd61d0ca65e1654e2c1738f75004cd Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 2 Jun 2026 22:20:46 +0200 Subject: [PATCH 1/6] feat: implement key validation in router and enhance schema serialization --- examples/next/app/blogs/[blog]/page.val.ts | 8 +- examples/next/content/authors.val.ts | 1 + packages/core/src/schema/record.ts | 14 +- packages/core/src/schema/router.test.ts | 183 +++++++++++++++++++++ packages/core/src/schema/router.ts | 35 ++-- 5 files changed, 228 insertions(+), 13 deletions(-) diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts index f9544a619..2c3039708 100644 --- a/examples/next/app/blogs/[blog]/page.val.ts +++ b/examples/next/app/blogs/[blog]/page.val.ts @@ -15,7 +15,13 @@ const blogSchema = s.object({ export default c.define( "/app/blogs/[blog]/page.val.ts", - s.router(nextAppRouter, blogSchema), + s.router(nextAppRouter, blogSchema).render({ + as: "list", + select: ({ val }) => ({ + title: val.title, + subtitle: val.author, + }), + }), { "/blogs/blog2": { title: "Blog 2", diff --git a/examples/next/content/authors.val.ts b/examples/next/content/authors.val.ts index 347917eee..43fbcb362 100644 --- a/examples/next/content/authors.val.ts +++ b/examples/next/content/authors.val.ts @@ -20,6 +20,7 @@ export const schema = s select: ({ val }) => ({ title: val.name, subtitle: val.birthdate, + image: val.image, }), }); diff --git a/packages/core/src/schema/record.ts b/packages/core/src/schema/record.ts index b2f06dd0a..c0d8380e3 100644 --- a/packages/core/src/schema/record.ts +++ b/packages/core/src/schema/record.ts @@ -127,7 +127,19 @@ export class RecordSchema< } const routerValidations = this.getRouterValidations(path, src); if (routerValidations) { - return routerValidations; + for (const errPath in routerValidations) { + const p = errPath as SourcePath; + const errs = routerValidations[p]; + if (error) { + if (error[p]) { + error[p] = [...error[p], ...errs]; + } else { + error[p] = errs; + } + } else { + error = { [p]: errs }; + } + } } for (const customValidationError of customValidationErrors) { error = this.appendValidationError( diff --git a/packages/core/src/schema/router.test.ts b/packages/core/src/schema/router.test.ts index e0629c0fd..c0b138e54 100644 --- a/packages/core/src/schema/router.test.ts +++ b/packages/core/src/schema/router.test.ts @@ -1,6 +1,8 @@ import { initSchema } from "../initSchema"; import { router } from "./router"; import { nextAppRouter, ValRouter } from "../router"; +import { SourcePath } from "../val"; +import { deserializeSchema } from "./deserialize"; const s = initSchema(); @@ -91,4 +93,185 @@ describe("router", () => { expect(serialized.router).toBe("next-app-router"); expect(serialized.item.type).toBe("object"); }); + + describe("key validation", () => { + // Router that flags any urlPath starting with "/bad" + const strictMockRouter: ValRouter = { + getRouterId: () => "strict-mock-router", + validate: (_moduleFilePath, urlPaths) => + urlPaths + .filter((p) => p.startsWith("/bad")) + .map((p) => ({ + error: { + message: `URL path '${p}' is not a valid route`, + urlPath: p, + expectedPath: null, + }, + })), + }; + + it("should serialize describe on the key schema", () => { + const schema = s.router( + mockRouter, + s.string().describe("URL slug"), + s.object({ title: s.string() }), + ); + + const serialized = schema["executeSerialize"](); + expect(serialized.type).toBe("record"); + expect(serialized.router).toBe("test-router"); + expect(serialized.key).toBeDefined(); + expect(serialized.key?.type).toBe("string"); + if (serialized.key?.type === "string") { + expect(serialized.key.description).toBe("URL slug"); + } + }); + + it("should enforce maxLength on keys (with keyError flag)", () => { + const schema = s.router( + mockRouter, + s.string().maxLength(5), + s.object({ title: s.string() }), + ); + + const result = schema["executeValidate"]("/test.val.ts" as SourcePath, { + "/ok": { title: "Short" }, + "/way-too-long-key": { title: "Too long" }, + }); + + expect(result).not.toBe(false); + if (result !== false) { + const allErrors = Object.values(result).flat(); + const lengthErrors = allErrors.filter( + (e) => e.keyError && e.message.includes("at most 5"), + ); + expect(lengthErrors).toHaveLength(1); + } + }); + + it("should run custom validator on keys", () => { + const schema = s.router( + mockRouter, + s + .string() + .validate((key) => + key.startsWith("/") ? false : "Key must start with '/'", + ), + s.object({ title: s.string() }), + ); + + const result = schema["executeValidate"]("/test.val.ts" as SourcePath, { + "/good": { title: "Good" }, + "bad-no-slash": { title: "Bad" }, + }); + + expect(result).not.toBe(false); + if (result !== false) { + const allErrors = Object.values(result).flat(); + const customErrors = allErrors.filter( + (e) => e.keyError && e.message === "Key must start with '/'", + ); + expect(customErrors).toHaveLength(1); + } + }); + + it("should surface both router pattern and key schema errors at the same key path", () => { + const schema = s.router( + strictMockRouter, + s.string().maxLength(5), + s.object({ title: s.string() }), + ); + + const result = schema["executeValidate"]("/test.val.ts" as SourcePath, { + "/bad-and-too-long": { title: "Fails both" }, + }); + + expect(result).not.toBe(false); + if (result !== false) { + const keyPath = Object.keys(result).find((p) => + p.includes("/bad-and-too-long"), + ); + expect(keyPath).toBeDefined(); + if (keyPath) { + const errsAtKey = result[keyPath as SourcePath]; + // Router pattern error from strictMockRouter + expect( + errsAtKey.some( + (e) => e.keyError && e.message.includes("not a valid route"), + ), + ).toBe(true); + // Key schema maxLength error + expect( + errsAtKey.some( + (e) => e.keyError && e.message.includes("at most 5"), + ), + ).toBe(true); + } + } + }); + + it("should still work with the two-arg form (no key schema)", () => { + const schema = s.router(mockRouter, s.object({ title: s.string() })); + const result = schema["executeValidate"]("/test.val.ts" as SourcePath, { + "any-key-is-fine": { title: "Yes" }, + "another-key": { title: "Also fine" }, + }); + expect(result).toBe(false); + + const serialized = schema["executeSerialize"](); + expect(serialized.key).toBeDefined(); + expect(serialized.key?.type).toBe("string"); + }); + + it("should be equivalent to s.record(key, value).router()", () => { + const keySchema = s.string().maxLength(5).describe("URL slug"); + const valueSchema = s.object({ title: s.string() }); + + const viaRouter = s.router(mockRouter, keySchema, valueSchema); + const viaRecord = s.record(keySchema, valueSchema).router(mockRouter); + + const a = viaRouter["executeSerialize"](); + const b = viaRecord["executeSerialize"](); + + expect(a.type).toBe(b.type); + expect(a.router).toBe(b.router); + expect(a.opt).toBe(b.opt); + expect(a.key?.type).toBe(b.key?.type); + if (a.key?.type === "string" && b.key?.type === "string") { + expect(a.key.description).toBe(b.key.description); + } + }); + + it("should round-trip through serialize/deserialize with key validation", () => { + const schema = s.router( + mockRouter, + s.string().maxLength(5).describe("URL slug"), + s.object({ title: s.string() }), + ); + + const serialized = schema["executeSerialize"](); + const deserialized = deserializeSchema(serialized); + + const failing = deserialized["executeValidate"]( + "/test.val.ts" as SourcePath, + { "way-too-long": { title: "X" } }, + ); + expect(failing).not.toBe(false); + if (failing !== false) { + const allErrors = Object.values(failing).flat(); + expect( + allErrors.some((e) => e.keyError && e.message.includes("at most 5")), + ).toBe(true); + } + + const reserialized = deserialized["executeSerialize"](); + expect(reserialized.type).toBe("record"); + if ( + reserialized.type === "record" && + reserialized.key?.type === "string" + ) { + expect(reserialized.key.description).toBe("URL slug"); + } + }); + }); }); diff --git a/packages/core/src/schema/router.ts b/packages/core/src/schema/router.ts index 052fb2d98..51559644c 100644 --- a/packages/core/src/schema/router.ts +++ b/packages/core/src/schema/router.ts @@ -5,16 +5,29 @@ import { RecordSchema } from "./record"; import { string } from "./string"; export function router< + K extends Schema, T extends Schema, - Src extends Record>, ->(router: ValRouter, item: T): RecordSchema, Src> { - const keySchema = string(); - const recordSchema = new RecordSchema, Src>( - item, - false, - [], - router, - keySchema, - ); - return recordSchema; +>( + router: ValRouter, + key: K, + item: T, +): RecordSchema, SelectorOfSchema>>; + +export function router>( + router: ValRouter, + item: T, +): RecordSchema, Record>>; + +export function router< + K extends Schema, + T extends Schema, +>( + router: ValRouter, + keyOrItem: K | T, + maybeItem?: T, +): RecordSchema, SelectorOfSchema>> { + if (maybeItem) { + return new RecordSchema(maybeItem, false, [], router, keyOrItem as K); + } + return new RecordSchema(keyOrItem as T, false, [], router, string()); } From 75d161e7560494b66edcee7658814b1e80e19c74 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 2 Jun 2026 22:25:16 +0200 Subject: [PATCH 2/6] Changeset --- .changeset/router-key-validation.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/router-key-validation.md diff --git a/.changeset/router-key-validation.md b/.changeset/router-key-validation.md new file mode 100644 index 000000000..45625d5c5 --- /dev/null +++ b/.changeset/router-key-validation.md @@ -0,0 +1,5 @@ +--- +"@valbuild/core": patch +--- + +Add key validation support to `s.router` (mirroring `s.record`). Pass a string schema as the second argument to attach `.maxLength()`, `.regexp()`, `.validate()`, `.describe()`, etc. to router keys — for example `s.router(nextAppRouter, s.string().maxLength(60).describe("URL slug"), s.object({ ... }))`. Router URL pattern validation continues to run, and both error sources now surface together at the same key path. From ad6d906a6c04c5e066e74dc23d7f64130f9cd6a3 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Thu, 30 Jul 2026 09:35:11 +0200 Subject: [PATCH 3/6] fix(core): merge validation errors path-wise instead of overwriting Sub-errors were merged with object spread, which replaces the array of a colliding SourcePath rather than concatenating it. A record validates the key and the item on the same path, so a key with a router error, a key schema error and an item error only reported one of them. Adds a mergeValidationErrors helper next to appendValidationError on Schema and uses it for every merge in record, array and object. Co-Authored-By: Claude Opus 5 --- packages/core/src/schema/array.ts | 18 ++--------- packages/core/src/schema/index.ts | 29 +++++++++++++++++ packages/core/src/schema/object.ts | 9 +----- packages/core/src/schema/record.ts | 43 +++---------------------- packages/core/src/schema/router.test.ts | 43 +++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 61 deletions(-) diff --git a/packages/core/src/schema/array.ts b/packages/core/src/schema/array.ts index a18e59d1f..be6554edc 100644 --- a/packages/core/src/schema/array.ts +++ b/packages/core/src/schema/array.ts @@ -9,10 +9,7 @@ import { SelectorSource } from "../selector"; import { unsafeCreateSourcePath } from "../selector/SelectorProxy"; import { ImageSource } from "../source/image"; import { ModuleFilePath, SourcePath } from "../val"; -import { - ValidationError, - ValidationErrors, -} from "./validation/ValidationError"; +import { ValidationErrors } from "./validation/ValidationError"; export type SerializedArraySchema = { type: "array"; @@ -73,21 +70,12 @@ export class ArraySchema< if (assertRes.data === null) { return false; } - let error: Record = {}; + let error: ValidationErrors = false; for (const [idx, i] of Object.entries(assertRes.data)) { const subPath = unsafeCreateSourcePath(path, Number(idx)); // eslint-disable-next-line @typescript-eslint/no-explicit-any const subError = this.item["executeValidate"](subPath, i as any); - if (subError) { - error = { - ...subError, - ...error, - }; - } - } - - if (Object.keys(error).length === 0) { - return false; + error = this.mergeValidationErrors(error, subError); } return error; } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 6a7039509..c9172ae0e 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -176,6 +176,35 @@ export abstract class Schema { } as ValidationErrors; } } + + /** + * Merges two sets of validation errors path-wise: errors on the same path are + * concatenated, not overwritten. Object spread cannot be used for this, since + * it replaces the array of the colliding path. A record, for example, validates + * the key and the item on the same path, so both sets must survive. + * + * MUTATES! since internal and perf sensitive + */ + protected mergeValidationErrors( + current: ValidationErrors, + incoming: ValidationErrors, + ): ValidationErrors { + if (!incoming) { + return current; + } + if (!current) { + return incoming; + } + for (const pathS in incoming) { + const path = pathS as SourcePath; + if (current[path]) { + current[path] = current[path].concat(incoming[path]); + } else { + current[path] = incoming[path]; + } + } + return current; + } } export type SelectorOfSchema> = diff --git a/packages/core/src/schema/object.ts b/packages/core/src/schema/object.ts index b26593f8f..0ad8cec77 100644 --- a/packages/core/src/schema/object.ts +++ b/packages/core/src/schema/object.ts @@ -140,14 +140,7 @@ export class ObjectSchema< ); } else { const subError = schema["executeValidate"](subPath, src[key]); - if (subError && error) { - error = { - ...subError, - ...error, - }; - } else if (subError) { - error = subError; - } + error = this.mergeValidationErrors(error, subError); } } diff --git a/packages/core/src/schema/record.ts b/packages/core/src/schema/record.ts index e254074b1..85b33593e 100644 --- a/packages/core/src/schema/record.ts +++ b/packages/core/src/schema/record.ts @@ -134,21 +134,7 @@ export class RecordSchema< } as ValidationErrors; } const routerValidations = this.getRouterValidations(path, src); - if (routerValidations) { - for (const errPath in routerValidations) { - const p = errPath as SourcePath; - const errs = routerValidations[p]; - if (error) { - if (error[p]) { - error[p] = [...error[p], ...errs]; - } else { - error[p] = errs; - } - } else { - error = { [p]: errs }; - } - } - } + error = this.mergeValidationErrors(error, routerValidations); for (const customValidationError of customValidationErrors) { error = this.appendValidationError( error, @@ -214,15 +200,7 @@ export class RecordSchema< ...err, keyError: true, })); - if (error) { - if (error[keyPath]) { - error[keyPath] = [...error[keyPath], ...keyError[keyPath]]; - } else { - error[keyPath] = keyError[keyPath]; - } - } else { - error = keyError; - } + error = this.mergeValidationErrors(error, keyError); } } @@ -239,26 +217,15 @@ export class RecordSchema< } else if (this.mediaOptions) { // Media collection: validate key (path/URL) and entry (metadata) const keyErr = this.validateMediaKey(subPath, key); - if (keyErr) { - error = error ? { ...error, ...keyErr } : keyErr; - } + error = this.mergeValidationErrors(error, keyErr); const entryErr = this.validateMediaEntry(subPath, elem); - if (entryErr) { - error = error ? { ...error, ...entryErr } : entryErr; - } + error = this.mergeValidationErrors(error, entryErr); } else { const subError = this.item["executeValidate"]( subPath, elem as SelectorSource, ); - if (subError && error) { - error = { - ...subError, - ...error, - }; - } else if (subError) { - error = subError; - } + error = this.mergeValidationErrors(error, subError); } }); return error; diff --git a/packages/core/src/schema/router.test.ts b/packages/core/src/schema/router.test.ts index c0b138e54..78318fa3a 100644 --- a/packages/core/src/schema/router.test.ts +++ b/packages/core/src/schema/router.test.ts @@ -210,6 +210,49 @@ describe("router", () => { } }); + it("should keep item errors on a key that also has router and key schema errors", () => { + // The item is a string, so its errors land on the key path itself: all + // three error sources collide on the same SourcePath + const schema = s.router( + strictMockRouter, + s.string().maxLength(5), + s.string().maxLength(3), + ); + + const result = schema["executeValidate"]("/test.val.ts" as SourcePath, { + "/bad-and-too-long": "item value is too long as well", + }); + + expect(result).not.toBe(false); + if (result !== false) { + const keyPath = Object.keys(result).find((p) => + p.includes("/bad-and-too-long"), + ); + expect(keyPath).toBeDefined(); + if (keyPath) { + const errsAtKey = result[keyPath as SourcePath]; + // Router pattern error + expect( + errsAtKey.some( + (e) => e.keyError && e.message.includes("not a valid route"), + ), + ).toBe(true); + // Key schema error + expect( + errsAtKey.some( + (e) => e.keyError && e.message.includes("at most 5"), + ), + ).toBe(true); + // Item error must not be dropped by the merge + expect( + errsAtKey.some( + (e) => !e.keyError && e.message.includes("at most 3"), + ), + ).toBe(true); + } + } + }); + it("should still work with the two-arg form (no key schema)", () => { const schema = s.router(mockRouter, s.object({ title: s.string() })); const result = schema["executeValidate"]("/test.val.ts" as SourcePath, { From 697b6b837e4ce4bdac535379cb7849cd84eac98d Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Thu, 30 Jul 2026 09:35:19 +0200 Subject: [PATCH 4/6] feat(ui): derive record key description from the schema The add and rename popovers only showed the key description when a caller passed it explicitly, so the sitemap's add-route button - which knows the path but not the schema - showed none. Both popovers already resolve the record schema, so fall back to its key description and keep the prop as an override. Co-Authored-By: Claude Opus 5 --- .../ui/spa/components/AddRecordPopover.tsx | 9 ++++++--- .../ui/spa/components/ChangeRecordPopover.tsx | 20 +++++++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/ui/spa/components/AddRecordPopover.tsx b/packages/ui/spa/components/AddRecordPopover.tsx index dd94bd458..0a7799490 100644 --- a/packages/ui/spa/components/AddRecordPopover.tsx +++ b/packages/ui/spa/components/AddRecordPopover.tsx @@ -119,6 +119,9 @@ export function AddRecordPopover({ } const [moduleFilePath, modulePath] = Internal.splitModuleFilePathAndModulePath(path); + // Callers may pass the description explicitly, but fall back to the key schema + // so that call sites which only know the path (the sitemap, for example) show it too + const description = keyDescription ?? schema.key?.description; return ( @@ -135,12 +138,12 @@ export function AddRecordPopover({ -

{keyDescription ? `Add ${keyDescription}` : "Add"}

+

{description ? `Add ${description}` : "Add"}

- {keyDescription && ( -
{keyDescription}
+ {description && ( +
{description}
)} {routePattern ? ( { if ("data" in parentSource && parentSource.data) { return Object.keys(parentSource.data); @@ -134,12 +146,12 @@ export function ChangeRecordPopover({ - {keyDescription ? `Rename ${keyDescription}` : "Rename record"} + {description ? `Rename ${description}` : "Rename record"} - {keyDescription && ( -
{keyDescription}
+ {description && ( +
{description}
)} {routePattern ? ( Date: Thu, 30 Jul 2026 09:35:26 +0200 Subject: [PATCH 5/6] docs(example): describe the router key in the blog router Uses the new three-argument s.router form in examples/next so the key description is visible in the editor, and notes the UI change and the validation error merge in the changeset. Co-Authored-By: Claude Opus 5 --- .changeset/router-key-validation.md | 3 ++- examples/next/app/blogs/[blog]/page.val.ts | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.changeset/router-key-validation.md b/.changeset/router-key-validation.md index 45625d5c5..a6eaa867c 100644 --- a/.changeset/router-key-validation.md +++ b/.changeset/router-key-validation.md @@ -1,5 +1,6 @@ --- "@valbuild/core": patch +"@valbuild/ui": patch --- -Add key validation support to `s.router` (mirroring `s.record`). Pass a string schema as the second argument to attach `.maxLength()`, `.regexp()`, `.validate()`, `.describe()`, etc. to router keys — for example `s.router(nextAppRouter, s.string().maxLength(60).describe("URL slug"), s.object({ ... }))`. Router URL pattern validation continues to run, and both error sources now surface together at the same key path. +Add key validation support to `s.router` (mirroring `s.record`). Pass a string schema as the second argument to attach `.maxLength()`, `.regexp()`, `.validate()`, `.describe()`, etc. to router keys — for example `s.router(nextAppRouter, s.string().maxLength(60).describe("URL slug"), s.object({ ... }))`. Router URL pattern validation continues to run, and both error sources now surface together at the same key path. Validation errors on the same path are now merged instead of overwritten, so router, key and item errors on a key are all reported. The key description is also shown when adding or renaming a route from the sitemap. diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts index 16d3c58a2..25156f538 100644 --- a/examples/next/app/blogs/[blog]/page.val.ts +++ b/examples/next/app/blogs/[blog]/page.val.ts @@ -15,13 +15,19 @@ const blogSchema = s.object({ export default c.define( "/app/blogs/[blog]/page.val.ts", - s.router(nextAppRouter, blogSchema).render({ - as: "list", - select: ({ val }) => ({ - title: val.title, - subtitle: val.author, + s + .router( + nextAppRouter, + s.string().describe("The URL of the blog post"), + blogSchema, + ) + .render({ + as: "list", + select: ({ val }) => ({ + title: val.title, + subtitle: val.author, + }), }), - }), { "/blogs/blog2": { title: "Blog 2", From d4271db7ed9e5441eae8d7b7bd7790fe3b8e7915 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Fri, 31 Jul 2026 14:49:44 +0200 Subject: [PATCH 6/6] fix(ui): align route key descriptions and show them in the sitemap form Builds on the key description fallback in the add and rename popovers. RouteForm carried its own p-4 inside an already padded popover, so the description rendered above it sat 16px left of the route inputs. Move the description into the form and drop the extra padding so they line up. Tooltips spliced the description into the label ("Add The URL of this blog post..."). Show a label - "Add page" / "Change URL of this page" - with the description on its own line beneath it. The sitemap has its own standalone add form, which the popover fallback does not reach, so thread the router key description through SitemapItem from the schemas snapshot and render it there too. Co-Authored-By: Claude Opus 5 --- .changeset/router-key-validation.md | 2 +- .../ui/spa/components/AddRecordPopover.tsx | 24 +++++++---- .../ui/spa/components/ChangeRecordPopover.tsx | 40 +++++++++++-------- .../ui/spa/components/NavMenu/SitemapItem.tsx | 8 ++++ .../ui/spa/components/NavMenu/mockData.ts | 1 + packages/ui/spa/components/NavMenu/types.ts | 2 + .../spa/components/NavMenu/useNavMenuData.ts | 29 +++++++++++--- packages/ui/spa/components/RouteForm.tsx | 9 ++++- 8 files changed, 83 insertions(+), 32 deletions(-) diff --git a/.changeset/router-key-validation.md b/.changeset/router-key-validation.md index a6eaa867c..a44f63188 100644 --- a/.changeset/router-key-validation.md +++ b/.changeset/router-key-validation.md @@ -3,4 +3,4 @@ "@valbuild/ui": patch --- -Add key validation support to `s.router` (mirroring `s.record`). Pass a string schema as the second argument to attach `.maxLength()`, `.regexp()`, `.validate()`, `.describe()`, etc. to router keys — for example `s.router(nextAppRouter, s.string().maxLength(60).describe("URL slug"), s.object({ ... }))`. Router URL pattern validation continues to run, and both error sources now surface together at the same key path. Validation errors on the same path are now merged instead of overwritten, so router, key and item errors on a key are all reported. The key description is also shown when adding or renaming a route from the sitemap. +Add key validation support to `s.router` (mirroring `s.record`). Pass a string schema as the second argument to attach `.maxLength()`, `.regexp()`, `.validate()`, `.describe()`, etc. to router keys — for example `s.router(nextAppRouter, s.string().maxLength(60).describe("URL slug"), s.object({ ... }))`. Router URL pattern validation continues to run, and both error sources now surface together at the same key path. Validation errors on the same path are now merged instead of overwritten, so router, key and item errors on a key are all reported. The key description is also shown when adding or renaming a route from the sitemap: it now lines up with the route inputs instead of sitting outside the form's padding, it is shown in the sitemap's own "Add new page" form, and tooltips show it as a separate line under `Add page` / `Change URL of this page` rather than splicing it into the label. diff --git a/packages/ui/spa/components/AddRecordPopover.tsx b/packages/ui/spa/components/AddRecordPopover.tsx index 0a7799490..52f64a4c9 100644 --- a/packages/ui/spa/components/AddRecordPopover.tsx +++ b/packages/ui/spa/components/AddRecordPopover.tsx @@ -138,13 +138,15 @@ export function AddRecordPopover({ -

{description ? `Add ${description}` : "Add"}

+

{routePattern ? "Add page" : "Add"}

+ {description && ( +

+ {description} +

+ )}
- {description && ( -
{description}
- )} {routePattern ? ( ) : ( - + <> + {description && ( +
{description}
+ )} + + )}
diff --git a/packages/ui/spa/components/ChangeRecordPopover.tsx b/packages/ui/spa/components/ChangeRecordPopover.tsx index e88c28da8..262ac390f 100644 --- a/packages/ui/spa/components/ChangeRecordPopover.tsx +++ b/packages/ui/spa/components/ChangeRecordPopover.tsx @@ -146,13 +146,15 @@ export function ChangeRecordPopover({ - {description ? `Rename ${description}` : "Rename record"} +

{routePattern ? "Change URL of this page" : "Rename record"}

+ {description && ( +

+ {description} +

+ )}
- {description && ( -
{description}
- )} {routePattern ? ( ) : ( - { - onSubmit(key); - setOpen(false); - }} - onCancel={() => { - setOpen(false); - }} - /> + <> + {description && ( +
{description}
+ )} + { + onSubmit(key); + setOpen(false); + }} + onCancel={() => { + setOpen(false); + }} + /> + )}
diff --git a/packages/ui/spa/components/NavMenu/SitemapItem.tsx b/packages/ui/spa/components/NavMenu/SitemapItem.tsx index 90203a2c2..f00dedd8a 100644 --- a/packages/ui/spa/components/NavMenu/SitemapItem.tsx +++ b/packages/ui/spa/components/NavMenu/SitemapItem.tsx @@ -179,6 +179,7 @@ export function SitemapItemNode({ setAddPopoverOpen(false)} /> @@ -215,11 +216,13 @@ export function SitemapItemNode({ function AddRouteForm({ routePattern, existingKeys, + keyDescription, onSubmit, onCancel, }: { routePattern: RoutePattern[]; existingKeys: string[]; + keyDescription?: string; onSubmit: (urlPath: string) => void; onCancel: () => void; }) { @@ -263,6 +266,11 @@ function AddRouteForm({
Add new page
+ {keyDescription && ( +
+ {keyDescription} +
+ )}
{routePattern.map((part, i) => ( diff --git a/packages/ui/spa/components/NavMenu/mockData.ts b/packages/ui/spa/components/NavMenu/mockData.ts index b878f349a..4f497501e 100644 --- a/packages/ui/spa/components/NavMenu/mockData.ts +++ b/packages/ui/spa/components/NavMenu/mockData.ts @@ -29,6 +29,7 @@ export const mockSitemap: SitemapItem = { moduleFilePath: "/app/blogs/[blog]/page.val.ts" as ModuleFilePath, routePattern: blogsRoutePattern, existingKeys: ["/blog-1", "/blog-2", "/blog-3"], + keyDescription: "The URL of this blog post. Lower case, no spaces.", sourcePath: '/app/blogs/[blog]/page.val.ts?p="/blogs"' as SourcePath, children: [ { diff --git a/packages/ui/spa/components/NavMenu/types.ts b/packages/ui/spa/components/NavMenu/types.ts index 9523c95c1..93f6ab69a 100644 --- a/packages/ui/spa/components/NavMenu/types.ts +++ b/packages/ui/spa/components/NavMenu/types.ts @@ -20,6 +20,8 @@ export type SitemapItem = { routePattern?: RoutePattern[]; /** Existing children keys (for validation in add form) */ existingKeys?: string[]; + /** Description of the router key schema (shown in the add page form) */ + keyDescription?: string; /** Child pages/folders */ children: SitemapItem[]; /** Whether this item or any descendant has validation errors */ diff --git a/packages/ui/spa/components/NavMenu/useNavMenuData.ts b/packages/ui/spa/components/NavMenu/useNavMenuData.ts index 534de4a19..186842247 100644 --- a/packages/ui/spa/components/NavMenu/useNavMenuData.ts +++ b/packages/ui/spa/components/NavMenu/useNavMenuData.ts @@ -1,10 +1,11 @@ import { useMemo } from "react"; -import { ModuleFilePath, SourcePath } from "@valbuild/core"; +import { ModuleFilePath, SerializedSchema, SourcePath } from "@valbuild/core"; import { useTrees } from "../useTrees"; import { useShallowModulesAtPaths, useNextAppRouterSrcFolder, } from "../ValProvider"; +import { useSchemas } from "../ValFieldProvider"; import { Internal } from "@valbuild/core"; import { getNextAppRouterSitemapTree, @@ -19,7 +20,10 @@ import { Remote } from "../../utils/Remote"; /** * Transforms a SitemapNode (from shared/internal) to our SitemapItem type. */ -function transformSitemapNode(node: SitemapNode | PageNode): SitemapItem { +function transformSitemapNode( + node: SitemapNode | PageNode, + schemas?: Record, +): SitemapItem { const canAddChild = !!node.pattern?.includes("["); const routePattern = canAddChild && node.pattern ? parseRoutePattern(node.pattern) : undefined; @@ -29,15 +33,24 @@ function transformSitemapNode(node: SitemapNode | PageNode): SitemapItem { ? node.children.map((child) => "/" + child.name) : undefined; + const moduleFilePath = node.moduleFilePath as ModuleFilePath | undefined; + const routerSchema = + canAddChild && moduleFilePath ? schemas?.[moduleFilePath] : undefined; + const keyDescription = + routerSchema?.type === "record" ? routerSchema.key?.description : undefined; + return { name: node.name, urlPath: node.pattern || "/", sourcePath: node.sourcePath as SourcePath | undefined, - moduleFilePath: node.moduleFilePath as ModuleFilePath | undefined, + moduleFilePath, canAddChild, routePattern, existingKeys, - children: node.children.map(transformSitemapNode), + keyDescription, + children: node.children.map((child) => + transformSitemapNode(child, schemas), + ), }; } @@ -66,6 +79,7 @@ export function useNavMenuData(): Remote { const shallowModules = useShallowModulesAtPaths(sitemapPaths, "record"); const srcFolder = useNextAppRouterSrcFolder(); + const schemas = useSchemas(); return useMemo((): Remote => { if (trees.status !== "success") { @@ -96,7 +110,10 @@ export function useNavMenuData(): Remote { } } const sitemapTree = getNextAppRouterSitemapTree(srcFolder.data, paths); - data.sitemap = transformSitemapNode(sitemapTree); + data.sitemap = transformSitemapNode( + sitemapTree, + schemas.status === "success" ? schemas.data : undefined, + ); } else if ( srcFolder.status === "loading" || shallowModules.status === "loading" @@ -122,5 +139,5 @@ export function useNavMenuData(): Remote { status: "success", data, }; - }, [trees, sitemapPaths, srcFolder, shallowModules]); + }, [trees, sitemapPaths, srcFolder, shallowModules, schemas]); } diff --git a/packages/ui/spa/components/RouteForm.tsx b/packages/ui/spa/components/RouteForm.tsx index c12e73941..0a888d761 100644 --- a/packages/ui/spa/components/RouteForm.tsx +++ b/packages/ui/spa/components/RouteForm.tsx @@ -12,6 +12,7 @@ export function RouteForm({ defaultParams, onCancel, defaultValue, + keyDescription, }: { routePattern: RoutePattern[]; existingKeys: string[]; @@ -22,6 +23,7 @@ export function RouteForm({ }; onCancel: () => void; defaultValue?: string; + keyDescription?: string; }) { const [params, setParams] = useState<{ [paramName: string]: string | string[]; @@ -75,12 +77,17 @@ export function RouteForm({ return (
{ e.preventDefault(); onSubmit(fullPath); }} > + {keyDescription && ( +
{keyDescription}
+ )}
{routePattern.map((part, i) => (