From 0c55fbfcce20527463bd852c5e147cdcdaed2353 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Fri, 17 Oct 2025 16:34:36 +0200 Subject: [PATCH 1/2] WIP: experiments with locales --- packages/core/src/index.ts | 2 +- packages/core/src/locale.ts | 6 ++ packages/core/src/router.test.ts | 25 ++++++- packages/core/src/router.ts | 108 +++++++++++++++++++++++++++-- packages/core/src/schema/index.ts | 1 + packages/core/src/schema/record.ts | 13 +++- 6 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/locale.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8dca9b034..8dbcf6f7b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,7 +128,7 @@ export { type ListArrayRender, type ReifiedRender, } from "./render"; -export type { ValRouter, RouteValidationError } from "./router"; +export type { ValRouter, NextAppRouter, RouteValidationError } from "./router"; import { nextAppRouter } from "./router"; export const FATAL_ERROR_TYPES = [ diff --git a/packages/core/src/locale.ts b/packages/core/src/locale.ts new file mode 100644 index 000000000..ff75e8b13 --- /dev/null +++ b/packages/core/src/locale.ts @@ -0,0 +1,6 @@ +export function validateLocale(locale: string): false | string { + if (locale.match(/^[a-z]{2}-[a-z]{2}$/)) { + return false; + } + return "Invalid locale format. Must be two lower case letters for language and two lowercase letters for country, separated by a hyphen. Expected format: xx-xx (e.g. en-us, nb-no)"; +} diff --git a/packages/core/src/router.test.ts b/packages/core/src/router.test.ts index e8b7f6a5a..9019637f4 100644 --- a/packages/core/src/router.test.ts +++ b/packages/core/src/router.test.ts @@ -1,4 +1,8 @@ -import { parseNextJsRoutePattern } from "./router"; +import { NextAppRouterImpl, parseNextJsRoutePattern } from "./router"; +import { object } from "./schema/object"; +import { record } from "./schema/record"; +import { string } from "./schema/string"; +import { ModuleFilePath } from "./val"; describe("parseNextJsRoutePattern", () => { describe("App Router patterns", () => { @@ -155,4 +159,23 @@ describe("parseNextJsRoutePattern", () => { ).toEqual(["docs", "[...slug]"]); }); }); + + describe("Localization", () => { + const router = new NextAppRouterImpl( + record(object({ title: string() })), + ).localize({ + type: "directory", + segment: "locale", + translation: "translation", + }); + + test("validate", () => { + expect( + router.validate( + "/app/[locale]/blogs/[blog]/page.val.ts" as ModuleFilePath, + ["/en/blogs/test"], + ), + ).toEqual([]); + }); + }); }); diff --git a/packages/core/src/router.ts b/packages/core/src/router.ts index acdfc0d3b..24725cd63 100644 --- a/packages/core/src/router.ts +++ b/packages/core/src/router.ts @@ -1,3 +1,5 @@ +import { Schema } from "./schema"; +import { SelectorSource } from "./selector"; import { ModuleFilePath } from "./val"; export type RouteValidationError = { @@ -126,10 +128,52 @@ function validateUrlAgainstPattern( return { isValid: true }; } -// This router should not be in core package -export const nextAppRouter: ValRouter = { - getRouterId: () => "next-app-router", - validate: (moduleFilePath, urlPaths) => { +// Next App router should not be in core package (it should be in the next package) +export class NextAppRouterImpl implements NextAppRouter { + constructor( + private readonly schema: Schema | null = null, + private readonly thisLocalize: { + type: "directory"; + segment: string; + translation: string; + } | null = null, + ) {} + + withSchema(schema: Schema): NextAppRouter { + return new NextAppRouterImpl(schema, this.thisLocalize); + } + getRouterId(): string { + return "next-app-router"; + } + localize(input: { + type: "directory"; + segment: string; + translation: string; + }): NextAppRouter { + return new NextAppRouterImpl(this.schema, input); + } + + validate( + moduleFilePath: ModuleFilePath, + urlPaths: string[], + ): RouteValidationError[] { + if (this.thisLocalize) { + this.routerLocalizedValidate(moduleFilePath, urlPaths); + } + return this.routerValidate(moduleFilePath, urlPaths); + } + + private routerLocalizedValidate( + moduleFilePath: ModuleFilePath, + urlPaths: string[], + ): RouteValidationError[] { + return this.routerValidate(moduleFilePath, urlPaths); + } + + private routerValidate( + moduleFilePath: ModuleFilePath, + urlPaths: string[], + ): RouteValidationError[] { const routePattern = parseNextJsRoutePattern(moduleFilePath); const errors: RouteValidationError[] = []; @@ -148,8 +192,10 @@ export const nextAppRouter: ValRouter = { } return errors; - }, -}; + } +} + +export const nextAppRouter: NextAppRouter = new NextAppRouterImpl(); /** * Parse Next.js route pattern from file path @@ -209,4 +255,54 @@ export interface ValRouter { moduleFilePath: ModuleFilePath, urlPaths: string[], ): RouteValidationError[]; + withSchema(schema: Schema): ValRouter; +} + +export interface NextAppRouter extends ValRouter { + /** + * Configure localization for this router. + * + * When localized, the file organization changes from a single `page.val.ts` file to + * separate `.val.ts` files for each locale (e.g., `en-us.val.ts`, `nb-no.val.ts`). + * + * @param input - Localization configuration + * @param input.moduleName - Must be `"locale"` to indicate locale-based filenames (e.g., `en-us.val.ts`, `nb-no.val.ts`) + * @param input.segment - The URL segment name for the locale (e.g., `"locale"` for `/[locale]/...`) + * @param input.translations - The field name in your schema that links translations across locales. + * This field should contain the URL path to the translation in another locale. + * + * @example + * ```typescript + * // In schema.val.ts + * export const schema = s.record(blogSchema).router( + * nextAppRouter.localize({ + * moduleName: "locale", + * segment: "locale", + * translations: "translations", + * }) + * ); + * + * // Creates locale-specific files: + * // - en-us.val.ts + * // - nb-no.val.ts + * // - fr-fr.val.ts + * + * // In en-us.val.ts + * export default c.define("/app/[locale]/blogs/[blog]/en-us.val.ts", schema, { + * "/en-us/blogs/my-post": { + * title: "My Post", + * translations: ["/nb-no/blogs/min-post", "/fr-fr/blogs/mon-post"], // or path to translated version + * }, + * }); + * ``` + * + * @remarks + * Locale identifiers must follow BCP 47 format in lowercase (e.g., `en-us`, `nb-no`, `fr-fr`) + * to ensure web-safe file names and URL paths. + */ + localize: (input: { + moduleName: "locale"; + segment: string; + translations?: string; + }) => NextAppRouter; } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index a05b665c8..37e59334d 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -83,6 +83,7 @@ export abstract class Schema { path: SourcePath, src: Src, ): ValidationErrors; + protected executeCustomValidateFunctions( src: Src, customValidateFunctions: CustomValidateFunction[], diff --git a/packages/core/src/schema/record.ts b/packages/core/src/schema/record.ts index 66f4d2daa..b21e96b3a 100644 --- a/packages/core/src/schema/record.ts +++ b/packages/core/src/schema/record.ts @@ -21,12 +21,14 @@ import { ValidationError, ValidationErrors, } from "./validation/ValidationError"; +import { validateLocale } from "../locale"; export type SerializedRecordSchema = { type: "record"; item: SerializedSchema; opt: boolean; router?: string; + locale?: string | null; customValidate?: boolean; }; @@ -39,6 +41,7 @@ export class RecordSchema< private readonly opt: boolean = false, private readonly customValidateFunctions: CustomValidateFunction[] = [], private readonly currentRouter: ValRouter | null = null, + private readonly currentLocale: string | null = null, ) { super(); } @@ -91,6 +94,13 @@ export class RecordSchema< if (routerValidations) { return routerValidations; } + if (this.currentLocale) { + const localeValidation = validateLocale(this.currentLocale); + if (localeValidation) { + const message = localeValidation; + error = this.appendValidationError(error, path, message, src, true); + } + } for (const customValidationError of customValidationErrors) { error = this.appendValidationError( error, @@ -177,7 +187,7 @@ export class RecordSchema< this.item, this.opt, this.customValidateFunctions, - router, + router.withSchema(this), ); } @@ -249,6 +259,7 @@ export class RecordSchema< item: this.item["executeSerialize"](), opt: this.opt, router: this.currentRouter?.getRouterId(), + locale: this.currentLocale, customValidate: this.customValidateFunctions && this.customValidateFunctions?.length > 0, From a27bcca982e813fc512d81b8f9f16a14ce28e381 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Fri, 17 Oct 2025 16:35:27 +0200 Subject: [PATCH 2/2] Added examples of locales --- .../app/[locale]/blog/[blog]/en-us.val.ts | 20 ++++++++ .../app/[locale]/blog/[blog]/nb-no.val.ts | 20 ++++++++ .../next/app/[locale]/blog/[blog]/page.tsx | 45 +++++++++++++++++ .../app/[locale]/blog/[blog]/schema.val.ts | 28 +++++++++++ .../[locale]/blog/[blog]/translations.val.ts | 28 +++++++++++ examples/next/app/blogs/[blog]/page.tsx | 28 ----------- examples/next/app/blogs/[blog]/page.val.ts | 49 ------------------- examples/next/components/link.val.ts | 2 +- examples/next/val.config.ts | 1 - examples/next/val.modules.ts | 3 +- packages/next/src/initVal.ts | 6 +-- packages/next/src/rsc/initValRsc.ts | 27 +++++----- 12 files changed, 162 insertions(+), 95 deletions(-) create mode 100644 examples/next/app/[locale]/blog/[blog]/en-us.val.ts create mode 100644 examples/next/app/[locale]/blog/[blog]/nb-no.val.ts create mode 100644 examples/next/app/[locale]/blog/[blog]/page.tsx create mode 100644 examples/next/app/[locale]/blog/[blog]/schema.val.ts create mode 100644 examples/next/app/[locale]/blog/[blog]/translations.val.ts delete mode 100644 examples/next/app/blogs/[blog]/page.tsx delete mode 100644 examples/next/app/blogs/[blog]/page.val.ts diff --git a/examples/next/app/[locale]/blog/[blog]/en-us.val.ts b/examples/next/app/[locale]/blog/[blog]/en-us.val.ts new file mode 100644 index 000000000..85d9ffb0a --- /dev/null +++ b/examples/next/app/[locale]/blog/[blog]/en-us.val.ts @@ -0,0 +1,20 @@ +import { c } from "../../../../val.config"; +import { schema } from "./schema.val"; + +export default c.define("/app/[locale]/blogs/[blog]/en-us.val.ts", schema, { + "/en-us/blogs/my-page": { + title: "My page", + author: "freekh", + content: [ + { + tag: "p", + children: ["English content"], + }, + ], + link: { + href: "/", + label: "Home", + }, + translation: "/nb-no/blogs/min-side", + }, +}); diff --git a/examples/next/app/[locale]/blog/[blog]/nb-no.val.ts b/examples/next/app/[locale]/blog/[blog]/nb-no.val.ts new file mode 100644 index 000000000..ddedfd7be --- /dev/null +++ b/examples/next/app/[locale]/blog/[blog]/nb-no.val.ts @@ -0,0 +1,20 @@ +import { c } from "../../../../val.config"; +import { schema } from "./schema.val"; + +export default c.define("/app/[locale]/blogs/[blog]/nb-no.val.ts", schema, { + "/nb-no/blogs/min-side": { + title: "Min side", + author: "thoram", + content: [ + { + tag: "p", + children: ["Norsk innhold"], + }, + ], + link: { + href: "/", + label: "Hjem", + }, + translation: "/en-us/blogs/my-page", + }, +}); diff --git a/examples/next/app/[locale]/blog/[blog]/page.tsx b/examples/next/app/[locale]/blog/[blog]/page.tsx new file mode 100644 index 000000000..d48bb231c --- /dev/null +++ b/examples/next/app/[locale]/blog/[blog]/page.tsx @@ -0,0 +1,45 @@ +"use server"; +import { notFound } from "next/navigation"; +import { fetchVal, fetchValRoute } from "../../../../val/rsc"; +import Link from "next/link"; +import authorsVal from "../../../../content/authors.val"; +import { ValRichText } from "@valbuild/next"; +import enVal from "./en-us.val"; +import nbVal from "./nb-no.val"; +import { Blog } from "./schema.val"; +import translationsVal from "./translations.val"; + +export default async function BlogPage({ + params, +}: { + params: Promise<{ blog: string; locale: string }>; +}) { + const { locale } = await params; + const translations = await fetchVal(translationsVal); + const blog = await fetchValRoute([enVal, nbVal], params); + + const authors = await fetchVal(authorsVal); + if (!blog) { + return notFound(); + } + const author = authors[blog.author]; + return ( +
+

{blog.title}

+ + {blog.content} + {blog.link.label} + {blog.translations?.length > 0 && ( +

+ {translations[locale]?.translationCanBeFound} + {blog.translations.map((translation) => ( + + {translation} + + ))} +

+ )} + s +
+ ); +} diff --git a/examples/next/app/[locale]/blog/[blog]/schema.val.ts b/examples/next/app/[locale]/blog/[blog]/schema.val.ts new file mode 100644 index 000000000..a928634c7 --- /dev/null +++ b/examples/next/app/[locale]/blog/[blog]/schema.val.ts @@ -0,0 +1,28 @@ +import authorsVal from "../../../../content/authors.val"; +import { nextAppRouter, s, t } from "../../../../val.config"; + +const blogSchema = s.object({ + title: s.string(), + translations: s.array( + s.object({ + locale: s.string(), + key: s.string(), + }), + ), + author: s.keyOf(authorsVal), + content: s.richtext(), + link: s.object({ + href: s.string(), + label: s.string(), + }), +}); + +export type Blog = t.inferSchema; + +export const schema = s.record(blogSchema).router( + nextAppRouter.localize({ + moduleName: "locale", + segment: "locale", + translations: "translations", + }), +); diff --git a/examples/next/app/[locale]/blog/[blog]/translations.val.ts b/examples/next/app/[locale]/blog/[blog]/translations.val.ts new file mode 100644 index 000000000..025fe785f --- /dev/null +++ b/examples/next/app/[locale]/blog/[blog]/translations.val.ts @@ -0,0 +1,28 @@ +import { c, s } from "../../../../val.config"; + +export default c.define( + "/app/[locale]/blog/[blog]/translations.val.ts", + s + .record( + s.object({ + "en-us": s.string(), + "nb-no": s.string(), + "translation-is-available-in": s.string(), + }), + ) + .keys({ locale: { required: ["en-us", "nb-no"] } }), + { + "en-us": { + "en-us": "English", + "nb-no": "Norwegian", + "translation-is-available-in": + "This blog is also available in the following languages:", + }, + "nb-no": { + "en-us": "Engelsk", + "nb-no": "Norsk", + "translation-is-available-in": + "Denne bloggen er også tilgjengelig på følgende språk:", + }, + }, +); diff --git a/examples/next/app/blogs/[blog]/page.tsx b/examples/next/app/blogs/[blog]/page.tsx deleted file mode 100644 index 9c1436a7a..000000000 --- a/examples/next/app/blogs/[blog]/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -"use server"; -import { notFound } from "next/navigation"; -import { fetchVal, fetchValRoute } from "../../../val/rsc"; -import blogsVal from "./page.val"; -import Link from "next/link"; -import authorsVal from "../../../content/authors.val"; -import { ValRichText } from "@valbuild/next"; - -export default async function BlogPage({ - params, -}: { - params: Promise<{ blog: string }>; -}) { - const blog = await fetchValRoute(blogsVal, params); - const authors = await fetchVal(authorsVal); - if (!blog) { - return notFound(); - } - const author = authors[blog.author]; - return ( -
-

{blog.title}

- - {blog.content} - {blog.link.label} -
- ); -} diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts deleted file mode 100644 index 3017a8327..000000000 --- a/examples/next/app/blogs/[blog]/page.val.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { c, nextAppRouter, s } from "_/val.config"; -import authorsVal from "../../../content/authors.val"; -import { linkSchema } from "../../../components/link.val"; - -const blogSchema = s.object({ - title: s.string(), - content: s.richtext(), - author: s.keyOf(authorsVal), - get link() { - return linkSchema; - }, -}); - -export default c.define( - "/app/blogs/[blog]/page.val.ts", - s.record(blogSchema).router(nextAppRouter), - { - "/blogs/blog2": { - title: "Blog 2", - content: [ - { - tag: "p", - children: ["Blog 2 content"], - }, - ], - author: "freekh", - link: { - label: "Read more", - type: "blog", - href: "/blogs/blog1", - }, - }, - "/blogs/blog1": { - title: "Blog 1", - content: [ - { - tag: "p", - children: ["Blog 1 content"], - }, - ], - author: "freekh", - link: { - label: "See more", - type: "generic", - href: "/generic/test/foo", - }, - }, - }, -); diff --git a/examples/next/components/link.val.ts b/examples/next/components/link.val.ts index 605e74650..fe05a6293 100644 --- a/examples/next/components/link.val.ts +++ b/examples/next/components/link.val.ts @@ -1,5 +1,5 @@ import { s } from "../val.config"; -import blogsVal from "../app/blogs/[blog]/page.val"; +import blogsVal from "../app/[locale]/blog/[blog]/page.val"; import genericPageVal from "../app/generic/[[...path]]/page.val"; import { Schema } from "@valbuild/core"; import pageVal from "../app/page.val"; diff --git a/examples/next/val.config.ts b/examples/next/val.config.ts index bb601ca86..7d8a3b927 100644 --- a/examples/next/val.config.ts +++ b/examples/next/val.config.ts @@ -3,7 +3,6 @@ import { initVal } from "@valbuild/next"; const { s, c, val, config, nextAppRouter } = initVal({ project: "valbuild/val-examples-next", root: "/examples/next", - defaultTheme: "dark", }); export type { t } from "@valbuild/next"; diff --git a/examples/next/val.modules.ts b/examples/next/val.modules.ts index 685a27ebe..a39feacec 100644 --- a/examples/next/val.modules.ts +++ b/examples/next/val.modules.ts @@ -3,7 +3,8 @@ import { config } from "./val.config"; export default modules(config, [ { def: () => import("./content/authors.val") }, - { def: () => import("./app/blogs/[blog]/page.val") }, + { def: () => import("./app/[locale]/blog/[blog]/en-us.val") }, + { def: () => import("./app/[locale]/blog/[blog]/nb-no.val") }, { def: () => import("./app/generic/[[...path]]/page.val") }, { def: () => import("./app/page.val") }, ]); diff --git a/packages/next/src/initVal.ts b/packages/next/src/initVal.ts index 8a7e1f8e8..d549949b9 100644 --- a/packages/next/src/initVal.ts +++ b/packages/next/src/initVal.ts @@ -7,7 +7,7 @@ import { SelectorSource, Json, Internal, - ValRouter, + type NextAppRouter, } from "@valbuild/core"; import { raw } from "./raw"; import { getUnpatchedUnencodedVal } from "./getUnpatchedUnencodedVal"; @@ -16,7 +16,7 @@ import { ValEncodedString } from "./external_exempt_from_val_quickjs"; type ValAttrs = { "data-val-path"?: string }; -const nextAppRouter: ValRouter = Internal.nextAppRouter; +const nextAppRouter: NextAppRouter = Internal.nextAppRouter; export const initVal = ( config?: ValConfig, @@ -39,7 +39,7 @@ export const initVal = ( attrs: | Json>(target: T) => ValAttrs; unstable_decodeValPathOfString: typeof decodeValPathOfString; }; - nextAppRouter: ValRouter; + nextAppRouter: NextAppRouter; } => { const { s, c, val, config: systemConfig } = createValSystem(config); const currentConfig = { diff --git a/packages/next/src/rsc/initValRsc.ts b/packages/next/src/rsc/initValRsc.ts index 4637bccf7..5e4004e5e 100644 --- a/packages/next/src/rsc/initValRsc.ts +++ b/packages/next/src/rsc/initValRsc.ts @@ -188,7 +188,7 @@ const initFetchValRouteStega = }>, ) => async >>( - selector: T, + selector: T | T[], params: | Promise> | Record @@ -203,17 +203,20 @@ const initFetchValRouteStega = getCookies, ); const resolvedParams = await Promise.resolve(params); - const path = selector && Internal.getValPath(selector); - const schema = selector && Internal.getSchema(selector); - const val = selector && (await fetchVal(selector)); - const route = initValRouteFromVal( - resolvedParams, - "fetchValRoute", - path, - schema, - val, - ); - return route; + for (const s of Array.isArray(selector) ? selector : [selector]) { + const path = s && Internal.getValPath(s); + const schema = s && Internal.getSchema(s); + const val = s && (await fetchVal(s)); + const route = initValRouteFromVal( + resolvedParams, + "fetchValRoute", + path, + schema, + val, + ); + return route; + } + return null as FetchValRouteReturnType; }; const initFetchValRouteUrl =