From 13c75e6371265de4a14a7e3a0019b232b6f7fa78 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 17:06:05 +0200 Subject: [PATCH 01/16] Possible to store files under public/ not only public/val --- packages/cli/src/runValidation.ts | 6 +++--- packages/core/src/schema/files.ts | 2 +- packages/core/src/schema/images.ts | 12 ++++++------ packages/core/src/source/image.ts | 2 +- packages/core/src/source/remote.ts | 4 ++-- packages/ui/spa/search/search.worker.ts | 2 +- packages/ui/spa/utils/getFilenameFromRef.ts | 10 +++++----- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/runValidation.ts b/packages/cli/src/runValidation.ts index 4c27f2b64..8b08db20c 100644 --- a/packages/cli/src/runValidation.ts +++ b/packages/cli/src/runValidation.ts @@ -335,12 +335,12 @@ export async function handleRemoteFileUpload( const relativeFilePath = path .relative(ctx.projectRoot, filePath) .split(path.sep) - .join("/") as `public/val/${string}`; + .join("/") as `public/${string}`; - if (!relativeFilePath.startsWith("public/val/")) { + if (!relativeFilePath.startsWith("public")) { return { success: false, - errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`, + errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`, }; } diff --git a/packages/core/src/schema/files.ts b/packages/core/src/schema/files.ts index ef645ac76..c705fe0b7 100644 --- a/packages/core/src/schema/files.ts +++ b/packages/core/src/schema/files.ts @@ -60,7 +60,7 @@ export const files = ( Schema, Record > => { - const directory = options.directory ?? "/public/val"; + const directory = options.directory ?? "/public"; const itemSchema = new ObjectSchema( { mimeType: new StringSchema({}, false) }, false, diff --git a/packages/core/src/schema/images.ts b/packages/core/src/schema/images.ts index 38b308002..c4aab059b 100644 --- a/packages/core/src/schema/images.ts +++ b/packages/core/src/schema/images.ts @@ -20,7 +20,7 @@ export type ImagesOptions = { /** * The accepted mime type pattern. Must be an image type (e.g., "image/png", "image/webp", "image/*") */ - accept: Accept; + accept?: Accept; /** * The directory where images should be stored. * Must start with "/public" (e.g., "/public/val/images") @@ -92,14 +92,14 @@ type ImagesItemSrc = { * ``` */ export const images = ( - options: ImagesOptions, + options?: ImagesOptions, ): RecordSchema< ObjectSchema, Schema, Record > => { - const directory = options.directory ?? "/public/val"; - const altSchema = options.alt ?? string().nullable(); + const directory = options?.directory ?? "/public/val"; + const altSchema = options?.alt ?? string().nullable(); const itemSchema = new ObjectSchema( { width: new NumberSchema(undefined, false), @@ -111,9 +111,9 @@ export const images = ( ) as ObjectSchema; return new RecordSchema(itemSchema, false, [], null, null, { type: "images", - accept: options.accept, + accept: options?.accept ?? "image/*", directory, - remote: options.remote ?? false, + remote: options?.remote ?? false, altSchema, }); }; diff --git a/packages/core/src/source/image.ts b/packages/core/src/source/image.ts index 465db5a6c..97f9bcca7 100644 --- a/packages/core/src/source/image.ts +++ b/packages/core/src/source/image.ts @@ -19,7 +19,7 @@ export type ImageSource< export const initImage = (config?: ValConfig) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const fileDirectory = config?.files?.directory ?? "/public/val"; + const fileDirectory = config?.files?.directory ?? "/public"; type FileDirectory = typeof fileDirectory; diff --git a/packages/core/src/source/remote.ts b/packages/core/src/source/remote.ts index 56b25130e..ec5991906 100644 --- a/packages/core/src/source/remote.ts +++ b/packages/core/src/source/remote.ts @@ -31,7 +31,7 @@ export const initRemote = (config?: ValConfig) => { }; export type RemoteRef = - `${string}/file/p/${string}/v/${string}/h/${string}/f/${string}/p/public/val/${string}`; + `${string}/file/p/${string}/v/${string}/h/${string}/f/${string}/p/public/${string}`; export function createRemoteRef( remoteHost: string, @@ -47,7 +47,7 @@ export function createRemoteRef( coreVersion: string; validationHash: string; fileHash: string; - filePath: `public/val/${string}`; + filePath: `public/${string}`; bucket: string; }, ): RemoteRef { diff --git a/packages/ui/spa/search/search.worker.ts b/packages/ui/spa/search/search.worker.ts index c7a2f3348..7594185ae 100644 --- a/packages/ui/spa/search/search.worker.ts +++ b/packages/ui/spa/search/search.worker.ts @@ -69,7 +69,7 @@ function buildIndex( ) { const filename = source[FILE_REF_PROP] as string; // Extract just the filename from the path - const filenameOnly = filename.replace("/public/val/", ""); + const filenameOnly = filename.replace("/public", ""); const metadata = source?.metadata; const alt = metadata && typeof metadata === "object" && "alt" in metadata diff --git a/packages/ui/spa/utils/getFilenameFromRef.ts b/packages/ui/spa/utils/getFilenameFromRef.ts index 1ff766857..13017628a 100644 --- a/packages/ui/spa/utils/getFilenameFromRef.ts +++ b/packages/ui/spa/utils/getFilenameFromRef.ts @@ -10,7 +10,7 @@ function cleanRefPath(ref: string): string { /** * Extract a human-readable filename from a file ref (local path or remote URL). - * Handles both remote refs (via splitRemoteRef) and plain `/public/val/...` paths. + * Handles both remote refs (via splitRemoteRef) and plain `/public/...` paths. */ export function getFilenameFromRef(ref: string): string { const cleanPath = cleanRefPath(ref); @@ -19,10 +19,10 @@ export function getFilenameFromRef(ref: string): string { /** * Parse a file ref into its constituent parts: a clean path, filename, and - * folder (the path relative to `/public/val`). + * folder (the path relative to `/public`). * - * The `folder` strips the standard `/public/val` prefix so it shows a - * concise location like `/images` instead of `/public/val/images`. + * The `folder` strips the standard `/public` prefix so it shows a + * concise location like `/images` instead of `/public/images`. */ export function getRefParts(ref: string): { cleanPath: string; @@ -32,6 +32,6 @@ export function getRefParts(ref: string): { const cleanPath = cleanRefPath(ref); const filename = cleanPath.split("/").pop() || cleanPath; const folder = - cleanPath.replace("/public/val", "").replace(/\/[^/]+$/, "") || "/"; + cleanPath.replace("/public", "").replace(/\/[^/]+$/, "") || "/"; return { cleanPath, filename, folder }; } From e1d694f566b8633297cd1b1d9a355d7e3d8a659c Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 17:45:05 +0200 Subject: [PATCH 02/16] Allow files in public dir, not only public/val dir. --- packages/cli/src/cli.ts | 2 +- packages/cli/src/listUnusedFiles.ts | 11 ++++++++++- packages/cli/src/runValidation.ts | 2 +- packages/cli/src/utils/evalValConfigFile.ts | 10 +++++++--- packages/core/src/initVal.ts | 2 +- packages/core/src/remote/splitRemoteRef.ts | 6 +++--- packages/core/src/schema/files.ts | 2 +- packages/core/src/source/image.ts | 2 +- packages/server/src/checkRemoteRef.ts | 6 +++--- packages/server/src/createFixPatch.ts | 4 ++-- packages/shared/src/internal/SharedValConfig.ts | 10 +++++++++- .../richtext/conversion/remirrorToRichTextSource.ts | 2 +- packages/ui/spa/components/fields/FileField.tsx | 2 +- packages/ui/spa/components/fields/ModuleGallery.tsx | 2 +- 14 files changed, 42 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2be3aaa7e..14af857ef 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -43,7 +43,7 @@ async function main(): Promise { Command: list-unused-files Description: EXPERIMENTAL. - List files that are in public/val but not in use by any Val module. + List files that are in the configured files directory (files.directory, default public/val) but not in use by any Val module. This is useful for cleaning up unused files. Options: --root [root], -r [root] Set project root directory (default process.cwd()) diff --git a/packages/cli/src/listUnusedFiles.ts b/packages/cli/src/listUnusedFiles.ts index 47976e574..eeccdce75 100644 --- a/packages/cli/src/listUnusedFiles.ts +++ b/packages/cli/src/listUnusedFiles.ts @@ -8,11 +8,20 @@ import { import { createService } from "@valbuild/server"; import { glob } from "fast-glob"; import path from "path"; +import { evalValConfigFile } from "./utils/evalValConfigFile"; export async function listUnusedFiles({ root }: { root?: string }) { - const managedDir = "public/val"; const projectRoot = root ? path.resolve(root) : process.cwd(); + const valConfigFile = + (await evalValConfigFile(projectRoot, "val.config.ts")) || + (await evalValConfigFile(projectRoot, "val.config.js")); + // Strip the leading "/" so it is relative to the project root (e.g. "public/val"). + const managedDir = (valConfigFile?.files?.directory ?? "/public/val").replace( + /^\//, + "", + ); + const service = await createService(projectRoot, {}); const valFiles: string[] = await glob("**/*.val.{js,ts}", { diff --git a/packages/cli/src/runValidation.ts b/packages/cli/src/runValidation.ts index 8b08db20c..4758df2ac 100644 --- a/packages/cli/src/runValidation.ts +++ b/packages/cli/src/runValidation.ts @@ -337,7 +337,7 @@ export async function handleRemoteFileUpload( .split(path.sep) .join("/") as `public/${string}`; - if (!relativeFilePath.startsWith("public")) { + if (!relativeFilePath.startsWith("public/")) { return { success: false, errorMessage: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`, diff --git a/packages/cli/src/utils/evalValConfigFile.ts b/packages/cli/src/utils/evalValConfigFile.ts index b488f0aff..6686f164b 100644 --- a/packages/cli/src/utils/evalValConfigFile.ts +++ b/packages/cli/src/utils/evalValConfigFile.ts @@ -13,9 +13,13 @@ const ValConfigSchema = z.object({ .object({ directory: z .string() - .refine((val): val is `/public/val` => val.startsWith("/public/val"), { - message: "files.directory must start with '/public/val'", - }), + .refine( + (val): val is `/public` | `/public/${string}` => + val === "/public" || val.startsWith("/public/"), + { + message: "files.directory must start with '/public'", + }, + ), }) .optional(), gitCommit: z.string().optional(), diff --git a/packages/core/src/initVal.ts b/packages/core/src/initVal.ts index 873e6b96c..ff738279b 100644 --- a/packages/core/src/initVal.ts +++ b/packages/core/src/initVal.ts @@ -18,7 +18,7 @@ export type ValConstructor = { unstable_getPath: typeof getPath; }; -export type ConfigDirectory = `/public/val`; +export type ConfigDirectory = `/public` | `/public/${string}`; export type ValConfig = { project?: string; diff --git a/packages/core/src/remote/splitRemoteRef.ts b/packages/core/src/remote/splitRemoteRef.ts index 13078db2f..a7b61e53b 100644 --- a/packages/core/src/remote/splitRemoteRef.ts +++ b/packages/core/src/remote/splitRemoteRef.ts @@ -10,7 +10,7 @@ export function splitRemoteRef(ref: string): version: string; validationHash: string; fileHash: string; - filePath: `public/val/${string}`; + filePath: `public/${string}`; } | { status: "error"; @@ -29,7 +29,7 @@ export function splitRemoteRef(ref: string): error: "Invalid remote ref: " + ref, }; } - if (match[7].indexOf("public/val/") !== 0) { + if (match[7].indexOf("public/") !== 0) { return { status: "error", error: "Invalid remote ref: " + ref, @@ -44,6 +44,6 @@ export function splitRemoteRef(ref: string): version: match[4], validationHash: match[5], fileHash: match[6], - filePath: match[7] as `public/val/${string}`, + filePath: match[7] as `public/${string}`, }; } diff --git a/packages/core/src/schema/files.ts b/packages/core/src/schema/files.ts index c705fe0b7..ef645ac76 100644 --- a/packages/core/src/schema/files.ts +++ b/packages/core/src/schema/files.ts @@ -60,7 +60,7 @@ export const files = ( Schema, Record > => { - const directory = options.directory ?? "/public"; + const directory = options.directory ?? "/public/val"; const itemSchema = new ObjectSchema( { mimeType: new StringSchema({}, false) }, false, diff --git a/packages/core/src/source/image.ts b/packages/core/src/source/image.ts index 97f9bcca7..465db5a6c 100644 --- a/packages/core/src/source/image.ts +++ b/packages/core/src/source/image.ts @@ -19,7 +19,7 @@ export type ImageSource< export const initImage = (config?: ValConfig) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const fileDirectory = config?.files?.directory ?? "/public"; + const fileDirectory = config?.files?.directory ?? "/public/val"; type FileDirectory = typeof fileDirectory; diff --git a/packages/server/src/checkRemoteRef.ts b/packages/server/src/checkRemoteRef.ts index 0b6e17994..c377b0b9d 100644 --- a/packages/server/src/checkRemoteRef.ts +++ b/packages/server/src/checkRemoteRef.ts @@ -49,10 +49,10 @@ export async function checkRemoteRef( error: `File path is missing in remote ref: ${ref}`, }; } - if (!relativeFilePath.startsWith("public/val/")) { + if (!relativeFilePath.startsWith("public/")) { return { status: "error", - error: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`, + error: `File path must be within the public/ directory (e.g. public/path/to/file.txt). Got: ${relativeFilePath}`, }; } const coreVersion = Internal.VERSION.core || "unknown"; @@ -148,7 +148,7 @@ export async function checkRemoteRef( } const newFileExt = Internal.mimeTypeToFileExt(updatedMetadata.mimeType); const newFilePath = (relativeFilePath.slice(0, -fileExt.length) + - newFileExt) as `public/val/${string}`; + newFileExt) as `public/${string}`; return { status: "fix-required", metadata: updatedMetadata, diff --git a/packages/server/src/createFixPatch.ts b/packages/server/src/createFixPatch.ts index 3b6e2eeb6..765f65104 100644 --- a/packages/server/src/createFixPatch.ts +++ b/packages/server/src/createFixPatch.ts @@ -317,11 +317,11 @@ export async function createFixPatch( }); continue; } - if (!filePath.startsWith("public/val/")) { + if (!filePath.startsWith("public/")) { remainingErrors.push({ ...validationError, message: - "Unexpected error while downloading remote (invalid file path - must start with public/val/)", + "Unexpected error while downloading remote (invalid file path - must start with public/)", fixes: undefined, }); continue; diff --git a/packages/shared/src/internal/SharedValConfig.ts b/packages/shared/src/internal/SharedValConfig.ts index fde829620..e576605f9 100644 --- a/packages/shared/src/internal/SharedValConfig.ts +++ b/packages/shared/src/internal/SharedValConfig.ts @@ -13,7 +13,15 @@ export const SharedValConfig: z.ZodSchema< root: z.string().optional(), files: z .object({ - directory: z.literal("/public/val"), + directory: z + .string() + .refine( + (val): val is `/public` | `/public/${string}` => + val === "/public" || val.startsWith("/public/"), + { + message: "files.directory must start with '/public'", + }, + ), }) .optional(), gitCommit: z.string().optional(), diff --git a/packages/shared/src/internal/richtext/conversion/remirrorToRichTextSource.ts b/packages/shared/src/internal/richtext/conversion/remirrorToRichTextSource.ts index 9b58cbca9..90260c508 100644 --- a/packages/shared/src/internal/richtext/conversion/remirrorToRichTextSource.ts +++ b/packages/shared/src/internal/richtext/conversion/remirrorToRichTextSource.ts @@ -413,7 +413,7 @@ function convertImageNode( remoteFileHash, textEncoder, ), - filePath: filePath.slice(1) as `public/val/${string}`, + filePath: filePath.slice(1) as `public/${string}`, }) : (filePath as `/public/${string}`); if (existingFilesEntry) { diff --git a/packages/ui/spa/components/fields/FileField.tsx b/packages/ui/spa/components/fields/FileField.tsx index 56fb09de1..e320ba5ab 100644 --- a/packages/ui/spa/components/fields/FileField.tsx +++ b/packages/ui/spa/components/fields/FileField.tsx @@ -85,7 +85,7 @@ export async function createFilePatch( textEncoder, ), fileHash: remoteFileHash, - filePath: `${(directory ?? "/public/val").slice(1) as `public/val/${string}`}/${newFilePath}`, + filePath: `${(directory ?? "/public/val").slice(1) as `public/${string}`}/${newFilePath}`, }) : filePath; return { diff --git a/packages/ui/spa/components/fields/ModuleGallery.tsx b/packages/ui/spa/components/fields/ModuleGallery.tsx index 646ede370..9b613f1f1 100644 --- a/packages/ui/spa/components/fields/ModuleGallery.tsx +++ b/packages/ui/spa/components/fields/ModuleGallery.tsx @@ -302,7 +302,7 @@ export function ModuleGallery({ bucket: remoteData.bucket, validationHash, fileHash: remoteFileHash, - filePath: `${directory.slice(1) as `public/val/${string}`}/${newFilename}`, + filePath: `${directory.slice(1) as `public/${string}`}/${newFilename}`, }); isRemote = true; } else { From 869bc9d35670f31868bb89ae090132b6edce6701 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 17:48:20 +0200 Subject: [PATCH 03/16] Changeset --- .changeset/public-dir-not-only-public-val.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/public-dir-not-only-public-val.md diff --git a/.changeset/public-dir-not-only-public-val.md b/.changeset/public-dir-not-only-public-val.md new file mode 100644 index 000000000..03255cea9 --- /dev/null +++ b/.changeset/public-dir-not-only-public-val.md @@ -0,0 +1,5 @@ +--- +"@valbuild/core": patch +--- + +Allow storing files anywhere under `/public`, not only `/public/val`. Config validation, remote refs, and the studio file/image fields now accept any `/public/...` directory (the default remains `/public/val`). Also removed the `files` property from `SharedValConfig` — the per-schema `s.files`/`s.images` directory is now the source of truth. From 957aacc4f54edd6eb3a7736514fff8165cc5f05a Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 20:24:58 +0200 Subject: [PATCH 04/16] Remove "files" directory option --- packages/shared/src/internal/SharedValConfig.ts | 16 ++-------------- packages/ui/spa/components/fields/FileField.tsx | 2 +- packages/ui/spa/components/fields/ImageField.tsx | 2 +- .../ui/spa/components/fields/RichTextField.tsx | 2 +- 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/shared/src/internal/SharedValConfig.ts b/packages/shared/src/internal/SharedValConfig.ts index e576605f9..41d0f09b4 100644 --- a/packages/shared/src/internal/SharedValConfig.ts +++ b/packages/shared/src/internal/SharedValConfig.ts @@ -2,7 +2,8 @@ import { ValConfig } from "@valbuild/core"; import { z } from "zod"; export const SharedValConfig: z.ZodSchema< - ValConfig & { + // The files directory is not shared - the per-schema s.files/s.images directory is the source of truth. + Omit & { // We are adding URLs and other server only config options here contentHostUrl?: string; } @@ -11,19 +12,6 @@ export const SharedValConfig: z.ZodSchema< appHostUrl: z.string().optional(), project: z.string().optional(), root: z.string().optional(), - files: z - .object({ - directory: z - .string() - .refine( - (val): val is `/public` | `/public/${string}` => - val === "/public" || val.startsWith("/public/"), - { - message: "files.directory must start with '/public'", - }, - ), - }) - .optional(), gitCommit: z.string().optional(), gitBranch: z.string().optional(), defaultTheme: z.enum(["dark", "light"]).optional(), diff --git a/packages/ui/spa/components/fields/FileField.tsx b/packages/ui/spa/components/fields/FileField.tsx index e320ba5ab..0648ae3fd 100644 --- a/packages/ui/spa/components/fields/FileField.tsx +++ b/packages/ui/spa/components/fields/FileField.tsx @@ -438,7 +438,7 @@ export function FileField({ metadata, type, remoteData, - moduleDirectory ?? config.files?.directory, + moduleDirectory, !!referencedModule, ) .then(({ patch, filePath }) => { diff --git a/packages/ui/spa/components/fields/ImageField.tsx b/packages/ui/spa/components/fields/ImageField.tsx index 322fe6205..3ce68e94c 100644 --- a/packages/ui/spa/components/fields/ImageField.tsx +++ b/packages/ui/spa/components/fields/ImageField.tsx @@ -223,7 +223,7 @@ export function ImageField({ addAndUploadPatchWithFileOps, addModuleFilePatch, remoteData, - directory: moduleDirectory ?? config.files?.directory, + directory: moduleDirectory, referencedModule, existingAlt, }); diff --git a/packages/ui/spa/components/fields/RichTextField.tsx b/packages/ui/spa/components/fields/RichTextField.tsx index bf80fd556..99ae7bb93 100644 --- a/packages/ui/spa/components/fields/RichTextField.tsx +++ b/packages/ui/spa/components/fields/RichTextField.tsx @@ -152,7 +152,7 @@ export function RichTextField({ config, ]); - const imageDirectory = imageModuleDirectory ?? config?.files?.directory; + const imageDirectory = imageModuleDirectory; const onImageUpload = useMemo(() => { if (!hasImageEnabled) return undefined; From eb38fb1ec141e8c0a9d34dd9ee5b560d5cbaaa1c Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 22:15:35 +0200 Subject: [PATCH 05/16] fix(ui): anchor /public prefix strip in getRefParts Co-Authored-By: Claude Opus 4.8 --- packages/ui/spa/utils/getFilenameFromRef.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/spa/utils/getFilenameFromRef.ts b/packages/ui/spa/utils/getFilenameFromRef.ts index 13017628a..b6ba72df3 100644 --- a/packages/ui/spa/utils/getFilenameFromRef.ts +++ b/packages/ui/spa/utils/getFilenameFromRef.ts @@ -32,6 +32,6 @@ export function getRefParts(ref: string): { const cleanPath = cleanRefPath(ref); const filename = cleanPath.split("/").pop() || cleanPath; const folder = - cleanPath.replace("/public", "").replace(/\/[^/]+$/, "") || "/"; + cleanPath.replace(/^\/public/, "").replace(/\/[^/]+$/, "") || "/"; return { cleanPath, filename, folder }; } From 5e242af486960b13e10f6692073a82f15251bc84 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 22:15:50 +0200 Subject: [PATCH 06/16] fix(ui): extract real basename for file/image search labels Co-Authored-By: Claude Opus 4.8 --- packages/ui/spa/search/search.worker.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/ui/spa/search/search.worker.ts b/packages/ui/spa/search/search.worker.ts index 7594185ae..cea3974dc 100644 --- a/packages/ui/spa/search/search.worker.ts +++ b/packages/ui/spa/search/search.worker.ts @@ -11,6 +11,7 @@ import { traverseSchemaSource, flattenRichText, } from "../utils/traverseSchemaSource"; +import { getFilenameFromRef } from "../utils/getFilenameFromRef"; import type { WorkerRequest, WorkerResponse } from "./worker-types"; let index: Index | null = null; @@ -68,8 +69,8 @@ function buildIndex( typeof source[FILE_REF_PROP] === "string" ) { const filename = source[FILE_REF_PROP] as string; - // Extract just the filename from the path - const filenameOnly = filename.replace("/public", ""); + // Extract just the basename (handles local and remote refs) + const filenameOnly = getFilenameFromRef(filename); const metadata = source?.metadata; const alt = metadata && typeof metadata === "object" && "alt" in metadata From cd4a3339e2023157d056f8ae146b8a738a1e4260 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 22:25:06 +0200 Subject: [PATCH 07/16] fix(ui): cast full filePath expression for remote refs Co-Authored-By: Claude Opus 4.8 --- packages/ui/spa/components/fields/FileField.tsx | 3 ++- packages/ui/spa/components/fields/ModuleGallery.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/spa/components/fields/FileField.tsx b/packages/ui/spa/components/fields/FileField.tsx index 0648ae3fd..b4f509f09 100644 --- a/packages/ui/spa/components/fields/FileField.tsx +++ b/packages/ui/spa/components/fields/FileField.tsx @@ -85,7 +85,8 @@ export async function createFilePatch( textEncoder, ), fileHash: remoteFileHash, - filePath: `${(directory ?? "/public/val").slice(1) as `public/${string}`}/${newFilePath}`, + filePath: + `${(directory ?? "/public/val").slice(1)}/${newFilePath}` as `public/${string}`, }) : filePath; return { diff --git a/packages/ui/spa/components/fields/ModuleGallery.tsx b/packages/ui/spa/components/fields/ModuleGallery.tsx index 9b613f1f1..dc54daa1f 100644 --- a/packages/ui/spa/components/fields/ModuleGallery.tsx +++ b/packages/ui/spa/components/fields/ModuleGallery.tsx @@ -302,7 +302,8 @@ export function ModuleGallery({ bucket: remoteData.bucket, validationHash, fileHash: remoteFileHash, - filePath: `${directory.slice(1) as `public/${string}`}/${newFilename}`, + filePath: + `${directory.slice(1)}/${newFilename}` as `public/${string}`, }); isRemote = true; } else { From 1a5d497266d458691db931770fd6e13888503bfd Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 22:25:06 +0200 Subject: [PATCH 08/16] fix(cli): reject trailing slash in files.directory config Co-Authored-By: Claude Opus 4.8 --- packages/cli/src/utils/evalValConfigFile.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/evalValConfigFile.ts b/packages/cli/src/utils/evalValConfigFile.ts index 6686f164b..40403e1c1 100644 --- a/packages/cli/src/utils/evalValConfigFile.ts +++ b/packages/cli/src/utils/evalValConfigFile.ts @@ -15,9 +15,11 @@ const ValConfigSchema = z.object({ .string() .refine( (val): val is `/public` | `/public/${string}` => - val === "/public" || val.startsWith("/public/"), + val === "/public" || + (val.startsWith("/public/") && !val.endsWith("/")), { - message: "files.directory must start with '/public'", + message: + "files.directory must start with '/public' and not end with '/'", }, ), }) From feb432f1379849e810f7cd20c78fbf290d3b3cca Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Wed, 24 Jun 2026 22:25:06 +0200 Subject: [PATCH 09/16] fix(core): reject path-traversal segments in splitRemoteRef Co-Authored-By: Claude Opus 4.8 --- packages/core/src/remote/splitRemoteRef.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/core/src/remote/splitRemoteRef.ts b/packages/core/src/remote/splitRemoteRef.ts index a7b61e53b..82b19d382 100644 --- a/packages/core/src/remote/splitRemoteRef.ts +++ b/packages/core/src/remote/splitRemoteRef.ts @@ -35,6 +35,14 @@ export function splitRemoteRef(ref: string): error: "Invalid remote ref: " + ref, }; } + if ( + match[7].split("/").some((segment) => segment === "." || segment === "..") + ) { + return { + status: "error", + error: "Invalid remote ref: " + ref, + }; + } return { status: "success", From 1224336188504e093bfbd6da5ad1ea8401fdaeee Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 28 Jul 2026 09:33:41 +0200 Subject: [PATCH 10/16] fix(core): include bucket segment in RemoteRef type The RemoteRef template literal was missing the `/b/{bucket}` segment that createRemoteRef writes and splitRemoteRef parses, so the "project id" segment could silently absorb `/b/...`. Co-Authored-By: Claude Opus 5 --- packages/core/src/source/remote.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/source/remote.ts b/packages/core/src/source/remote.ts index ec5991906..65e331575 100644 --- a/packages/core/src/source/remote.ts +++ b/packages/core/src/source/remote.ts @@ -30,8 +30,9 @@ export const initRemote = (config?: ValConfig) => { return remote; }; +// NOTE: the segments must match the ref built by createRemoteRef below and the RegEx in splitRemoteRef. export type RemoteRef = - `${string}/file/p/${string}/v/${string}/h/${string}/f/${string}/p/public/${string}`; + `${string}/file/p/${string}/b/${string}/v/${string}/h/${string}/f/${string}/p/public/${string}`; export function createRemoteRef( remoteHost: string, From 581583975afd6ebcac351ff10a819a1633a20a38 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 28 Jul 2026 09:33:41 +0200 Subject: [PATCH 11/16] test(core): cover public/ prefix and traversal rejection in splitRemoteRef Covers the broadened `public/...` file paths and the `.`/`..` segment rejection, including that dots inside a file name are not traversal. Co-Authored-By: Claude Opus 5 --- .../core/src/remote/splitRemoteRef.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/packages/core/src/remote/splitRemoteRef.test.ts b/packages/core/src/remote/splitRemoteRef.test.ts index 2c97a53dd..523b2aef0 100644 --- a/packages/core/src/remote/splitRemoteRef.test.ts +++ b/packages/core/src/remote/splitRemoteRef.test.ts @@ -56,6 +56,77 @@ describe("splitRemoteRef", () => { }); }); + it("should handle a file path directly under public", () => { + const ref = + "https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/images/test.png"; + const result = splitRemoteRef(ref); + + expect(result).toEqual({ + status: "success", + remoteHost: "https://remote.val.build", + projectId: "project123", + bucket: "01", + version: "1.0.0", + validationHash: "abc123", + fileHash: "def456", + filePath: "public/images/test.png", + }); + }); + + it("should return an error if the file path is not within public", () => { + const ref = + "https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/secret/test.png"; + const result = splitRemoteRef(ref); + + expect(result).toEqual({ + status: "error", + error: "Invalid remote ref: " + ref, + }); + }); + + it("should return an error if the file path traverses out of public", () => { + const ref = + "https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/../secret/test.png"; + const result = splitRemoteRef(ref); + + expect(result).toEqual({ + status: "error", + error: "Invalid remote ref: " + ref, + }); + }); + + it("should return an error if the file path contains a current dir segment", () => { + const ref = + "https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/./test.png"; + const result = splitRemoteRef(ref); + + expect(result).toEqual({ + status: "error", + error: "Invalid remote ref: " + ref, + }); + }); + + it("should not confuse a file name containing dots with traversal", () => { + const ref = + "https://remote.val.build/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/..hidden..test.png"; + const result = splitRemoteRef(ref); + + expect(result).toMatchObject({ + status: "success", + filePath: "public/val/..hidden..test.png", + }); + }); + + it("should return an error for a local ref", () => { + const ref = "/public/val/test.png"; + const result = splitRemoteRef(ref); + + expect(result).toEqual({ + status: "error", + error: "Not a remote ref: " + ref, + }); + }); + it("should handle a remote ref with an HTTP host", () => { const ref = "http://example.com/file/p/project123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/test.png"; From 38ad44246b2271c2c26baa0a23beddb7fcef9183 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 28 Jul 2026 09:33:48 +0200 Subject: [PATCH 12/16] fix(cli): reject path traversal in files.directory config Aligns files.directory validation with the remote-ref checks so the directory cannot escape /public via `.` or `..` segments. Co-Authored-By: Claude Opus 5 --- packages/cli/src/utils/evalValConfigFile.ts | 24 +++++++++++---------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/utils/evalValConfigFile.ts b/packages/cli/src/utils/evalValConfigFile.ts index 40403e1c1..78343d1f2 100644 --- a/packages/cli/src/utils/evalValConfigFile.ts +++ b/packages/cli/src/utils/evalValConfigFile.ts @@ -11,17 +11,19 @@ const ValConfigSchema = z.object({ root: z.string().optional(), files: z .object({ - directory: z - .string() - .refine( - (val): val is `/public` | `/public/${string}` => - val === "/public" || - (val.startsWith("/public/") && !val.endsWith("/")), - { - message: - "files.directory must start with '/public' and not end with '/'", - }, - ), + directory: z.string().refine( + (val): val is `/public` | `/public/${string}` => + (val === "/public" || + (val.startsWith("/public/") && !val.endsWith("/"))) && + // Reject path traversal so the directory cannot escape /public + !val + .split("/") + .some((segment) => segment === "." || segment === ".."), + { + message: + "files.directory must start with '/public', must not end with '/' and must not contain '.' or '..' segments", + }, + ), }) .optional(), gitCommit: z.string().optional(), From 1daa92c5b3a72a3ce7a190abe16d56a181b9082b Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 28 Jul 2026 09:33:48 +0200 Subject: [PATCH 13/16] docs(core): document s.images() defaults and cover them with tests accept is now optional and defaults to "image/*" - document that on the option and on s.images() itself, and add tests for the accept, directory, alt and remote defaults when options are omitted. Co-Authored-By: Claude Opus 5 --- packages/core/src/schema/images.test.ts | 107 ++++++++++++++++++++++++ packages/core/src/schema/images.ts | 4 + 2 files changed, 111 insertions(+) diff --git a/packages/core/src/schema/images.test.ts b/packages/core/src/schema/images.test.ts index f5af9d3e4..5ade2d7e4 100644 --- a/packages/core/src/schema/images.test.ts +++ b/packages/core/src/schema/images.test.ts @@ -558,6 +558,113 @@ describe("ImagesSchema", () => { }); }); + describe("defaults", () => { + test("should default accept to image/* when options are omitted", () => { + const schema = images(); + const serialized = schema["executeSerialize"](); + expect((serialized as SerializedImagesSchema).mediaType).toBe("images"); + expect(serialized.accept).toBe("image/*"); + expect(serialized.directory).toBe("/public/val"); + expect(serialized.remote).toBe(false); + expect(serialized.opt).toBe(false); + }); + + test("should default accept to image/* when options are empty", () => { + const serialized = images({})["executeSerialize"](); + expect(serialized.accept).toBe("image/*"); + expect(serialized.directory).toBe("/public/val"); + }); + + test("should default accept to image/* when only directory is given", () => { + const serialized = images({ directory: "/public/images" })[ + "executeSerialize" + ](); + expect(serialized.accept).toBe("image/*"); + expect(serialized.directory).toBe("/public/images"); + }); + + test("should default alt to a nullable string when options are omitted", () => { + const serialized = images()["executeSerialize"](); + expect(serialized.alt).toMatchObject({ type: "string", opt: true }); + }); + + test("should accept any image mime type when options are omitted", () => { + const schema = images(); + const src: Record = { + "/public/val/test.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: null, + }, + }; + expect( + filterCheckErrors(schema["executeValidate"]("path" as SourcePath, src)), + ).toBeFalsy(); + }); + + test("should reject non-image mime types when options are omitted", () => { + const schema = images(); + const src = { + "/public/val/test.pdf": { + width: 800, + height: 600, + mimeType: "application/pdf", + alt: null, + }, + }; + const result = schema["executeValidate"]( + "path" as SourcePath, + src as unknown as Record, + ); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasMimeError = errors.some((e: { message: string }) => + e.message.includes("Mime type mismatch"), + ); + expect(hasMimeError).toBe(true); + }); + + test("should use the default /public/val directory when options are omitted", () => { + const schema = images(); + const src: Record = { + "/public/other/test.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: null, + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("must be within the /public/val/ directory"), + ); + expect(hasDirError).toBe(true); + }); + + test("should not allow remote refs when options are omitted", () => { + const schema = images(); + const src: Record = { + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/image.webp": + { + width: 800, + height: 600, + mimeType: "image/webp", + alt: null, + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasRemoteError = errors.some((e: { message: string }) => + e.message.includes("Remote URLs are not allowed"), + ); + expect(hasRemoteError).toBe(true); + }); + }); + describe("custom validation", () => { test("should support custom validation function", () => { const schema = images({ accept: "image/webp" }).validate((src) => { diff --git a/packages/core/src/schema/images.ts b/packages/core/src/schema/images.ts index c4aab059b..f45be2918 100644 --- a/packages/core/src/schema/images.ts +++ b/packages/core/src/schema/images.ts @@ -19,6 +19,7 @@ export type AltSchema = export type ImagesOptions = { /** * The accepted mime type pattern. Must be an image type (e.g., "image/png", "image/webp", "image/*") + * @default "image/*" */ accept?: Accept; /** @@ -74,6 +75,9 @@ type ImagesItemSrc = { /** * Define a collection of images. * + * All options are optional: `s.images()` accepts any image type (`"image/*"`) in + * the default `/public/val` directory, with nullable alt text and remote disabled. + * * @example * ```typescript * const schema = s.images({ From cd5c3e42121db21508251aa2d5fe6b151ad4c54a Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 28 Jul 2026 09:33:55 +0200 Subject: [PATCH 14/16] fix(ui): strip /public from folder only on a whole path segment /^\/public/ also matched refs like "/publicity/a.png", mangling the displayed folder. Anchor on a segment boundary and add tests for getFilenameFromRef/getRefParts, which had none. Co-Authored-By: Claude Opus 5 --- .../ui/spa/utils/getFilenameFromRef.test.ts | 62 +++++++++++++++++++ packages/ui/spa/utils/getFilenameFromRef.ts | 3 +- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 packages/ui/spa/utils/getFilenameFromRef.test.ts diff --git a/packages/ui/spa/utils/getFilenameFromRef.test.ts b/packages/ui/spa/utils/getFilenameFromRef.test.ts new file mode 100644 index 000000000..2cff2d44f --- /dev/null +++ b/packages/ui/spa/utils/getFilenameFromRef.test.ts @@ -0,0 +1,62 @@ +import { getFilenameFromRef, getRefParts } from "./getFilenameFromRef"; + +const REMOTE_REF = + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/images/hero.webp"; + +describe("getFilenameFromRef", () => { + test("extracts the basename of a local ref", () => { + expect(getFilenameFromRef("/public/val/images/hero.webp")).toBe( + "hero.webp", + ); + }); + + test("extracts the basename of a ref directly under /public", () => { + expect(getFilenameFromRef("/public/hero.webp")).toBe("hero.webp"); + }); + + test("extracts the basename of a remote ref", () => { + expect(getFilenameFromRef(REMOTE_REF)).toBe("hero.webp"); + }); +}); + +describe("getRefParts", () => { + test("strips the /public prefix from the folder", () => { + expect(getRefParts("/public/val/images/hero.webp")).toEqual({ + cleanPath: "/public/val/images/hero.webp", + filename: "hero.webp", + folder: "/val/images", + }); + }); + + test("returns / for a file directly under /public", () => { + expect(getRefParts("/public/hero.webp")).toEqual({ + cleanPath: "/public/hero.webp", + filename: "hero.webp", + folder: "/", + }); + }); + + test("only strips /public as a whole path segment", () => { + expect(getRefParts("/publicity/images/hero.webp")).toEqual({ + cleanPath: "/publicity/images/hero.webp", + filename: "hero.webp", + folder: "/publicity/images", + }); + }); + + test("resolves a remote ref to its local-style path", () => { + expect(getRefParts(REMOTE_REF)).toEqual({ + cleanPath: "/public/val/images/hero.webp", + filename: "hero.webp", + folder: "/val/images", + }); + }); + + test("keeps a folder that itself contains /public", () => { + expect(getRefParts("/public/val/public/hero.webp")).toEqual({ + cleanPath: "/public/val/public/hero.webp", + filename: "hero.webp", + folder: "/val/public", + }); + }); +}); diff --git a/packages/ui/spa/utils/getFilenameFromRef.ts b/packages/ui/spa/utils/getFilenameFromRef.ts index b6ba72df3..81cc19cf2 100644 --- a/packages/ui/spa/utils/getFilenameFromRef.ts +++ b/packages/ui/spa/utils/getFilenameFromRef.ts @@ -31,7 +31,8 @@ export function getRefParts(ref: string): { } { const cleanPath = cleanRefPath(ref); const filename = cleanPath.split("/").pop() || cleanPath; + // Only strip a leading "/public" path segment: "/publicity/a.png" must keep its folder const folder = - cleanPath.replace(/^\/public/, "").replace(/\/[^/]+$/, "") || "/"; + cleanPath.replace(/^\/public(?=\/|$)/, "").replace(/\/[^/]+$/, "") || "/"; return { cleanPath, filename, folder }; } From fb17366c416cdd89418fdb722c79ff2b85f889e0 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 28 Jul 2026 09:33:55 +0200 Subject: [PATCH 15/16] refactor(ui): drop leftover imageDirectory alias in RichTextField Since the config.files.directory fallback was removed, imageDirectory was just an alias for imageModuleDirectory. Co-Authored-By: Claude Opus 5 --- packages/ui/spa/components/fields/RichTextField.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/ui/spa/components/fields/RichTextField.tsx b/packages/ui/spa/components/fields/RichTextField.tsx index 99ae7bb93..4de5e4c38 100644 --- a/packages/ui/spa/components/fields/RichTextField.tsx +++ b/packages/ui/spa/components/fields/RichTextField.tsx @@ -152,8 +152,6 @@ export function RichTextField({ config, ]); - const imageDirectory = imageModuleDirectory; - const onImageUpload = useMemo(() => { if (!hasImageEnabled) return undefined; @@ -189,7 +187,7 @@ export function RichTextField({ metadata, "image", imageRemoteData, - imageDirectory, + imageModuleDirectory, !!imageReferencedModule, ); @@ -300,7 +298,7 @@ export function RichTextField({ hasImageEnabled, patchPath, imageRemoteData, - imageDirectory, + imageModuleDirectory, imageReferencedModule, addAndUploadPatchWithFileOps, addModuleFilePatch, From 4ef5d36c076041bc405cb57a091e17f1dec2e762 Mon Sep 17 00:00:00 2001 From: Fredrik Ekholdt Date: Tue, 28 Jul 2026 09:36:28 +0200 Subject: [PATCH 16/16] chore: add ui/shared/server/cli to the changeset The PR changes packages/ui/spa, but @valbuild/ui only has @valbuild/core and @valbuild/shared as devDependencies, so changesets would not bump it via the dependency graph and the studio changes would ship unversioned. Co-Authored-By: Claude Opus 5 --- .changeset/public-dir-not-only-public-val.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.changeset/public-dir-not-only-public-val.md b/.changeset/public-dir-not-only-public-val.md index 03255cea9..3d6b6df56 100644 --- a/.changeset/public-dir-not-only-public-val.md +++ b/.changeset/public-dir-not-only-public-val.md @@ -1,5 +1,9 @@ --- "@valbuild/core": patch +"@valbuild/shared": patch +"@valbuild/server": patch +"@valbuild/cli": patch +"@valbuild/ui": patch --- Allow storing files anywhere under `/public`, not only `/public/val`. Config validation, remote refs, and the studio file/image fields now accept any `/public/...` directory (the default remains `/public/val`). Also removed the `files` property from `SharedValConfig` — the per-schema `s.files`/`s.images` directory is now the source of truth.