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..3d6b6df56 --- /dev/null +++ b/.changeset/public-dir-not-only-public-val.md @@ -0,0 +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. 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 4c27f2b64..4758df2ac 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/cli/src/utils/evalValConfigFile.ts b/packages/cli/src/utils/evalValConfigFile.ts index b488f0aff..78343d1f2 100644 --- a/packages/cli/src/utils/evalValConfigFile.ts +++ b/packages/cli/src/utils/evalValConfigFile.ts @@ -11,11 +11,19 @@ const ValConfigSchema = z.object({ root: z.string().optional(), files: z .object({ - directory: z - .string() - .refine((val): val is `/public/val` => val.startsWith("/public/val"), { - message: "files.directory must start with '/public/val'", - }), + 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(), 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.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"; diff --git a/packages/core/src/remote/splitRemoteRef.ts b/packages/core/src/remote/splitRemoteRef.ts index 13078db2f..82b19d382 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,15 @@ 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, + }; + } + if ( + match[7].split("/").some((segment) => segment === "." || segment === "..") + ) { return { status: "error", error: "Invalid remote ref: " + ref, @@ -44,6 +52,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/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 38b308002..f45be2918 100644 --- a/packages/core/src/schema/images.ts +++ b/packages/core/src/schema/images.ts @@ -19,8 +19,9 @@ 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; + accept?: Accept; /** * The directory where images should be stored. * Must start with "/public" (e.g., "/public/val/images") @@ -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({ @@ -92,14 +96,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 +115,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/remote.ts b/packages/core/src/source/remote.ts index 56b25130e..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/val/${string}`; + `${string}/file/p/${string}/b/${string}/v/${string}/h/${string}/f/${string}/p/public/${string}`; export function createRemoteRef( remoteHost: string, @@ -47,7 +48,7 @@ export function createRemoteRef( coreVersion: string; validationHash: string; fileHash: string; - filePath: `public/val/${string}`; + filePath: `public/${string}`; bucket: string; }, ): RemoteRef { 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..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,11 +12,6 @@ export const SharedValConfig: z.ZodSchema< appHostUrl: z.string().optional(), project: z.string().optional(), root: z.string().optional(), - files: z - .object({ - directory: z.literal("/public/val"), - }) - .optional(), gitCommit: z.string().optional(), gitBranch: z.string().optional(), defaultTheme: z.enum(["dark", "light"]).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..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/val/${string}`}/${newFilePath}`, + filePath: + `${(directory ?? "/public/val").slice(1)}/${newFilePath}` as `public/${string}`, }) : filePath; return { @@ -438,7 +439,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/ModuleGallery.tsx b/packages/ui/spa/components/fields/ModuleGallery.tsx index 646ede370..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/val/${string}`}/${newFilename}`, + filePath: + `${directory.slice(1)}/${newFilename}` as `public/${string}`, }); isRemote = true; } else { diff --git a/packages/ui/spa/components/fields/RichTextField.tsx b/packages/ui/spa/components/fields/RichTextField.tsx index bf80fd556..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 ?? config?.files?.directory; - 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, diff --git a/packages/ui/spa/search/search.worker.ts b/packages/ui/spa/search/search.worker.ts index c7a2f3348..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/val/", ""); + // 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 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 1ff766857..81cc19cf2 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; @@ -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/val", "").replace(/\/[^/]+$/, "") || "/"; + cleanPath.replace(/^\/public(?=\/|$)/, "").replace(/\/[^/]+$/, "") || "/"; return { cleanPath, filename, folder }; }