diff --git a/.agent/rules.md b/.agent/rules.md new file mode 100644 index 000000000..45a4c0338 --- /dev/null +++ b/.agent/rules.md @@ -0,0 +1,204 @@ +# Val Codebase Instructions + +Instructions for AI assistants working with the Val content management system codebase. + +## General rules + +1. Never add @ts-expect-error unless explicitly being allowed to do so +2. Never use as any unless explicitly being allowed to do so +3. Ask if you need to use type assertions (`as Something`) - we try to avoid those + +## Type System Architecture + +### Core Type Hierarchy + +Val has a dual type system: **Source** types define data shape, **Selector** types is the user facing types. + +``` +Source (data) → Selector (access) +───────────────────────────────────────────── +ImageSource → ImageSelector +FileSource → FileSelector +RemoteSource → GenericSelector +RichTextSource → RichTextSelector +SourceObject → ObjectSelector +SourceArray → ArraySelector +string/number/boolean → StringSelector/NumberSelector/BooleanSelector +``` + +### Key Type Definitions + +**Source** (`packages/core/src/source/index.ts`): + +```typescript +export type Source = + | SourcePrimitive // string | number | boolean | null + | SourceObject // { [key: string]: Source } + | SourceArray // readonly Source[] + | RemoteSource + | FileSource + | ImageSource + | RichTextSource; +``` + +**SelectorSource** (`packages/core/src/selector/index.ts`): + +```typescript +export type SelectorSource = + | SourcePrimitive + | undefined + | readonly SelectorSource[] + | { [key: string]: SelectorSource } + | ImageSource + | FileSource + | RemoteSource + | RichTextSource + | GenericSelector; +``` + +**GenericSelector** (`packages/core/src/selector/index.ts`): + +```typescript +class GenericSelector { + [GetSource]: T; // The actual source value + [GetSchema]: Schema | undefined; // Schema for validation + [Path]: SourcePath | undefined; // Path in the module tree + [ValError]: Error | undefined; // Type errors +} +``` + +### CRITICAL: Adding New Source Types + +When adding a new source type, it **MUST** be added to BOTH unions: + +1. `Source` in `packages/core/src/source/index.ts` +2. `SelectorSource` in `packages/core/src/selector/index.ts` + +Additionally: 3. Create selector type in `packages/core/src/selector/{name}.ts` 4. Add mapping in `Selector` conditional type in `packages/core/src/selector/index.ts` + +### FORBIDDEN: Type Intersection Hacks + +**NEVER** use type intersections (`&`) to force a type to satisfy constraints: + +```typescript +// ❌ WRONG - This is a hack that hides the real problem +export type RichTextSelector = GenericSelector & Source>; + +// ✅ CORRECT - Add missing types to SelectorSource union +export type SelectorSource = + | ...existing types... + | ImageSource // Add missing type here +``` + +If you see `Type 'X' does not satisfy the constraint 'Source'`, the fix is almost always adding a type to `SelectorSource`, NOT using intersections. + +## Schema System + +### Schema-Source Relationship + +Each Schema class validates and types its corresponding Source type: + +| Schema | Source | Factory | +| ------------------- | ------------------- | --------------------- | +| `ImageSchema` | `ImageSource` | `s.image()` | +| `FileSchema` | `FileSource` | `s.file()` | +| `RichTextSchema` | `RichTextSource` | `s.richtext(options)` | +| `ObjectSchema` | `SourceObject` | `s.object({...})` | +| `ArraySchema` | `SourceArray` | `s.array(schema)` | + +## Module System + +### c.define() Pattern + +```typescript +c.define( + "/content/page.val.ts", // Module path + s.object({...}), // Schema + { ... } // Source data matching schema +) +``` + +### Source Constructors + +```typescript +c.image("/public/val/logo.png", { + width: 100, + height: 100, + mimeType: "image/png", +}); +c.file("/public/val/doc.pdf", { mimeType: "application/pdf" }); +c.remote("https://...", { mimeType: "image/jpeg" }); +``` + +## UI Architecture + +### Shadow DOM Isolation + +The Val UI runs inside a Shadow DOM for CSS/JS isolation from the host page: + +```typescript +// packages/ui/spa/components/ShadowRoot.tsx +const root = node.attachShadow({ mode: "open" }); +// ID: "val-shadow-root" +``` + +**Implications:** + +- CSS must target `:host` (not `:root`) for shadow DOM styles +- External stylesheets must be loaded inside the shadow root +- `document.querySelector` won't find elements inside shadow DOM +- Use `shadowRoot.querySelector` or React refs instead + +### CSS Architecture + +```css +/* packages/ui/spa/index.css */ +@layer base { + :host, /* Shadow DOM */ + :root { + /* Regular DOM fallback */ + --background: ...; + --foreground: ...; + } +} +``` + +- Dark mode: `[data-mode="dark"]` selector +- CSS loaded via `/api/val/static/{VERSION}/spa/index.css` +- Event `val-css-loaded` dispatched when styles are ready + +### Tailwind Configuration + +```javascript +// packages/ui/tailwind.config.js +darkMode: ["class", '[data-mode="dark"]']; +``` + +Custom color tokens map to CSS variables (e.g., `bg-background` → `var(--background)`). + +## Testing + +Run tests from root dir with: + +```bash +pnpm test # All tests +pnpm test packages/core/src/... # Specific test file +pnpm run -r typecheck # Type checking +pnpm lint +pnpm format +``` + +### Test rules + +1. Never "fix" an issue by changing the test file +2. Prefer to define test data in a type-safe manner using `s` and `c` from `initVal`. Search for examples. + +## Common Fixes + +### "Type 'X' does not satisfy constraint 'Source'" + +→ Add the type to `SelectorSource` union in `packages/core/src/selector/index.ts` + +### "Property 'X' does not exist on type 'never'" + +→ Check if all variants are handled in conditional types (especially in `ImageNode`, `RichTextSource`) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 120000 index 000000000..8d157ee00 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +../.agent/rules.md \ No newline at end of file diff --git a/.cursor/rules/val-rules.md b/.cursor/rules/val-rules.md new file mode 120000 index 000000000..12477a339 --- /dev/null +++ b/.cursor/rules/val-rules.md @@ -0,0 +1 @@ +../../.agent/rules.md \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 120000 index 000000000..8d157ee00 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +../.agent/rules.md \ No newline at end of file diff --git a/examples/next/app/external.val.ts b/examples/next/app/external.val.ts index b5b116057..5798226c1 100644 --- a/examples/next/app/external.val.ts +++ b/examples/next/app/external.val.ts @@ -11,5 +11,6 @@ export default c.define( "https://www.instagram.com": { title: "Instagram" }, "https://www.linkedin.com": { title: "LinkedIn" }, "https://www.github.com": { title: "GitHub" }, + "https://val.build": { title: "" }, }, ); diff --git a/examples/next/content/authors.val.ts b/examples/next/content/authors.val.ts index 84ff04f92..46807fd7e 100644 --- a/examples/next/content/authors.val.ts +++ b/examples/next/content/authors.val.ts @@ -1,10 +1,12 @@ import { s, c, type t } from "../val.config"; +import mediaVal from "./media.val"; export const schema = s .record( s.object({ name: s.string().minLength(2), birthdate: s.date().from("1900-01-01").to("2024-01-01").nullable(), + image: s.image(mediaVal).nullable(), }), ) .render({ @@ -20,25 +22,31 @@ export default c.define("/content/authors.val.ts", schema, { teddy: { name: "Theodor René Carlsen", birthdate: null, + image: null, }, freekh: { name: "Fredrik Ekholdt", birthdate: "1981-12-30", + image: null, }, erlamd: { name: "Erlend Åmdal", birthdate: null, + image: null, }, thoram: { name: "Thomas Ramirez", birthdate: null, + image: null, }, isabjo: { name: "Isak Bjørnstad", birthdate: null, + image: null, }, kimmid: { name: "Kim Midtlid", birthdate: null, + image: null, }, }); diff --git a/examples/next/content/media.val.ts b/examples/next/content/media.val.ts new file mode 100644 index 000000000..ef551bce2 --- /dev/null +++ b/examples/next/content/media.val.ts @@ -0,0 +1,18 @@ +import { c, s } from "../val.config"; + +export default c.define( + "/content/media.val.ts", + s.images({ + accept: "image/*", + directory: "/public/val/images", + alt: s.string().minLength(4), + }), + { + "/public/val/images/logo.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: "An example image", + }, + }, +); diff --git a/examples/next/public/val/logo_7adc7.png b/examples/next/public/val/images/logo.png similarity index 100% rename from examples/next/public/val/logo_7adc7.png rename to examples/next/public/val/images/logo.png diff --git a/examples/next/val.modules.ts b/examples/next/val.modules.ts index 81e505361..88a1873ba 100644 --- a/examples/next/val.modules.ts +++ b/examples/next/val.modules.ts @@ -5,6 +5,7 @@ export default modules(config, [ { def: () => import("./content/authors.val") }, { def: () => import("./app/blogs/[blog]/page.val") }, { def: () => import("./app/generic/[[...path]]/page.val") }, + { def: () => import("./content/media.val") }, { def: () => import("./app/page.val") }, { def: () => import("./app/external.val") }, ]); diff --git a/packages/cli/src/__fixtures__/basic/content/basic-errors.val.ts b/packages/cli/src/__fixtures__/basic/content/basic-errors.val.ts new file mode 100644 index 000000000..11042ad19 --- /dev/null +++ b/packages/cli/src/__fixtures__/basic/content/basic-errors.val.ts @@ -0,0 +1,7 @@ +import { c, s } from "../val.config"; + +export default c.define( + "/content/basic-errors.val.ts", + s.string().minLength(30), + "Hello World", +); diff --git a/packages/cli/src/__fixtures__/basic/content/basic-image.val.ts b/packages/cli/src/__fixtures__/basic/content/basic-image.val.ts new file mode 100644 index 000000000..6db197ab5 --- /dev/null +++ b/packages/cli/src/__fixtures__/basic/content/basic-image.val.ts @@ -0,0 +1,7 @@ +import { c, s } from "../val.config"; + +export default c.define( + "/content/basic-image.val.ts", + s.image(), + c.image("/public/val/image.png"), +); diff --git a/packages/cli/src/__fixtures__/basic/content/basic-valid.val.ts b/packages/cli/src/__fixtures__/basic/content/basic-valid.val.ts new file mode 100644 index 000000000..d56661e9f --- /dev/null +++ b/packages/cli/src/__fixtures__/basic/content/basic-valid.val.ts @@ -0,0 +1,7 @@ +import { c, s } from "../val.config"; + +export default c.define( + "/content/basic-valid.val.ts", + s.string(), + "Hello World", +); diff --git a/packages/cli/src/__fixtures__/basic/public/val/image.png b/packages/cli/src/__fixtures__/basic/public/val/image.png new file mode 100644 index 000000000..252d9502d Binary files /dev/null and b/packages/cli/src/__fixtures__/basic/public/val/image.png differ diff --git a/packages/cli/src/__fixtures__/basic/tsconfig.json b/packages/cli/src/__fixtures__/basic/tsconfig.json new file mode 100644 index 000000000..110571d10 --- /dev/null +++ b/packages/cli/src/__fixtures__/basic/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es5", + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node" + }, + "exclude": ["node_modules"] +} diff --git a/packages/cli/src/__fixtures__/basic/val.config.ts b/packages/cli/src/__fixtures__/basic/val.config.ts new file mode 100644 index 000000000..8db86c966 --- /dev/null +++ b/packages/cli/src/__fixtures__/basic/val.config.ts @@ -0,0 +1,5 @@ +import { initVal } from "@valbuild/core"; + +const { s, c } = initVal(); + +export { s, c }; diff --git a/packages/cli/src/runValidation.test.ts b/packages/cli/src/runValidation.test.ts new file mode 100644 index 000000000..f33dfca91 --- /dev/null +++ b/packages/cli/src/runValidation.test.ts @@ -0,0 +1,140 @@ +import { describe, test, expect, beforeEach, afterEach } from "@jest/globals"; +import path from "path"; +import fs from "fs"; +import { DEFAULT_VAL_REMOTE_HOST, type ModuleFilePath, type ModulePath } from "@valbuild/core"; +import { createService } from "@valbuild/server"; +import { + createDefaultValFSHost, + runValidation, + ValidationEvent, + IValRemote, +} from "./runValidation"; + +const BASIC_FIXTURE = path.resolve(__dirname, "__fixtures__/basic"); + +const mockRemote: IValRemote = { + remoteHost: DEFAULT_VAL_REMOTE_HOST, + getSettings: async () => { + throw new Error("Not expected to be called"); + }, + uploadFile: async () => { + throw new Error("Not expected to be called"); + }, +}; + +describe("runValidation", () => { + let tmpDir: string; + + beforeEach(() => { + const tmpBase = path.join(__dirname, ".tmp"); + fs.mkdirSync(tmpBase, { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(tmpBase, "runValidation-")); + fs.cpSync(BASIC_FIXTURE, tmpDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test("returns summary-success for a valid module", async () => { + const events: ValidationEvent[] = []; + + for await (const event of runValidation({ + root: tmpDir, + fix: false, + valFiles: ["content/basic-valid.val.ts"], + project: undefined, + remote: mockRemote, + fs: createDefaultValFSHost(), + })) { + events.push(event); + } + + expect(events.at(-1)).toEqual({ type: "summary-success" }); + expect(events.filter((e) => e.type === "validation-error")).toHaveLength(0); + }); + + test("returns validation-error for a module with minLength violation", async () => { + const events: ValidationEvent[] = []; + + for await (const event of runValidation({ + root: tmpDir, + fix: false, + valFiles: ["content/basic-errors.val.ts"], + project: undefined, + remote: mockRemote, + fs: createDefaultValFSHost(), + })) { + events.push(event); + } + + expect(events.at(-1)).toEqual({ type: "summary-errors", count: 1 }); + expect(events.filter((e) => e.type === "validation-error")).toHaveLength(1); + }); + + test("applies metadata fix for image without metadata", async () => { + const events: ValidationEvent[] = []; + + for await (const event of runValidation({ + root: tmpDir, + fix: true, + valFiles: ["content/basic-image.val.ts"], + project: undefined, + remote: mockRemote, + fs: createDefaultValFSHost(), + })) { + events.push(event); + } + + expect(events.at(-1)).toEqual({ type: "summary-success" }); + expect(events.filter((e) => e.type === "validation-error")).toHaveLength(0); + expect(events.filter((e) => e.type === "fix-applied")).toHaveLength(1); + expect(events.find((e) => e.type === "fix-applied")).toMatchObject({ + type: "fix-applied", + sourcePath: "/content/basic-image.val.ts", + }); + }); + + test("image has metadata after applying fix", async () => { + const gen = runValidation({ + root: tmpDir, + fix: true, + valFiles: ["content/basic-image.val.ts"], + project: undefined, + remote: mockRemote, + fs: createDefaultValFSHost(), + }); + // consume all events to apply fixes + let next = await gen.next(); + while (!next.done) { + next = await gen.next(); + } + + const service = await createService(tmpDir, {}, createDefaultValFSHost()); + try { + const result = await service.get( + "/content/basic-image.val.ts" as ModuleFilePath, + "" as ModulePath, + { source: true, schema: true, validate: true }, + ); + // The schema always emits image:check-metadata when metadata exists + // (actual metadata verification happens in the fix handler). + // Verify no image:add-metadata errors remain (fix was applied): + if (result.errors && result.errors.validation) { + const allFixes = Object.values(result.errors.validation) + .flat() + .flatMap((e) => e.fixes ?? []); + expect(allFixes).not.toContain("image:add-metadata"); + } + expect(result.source).toMatchObject({ + metadata: { + width: 1, + height: 1, + mimeType: "image/png", + }, + }); + } finally { + service.dispose(); + } + }); +}); diff --git a/packages/cli/src/runValidation.ts b/packages/cli/src/runValidation.ts new file mode 100644 index 000000000..84bc1a60f --- /dev/null +++ b/packages/cli/src/runValidation.ts @@ -0,0 +1,972 @@ +import path from "path"; +import { + createFixPatch, + createService, + getPersonalAccessTokenPath, + parsePersonalAccessTokenFile, + Service, + IValFSHost, +} from "@valbuild/server"; +import { + FILE_REF_PROP, + Internal, + ModuleFilePath, + ModulePath, + SerializedFileSchema, + SerializedImageSchema, + SourcePath, + ValidationFix, +} from "@valbuild/core"; +import { + filterRoutesByPatterns, + validateRoutePatterns, + type SerializedRegExpPattern, +} from "@valbuild/shared/internal"; +import { getFileExt } from "./utils/getFileExt"; +import ts from "typescript"; +import nodeFs from "fs"; + +export type { IValFSHost }; + +export type IValRemote = { + remoteHost: string; + getSettings( + projectName: string, + options: { pat: string }, + ): Promise< + | { + success: true; + data: { + publicProjectId: string; + remoteFileBuckets: { bucket: string }[]; + }; + } + | { success: false; message: string } + >; + uploadFile( + project: string, + bucket: string, + fileHash: string, + fileExt: string | undefined, + fileBuffer: Buffer, + options: { pat: string }, + ): Promise<{ success: true } | { success: false; error: string }>; +}; + +const textEncoder = new TextEncoder(); + +// Types for handler system +export type ValModule = Awaited>; + +export type ValidationError = { + message: string; + value?: unknown; + fixes?: ValidationFix[]; +}; + +// Cache types for avoiding redundant service.get() calls +export type KeyOfCache = Map< + string, // moduleFilePath + modulePath key + { source: unknown; schema: { type: string } | undefined } +>; +export type RouterModulesCache = { + loaded: boolean; + modules: Record>; +}; + +export type FixHandlerContext = { + sourcePath: SourcePath; + validationError: ValidationError; + valModule: ValModule; + projectRoot: string; + fix: boolean; + service: Service; + valFiles: string[]; + moduleFilePath: ModuleFilePath; + file: string; + fs: IValFSHost; + // Shared state + remoteFiles: Record< + SourcePath, + { ref: string; metadata?: Record } + >; + publicProjectId?: string; + remoteFileBuckets?: string[]; + remoteFilesCounter: number; + remote: IValRemote; + project: string | undefined; + // Caches for validation + keyOfCache: KeyOfCache; + routerModulesCache: RouterModulesCache; +}; + +export type FixHandlerResult = { + success: boolean; + errorMessage?: string; + shouldApplyPatch?: boolean; + // Updated shared state + publicProjectId?: string; + remoteFileBuckets?: string[]; + remoteFilesCounter?: number; + // Events to emit + events?: ValidationEvent[]; +}; + +export type FixHandler = (ctx: FixHandlerContext) => Promise; + +export type ValidationEvent = + | { type: "file-valid"; file: string; durationMs: number } + | { + type: "file-error-count"; + file: string; + errorCount: number; + durationMs: number; + } + | { type: "validation-error"; sourcePath: string; message: string } + | { + type: "validation-fixable-error"; + sourcePath: string; + message: string; + fixable: boolean; + } + | { type: "unknown-fix"; sourcePath: string; fixes: string[] } + | { type: "fix-applied"; file: string; sourcePath: string } + | { type: "fatal-error"; file: string; message: string } + | { type: "remote-uploading"; ref: string } + | { type: "remote-uploaded"; ref: string } + | { type: "remote-already-uploaded"; filePath: string } + | { type: "remote-downloading"; sourcePath: string } + | { type: "summary-errors"; count: number } + | { type: "summary-success" }; + +// Handler functions +export async function handleFileMetadata( + ctx: FixHandlerContext, +): Promise { + const [, modulePath] = Internal.splitModuleFilePathAndModulePath( + ctx.sourcePath, + ); + + if (!ctx.valModule.source || !ctx.valModule.schema) { + return { + success: false, + errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`, + }; + } + + const fileSource = Internal.resolvePath( + modulePath, + ctx.valModule.source, + ctx.valModule.schema, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileRefProp = (fileSource.source as any)?.[FILE_REF_PROP]; + if (!fileRefProp) { + return { + success: false, + errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`, + }; + } + + const filePath = path.join(ctx.projectRoot, fileRefProp); + if (!ctx.fs.fileExists(filePath)) { + return { + success: false, + errorMessage: `File ${filePath} does not exist`, + }; + } + + return { success: true, shouldApplyPatch: true }; +} + +export async function handleKeyOfCheck( + ctx: FixHandlerContext, +): Promise { + if ( + !ctx.validationError.value || + typeof ctx.validationError.value !== "object" || + !("key" in ctx.validationError.value) || + !("sourcePath" in ctx.validationError.value) + ) { + return { + success: false, + errorMessage: `Unexpected error in ${ctx.sourcePath}: ${ctx.validationError.message} (Expected value to be an object with 'key' and 'sourcePath' properties - this is likely a bug in Val)`, + }; + } + + const { key, sourcePath } = ctx.validationError.value as { + key: unknown; + sourcePath: unknown; + }; + + if (typeof key !== "string") { + return { + success: false, + errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'key' to be a string - this is likely a bug in Val)`, + }; + } + + if (typeof sourcePath !== "string") { + return { + success: false, + errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'sourcePath' to be a string - this is likely a bug in Val)`, + }; + } + + const res = await checkKeyIsValid( + key, + sourcePath, + ctx.service, + ctx.keyOfCache, + ); + if (res.error) { + return { + success: false, + errorMessage: res.message, + }; + } + + return { success: true }; +} + +export async function handleRemoteFileUpload( + ctx: FixHandlerContext, +): Promise { + if (!ctx.fix) { + return { + success: false, + errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`, + }; + } + + const [, modulePath] = Internal.splitModuleFilePathAndModulePath( + ctx.sourcePath, + ); + + if (!ctx.valModule.source || !ctx.valModule.schema) { + return { + success: false, + errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`, + }; + } + + const resolvedRemoteFileAtSourcePath = Internal.resolvePath( + modulePath, + ctx.valModule.source, + ctx.valModule.schema, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileRefProp = (resolvedRemoteFileAtSourcePath.source as any)?.[ + FILE_REF_PROP + ]; + if (!fileRefProp) { + return { + success: false, + errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`, + }; + } + + const filePath = path.join(ctx.projectRoot, fileRefProp); + if (!ctx.fs.fileExists(filePath)) { + return { + success: false, + errorMessage: `File ${filePath} does not exist`, + }; + } + + const patFile = getPersonalAccessTokenPath(ctx.projectRoot); + if (!ctx.fs.fileExists(patFile)) { + return { + success: false, + errorMessage: `File: ${path.join(ctx.projectRoot, ctx.file)} has remote images that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`, + }; + } + + const patFileContent = ctx.fs.readFile(patFile); + if (patFileContent === undefined) { + return { + success: false, + errorMessage: `Could not read personal access token file at ${patFile}`, + }; + } + + const parsedPatFile = parsePersonalAccessTokenFile(patFileContent); + if (!parsedPatFile.success) { + return { + success: false, + errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`, + }; + } + const { pat } = parsedPatFile.data; + + if (ctx.remoteFiles[ctx.sourcePath]) { + return { + success: true, + events: [{ type: "remote-already-uploaded", filePath }], + }; + } + + if (!resolvedRemoteFileAtSourcePath.schema) { + return { + success: false, + errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`, + }; + } + + const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source; + const fileSourceMetadata = Internal.isFile(actualRemoteFileSource) + ? actualRemoteFileSource.metadata + : undefined; + const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema; + + if (!resolveRemoteFileSchema) { + return { + success: false, + errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`, + }; + } + + const projectName = ctx.project; + let publicProjectId = ctx.publicProjectId; + let remoteFileBuckets = ctx.remoteFileBuckets; + let remoteFilesCounter = ctx.remoteFilesCounter; + + if (!publicProjectId || !remoteFileBuckets) { + if (!projectName) { + return { + success: false, + errorMessage: + "Project name not found. Add project name to val.config or set the VAL_PROJECT environment variable", + }; + } + const settingsRes = await ctx.remote.getSettings(projectName, { pat }); + if (!settingsRes.success) { + return { + success: false, + errorMessage: `Could not get public project id: ${settingsRes.message}.`, + }; + } + publicProjectId = settingsRes.data.publicProjectId; + remoteFileBuckets = settingsRes.data.remoteFileBuckets.map((b) => b.bucket); + } + + if (!publicProjectId) { + return { + success: false, + errorMessage: "Could not get public project id", + }; + } + + if (!projectName) { + return { + success: false, + errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`, + }; + } + + if ( + resolveRemoteFileSchema.type !== "image" && + resolveRemoteFileSchema.type !== "file" + ) { + return { + success: false, + errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`, + }; + } + + remoteFilesCounter += 1; + const bucket = + remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length]; + + if (!bucket) { + return { + success: false, + errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`, + }; + } + + const fileBuffer = ctx.fs.readBuffer(filePath); + if (fileBuffer === undefined) { + return { + success: false, + errorMessage: `Error reading file: ${filePath}`, + }; + } + + const relativeFilePath = path + .relative(ctx.projectRoot, filePath) + .split(path.sep) + .join("/") as `public/val/${string}`; + + if (!relativeFilePath.startsWith("public/val/")) { + return { + success: false, + errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`, + }; + } + + const fileHash = Internal.remote.getFileHash(fileBuffer); + const coreVersion = Internal.VERSION.core || "unknown"; + const fileExt = getFileExt(filePath); + const schema = resolveRemoteFileSchema as + | SerializedImageSchema + | SerializedFileSchema; + const metadata = fileSourceMetadata; + const ref = Internal.remote.createRemoteRef(ctx.remote.remoteHost, { + publicProjectId, + coreVersion, + bucket, + validationHash: Internal.remote.getValidationHash( + coreVersion, + schema, + fileExt, + metadata, + fileHash, + textEncoder, + ), + fileHash, + filePath: relativeFilePath, + }); + + const remoteFileUpload = await ctx.remote.uploadFile( + projectName, + bucket, + fileHash, + fileExt, + fileBuffer, + { pat }, + ); + + if (!remoteFileUpload.success) { + return { + success: false, + errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`, + }; + } + + ctx.remoteFiles[ctx.sourcePath] = { + ref, + metadata: fileSourceMetadata, + }; + + return { + success: true, + shouldApplyPatch: true, + publicProjectId, + remoteFileBuckets, + remoteFilesCounter, + events: [ + { type: "remote-uploading", ref }, + { type: "remote-uploaded", ref }, + ], + }; +} + +export async function handleRemoteFileDownload( + ctx: FixHandlerContext, +): Promise { + if (ctx.fix) { + return { + success: true, + shouldApplyPatch: true, + events: [{ type: "remote-downloading", sourcePath: ctx.sourcePath }], + }; + } else { + return { + success: false, + errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`, + }; + } +} + +export async function handleRemoteFileCheck(): Promise { + // Skip - no action needed + return { success: true, shouldApplyPatch: true }; +} + +// Helper function +export async function checkKeyIsValid( + key: string, + sourcePath: string, + service: Service, + cache: KeyOfCache, +): Promise<{ error: false } | { error: true; message: string }> { + const [moduleFilePath, modulePath] = + Internal.splitModuleFilePathAndModulePath(sourcePath as SourcePath); + + const cacheKey = `${moduleFilePath}::${modulePath}`; + let keyOfModuleSource: unknown; + let keyOfModuleSchema: { type: string } | undefined; + + const cached = cache.get(cacheKey); + if (cached) { + keyOfModuleSource = cached.source; + keyOfModuleSchema = cached.schema; + } else { + const keyOfModule = await service.get(moduleFilePath, modulePath, { + source: true, + schema: true, + validate: false, + }); + keyOfModuleSource = keyOfModule.source; + keyOfModuleSchema = keyOfModule.schema as { type: string } | undefined; + cache.set(cacheKey, { + source: keyOfModuleSource, + schema: keyOfModuleSchema, + }); + } + + if (keyOfModuleSchema && keyOfModuleSchema.type !== "record") { + return { + error: true, + message: `Expected key at ${sourcePath} to be of type 'record'`, + }; + } + if ( + keyOfModuleSource && + typeof keyOfModuleSource === "object" && + key in keyOfModuleSource + ) { + return { error: false }; + } + if (!keyOfModuleSource || typeof keyOfModuleSource !== "object") { + return { + error: true, + message: `Expected ${sourcePath} to be a truthy object`, + }; + } + const alternatives = findSimilar(key, Object.keys(keyOfModuleSource)); + return { + error: true, + message: `Key '${key}' does not exist in ${sourcePath}. Closest match: '${alternatives[0].target}'. Other similar: ${alternatives + .slice(1, 4) + .map((a) => `'${a.target}'`) + .join(", ")}${alternatives.length > 4 ? ", ..." : ""}`, + }; +} + +/** + * Check if a route is valid by scanning all router modules + * and validating against include/exclude patterns + */ +export async function checkRouteIsValid( + route: string, + include: SerializedRegExpPattern | undefined, + exclude: SerializedRegExpPattern | undefined, + service: Service, + valFiles: string[], + cache: RouterModulesCache, +): Promise<{ error: false } | { error: true; message: string }> { + // 1. Scan all val files to find modules with routers (use cache if available) + if (!cache.loaded) { + for (const file of valFiles) { + const moduleFilePath = `/${file}` as ModuleFilePath; + const valModule = await service.get(moduleFilePath, "" as ModulePath, { + source: true, + schema: true, + validate: false, + }); + + // Check if this module has a router defined + if (valModule.schema?.type === "record" && valModule.schema.router) { + if (valModule.source && typeof valModule.source === "object") { + cache.modules[moduleFilePath] = valModule.source as Record< + string, + unknown + >; + } + } + } + cache.loaded = true; + } + + const routerModules = cache.modules; + + // 2. Check if route exists in any router module + let foundInModule: string | null = null; + for (const [moduleFilePath, source] of Object.entries(routerModules)) { + if (route in source) { + foundInModule = moduleFilePath; + break; + } + } + + if (!foundInModule) { + // Route not found in any router module + let allRoutes = Object.values(routerModules).flatMap((source) => + Object.keys(source), + ); + + if (allRoutes.length === 0) { + return { + error: true, + message: `Route '${route}' could not be validated: No router modules found in the project. Use s.record(...).router(...) to define router modules.`, + }; + } + + // Filter routes by include/exclude patterns for suggestions + allRoutes = filterRoutesByPatterns(allRoutes, include, exclude); + + const alternatives = findSimilar(route, allRoutes); + + return { + error: true, + message: `Route '${route}' does not exist in any router module. ${ + alternatives.length > 0 + ? `Closest match: '${alternatives[0].target}'. Other similar: ${alternatives + .slice(1, 4) + .map((a) => `'${a.target}'`) + .join(", ")}${alternatives.length > 4 ? ", ..." : ""}` + : "No similar routes found." + }`, + }; + } + + // 3. Validate against include/exclude patterns + const patternValidation = validateRoutePatterns(route, include, exclude); + if (!patternValidation.valid) { + return { + error: true, + message: patternValidation.message, + }; + } + + return { error: false }; +} + +/** + * Handler for router:check-route validation fix + */ +export async function handleRouteCheck( + ctx: FixHandlerContext, +): Promise { + const { sourcePath, validationError, service, valFiles, routerModulesCache } = + ctx; + + // Extract route and patterns from validation error value + const value = validationError.value as + | { + route: unknown; + include?: { source: string; flags: string }; + exclude?: { source: string; flags: string }; + } + | undefined; + + if (!value || typeof value.route !== "string") { + return { + success: false, + errorMessage: `Invalid route value in validation error: ${JSON.stringify(value)}`, + }; + } + + const route = value.route; + + // Check if the route is valid + const result = await checkRouteIsValid( + route, + value.include, + value.exclude, + service, + valFiles, + routerModulesCache, + ); + + if (result.error) { + return { + success: false, + errorMessage: `${sourcePath}: ${result.message}`, + }; + } + + return { success: true }; +} + +// Fix handler registry +export const currentFixHandlers: Record = { + "image:check-metadata": handleFileMetadata, + "image:add-metadata": handleFileMetadata, + "file:check-metadata": handleFileMetadata, + "file:add-metadata": handleFileMetadata, + "keyof:check-keys": handleKeyOfCheck, + "router:check-route": handleRouteCheck, + "image:upload-remote": handleRemoteFileUpload, + "file:upload-remote": handleRemoteFileUpload, + "image:download-remote": handleRemoteFileDownload, + "file:download-remote": handleRemoteFileDownload, + "image:check-remote": handleRemoteFileCheck, + "images:check-remote": handleRemoteFileCheck, + "file:check-remote": handleRemoteFileCheck, + "files:check-remote": handleRemoteFileCheck, +}; +const deprecatedFixHandlers: Record = { + "image:replace-metadata": handleFileMetadata, +}; +export const fixHandlers: Record = { + ...deprecatedFixHandlers, + ...currentFixHandlers, +}; + +export function createDefaultValFSHost(): IValFSHost { + return { + ...ts.sys, + writeFile: (fileName, data, encoding) => { + nodeFs.mkdirSync(path.dirname(fileName), { recursive: true }); + nodeFs.writeFileSync( + fileName, + typeof data === "string" ? data : new Uint8Array(data), + encoding, + ); + }, + rmFile: nodeFs.rmSync, + readBuffer: (fileName) => { + try { + return nodeFs.readFileSync(fileName); + } catch { + return undefined; + } + }, + }; +} + +export async function* runValidation({ + root, + fix, + valFiles, + project, + remote, + fs, +}: { + root: string; + fix: boolean; + valFiles: string[]; + project: string | undefined; + remote: IValRemote; + fs: IValFSHost; +}): AsyncGenerator { + const projectRoot = path.resolve(root); + + const service = await createService(projectRoot, {}, fs); + + let errors = 0; + + // Create caches that persist across all file validations + const keyOfCache: KeyOfCache = new Map(); + const routerModulesCache: RouterModulesCache = { + loaded: false, + modules: {}, + }; + + async function* validateFile(file: string): AsyncGenerator { + const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?) + const start = Date.now(); + const valModule = await service.get(moduleFilePath, "" as ModulePath, { + source: true, + schema: true, + validate: true, + }); + const remoteFiles: Record< + SourcePath, + { ref: string; metadata?: Record } + > = {}; + let remoteFileBuckets: string[] | undefined = undefined; + let remoteFilesCounter = 0; + if (!valModule.errors) { + yield { + type: "file-valid", + file: moduleFilePath, + durationMs: Date.now() - start, + }; + return; + } else { + let fileErrors = 0; + let fixedErrors = 0; + if (valModule.errors) { + if (valModule.errors.validation) { + for (const [sourcePath, validationErrors] of Object.entries( + valModule.errors.validation, + )) { + for (const v of validationErrors) { + if (!v.fixes || v.fixes.length === 0) { + // No fixes available - just report error + fileErrors += 1; + yield { + type: "validation-error", + sourcePath, + message: v.message, + }; + continue; + } + + // Find and execute appropriate handler + const fixType = v.fixes[0]; // Take first fix + const handler = fixHandlers[fixType]; + + if (!handler) { + yield { + type: "unknown-fix", + sourcePath, + fixes: v.fixes, + }; + fileErrors += 1; + continue; + } + + // Execute handler + const result = await handler({ + sourcePath: sourcePath as SourcePath, + validationError: v, + valModule, + projectRoot, + fix: !!fix, + service, + valFiles, + moduleFilePath, + file, + fs, + remoteFiles, + publicProjectId: undefined, + remoteFileBuckets, + remoteFilesCounter, + remote, + project, + keyOfCache, + routerModulesCache, + }); + + // Yield any events from handler + if (result.events) { + for (const event of result.events) { + yield event; + } + } + + // Update shared state from handler result + if (result.remoteFileBuckets !== undefined) { + remoteFileBuckets = result.remoteFileBuckets; + } + if (result.remoteFilesCounter !== undefined) { + remoteFilesCounter = result.remoteFilesCounter; + } + + if (!result.success) { + yield { + type: "validation-error", + sourcePath, + message: result.errorMessage ?? "Unknown error", + }; + fileErrors += 1; + continue; + } + + // Apply patch if needed + if (result.shouldApplyPatch) { + const fixPatch = await createFixPatch( + { projectRoot, remoteHost: remote.remoteHost }, + !!fix, + sourcePath as SourcePath, + v, + remoteFiles, + valModule.source, + valModule.schema, + ); + + if (fix && fixPatch?.patch && fixPatch?.patch.length > 0) { + await service.patch(moduleFilePath, fixPatch.patch); + fixedErrors += 1; + yield { type: "fix-applied", file, sourcePath }; + } + + for (const e of fixPatch?.remainingErrors ?? []) { + fileErrors += 1; + yield { + type: "validation-fixable-error", + sourcePath, + message: e.message, + fixable: !!(e.fixes && e.fixes.length), + }; + } + } + } + } + } + if ( + fixedErrors === fileErrors && + (!valModule.errors.fatal || valModule.errors.fatal.length == 0) + ) { + yield { + type: "file-valid", + file: moduleFilePath, + durationMs: Date.now() - start, + }; + } + for (const fatalError of valModule.errors.fatal || []) { + fileErrors += 1; + yield { + type: "fatal-error", + file: moduleFilePath, + message: fatalError.message, + }; + } + } else { + yield { + type: "file-valid", + file: moduleFilePath, + durationMs: Date.now() - start, + }; + } + if (fileErrors > 0) { + yield { + type: "file-error-count", + file: `/${file}`, + errorCount: fileErrors, + durationMs: Date.now() - start, + }; + } + errors += fileErrors; + } + } + + for (const file of valFiles.sort()) { + yield* validateFile(file); + } + + service.dispose(); + + if (errors > 0) { + yield { type: "summary-errors", count: errors }; + } else { + yield { type: "summary-success" }; + } +} + +// GPT generated levenshtein distance algorithm: +export const levenshtein = (a: string, b: string): number => { + const [m, n] = [a.length, b.length]; + if (!m || !n) return Math.max(m, n); + + const dp = Array.from({ length: m + 1 }, (_, i) => i); + + for (let j = 1; j <= n; j++) { + let prev = dp[0]; + dp[0] = j; + + for (let i = 1; i <= m; i++) { + const temp = dp[i]; + dp[i] = + a[i - 1] === b[j - 1] + ? prev + : Math.min(prev + 1, dp[i - 1] + 1, dp[i] + 1); + prev = temp; + } + } + + return dp[m]; +}; + +export function findSimilar(key: string, targets: string[]) { + return targets + .map((target) => ({ target, distance: levenshtein(key, target) })) + .sort((a, b) => a.distance - b.distance); +} diff --git a/packages/cli/src/validate.ts b/packages/cli/src/validate.ts index 101c05a82..241c674fe 100644 --- a/packages/cli/src/validate.ts +++ b/packages/cli/src/validate.ts @@ -1,675 +1,11 @@ import path from "path"; -import { - createFixPatch, - createService, - getSettings, - getPersonalAccessTokenPath, - parsePersonalAccessTokenFile, - uploadRemoteFile, - Service, -} from "@valbuild/server"; -import { - DEFAULT_CONTENT_HOST, - DEFAULT_VAL_REMOTE_HOST, - FILE_REF_PROP, - Internal, - ModuleFilePath, - ModulePath, - SerializedFileSchema, - SerializedImageSchema, - SourcePath, - ValidationFix, -} from "@valbuild/core"; -import { - filterRoutesByPatterns, - validateRoutePatterns, - type SerializedRegExpPattern, -} from "@valbuild/shared/internal"; -import { glob } from "fast-glob"; import picocolors from "picocolors"; import fs from "fs/promises"; +import { glob } from "fast-glob"; +import { DEFAULT_CONTENT_HOST, DEFAULT_VAL_REMOTE_HOST } from "@valbuild/core"; +import { getSettings, uploadRemoteFile } from "@valbuild/server"; import { evalValConfigFile } from "./utils/evalValConfigFile"; -import { getFileExt } from "./utils/getFileExt"; - -const textEncoder = new TextEncoder(); - -// Types for handler system -type ValModule = Awaited>; - -type ValidationError = { - message: string; - value?: unknown; - fixes?: ValidationFix[]; -}; - -// Cache types for avoiding redundant service.get() calls -type KeyOfCache = Map< - string, // moduleFilePath + modulePath key - { source: unknown; schema: { type: string } | undefined } ->; -type RouterModulesCache = { - loaded: boolean; - modules: Record>; -}; - -type FixHandlerContext = { - sourcePath: SourcePath; - validationError: ValidationError; - valModule: ValModule; - projectRoot: string; - fix: boolean; - service: Service; - valFiles: string[]; - moduleFilePath: ModuleFilePath; - file: string; - // Shared state - remoteFiles: Record< - SourcePath, - { ref: string; metadata?: Record } - >; - publicProjectId?: string; - remoteFileBuckets?: string[]; - remoteFilesCounter: number; - valRemoteHost: string; - contentHostUrl: string; - valConfigFile?: { project?: string }; - // Caches for validation - keyOfCache: KeyOfCache; - routerModulesCache: RouterModulesCache; -}; - -type FixHandlerResult = { - success: boolean; - errorMessage?: string; - shouldApplyPatch?: boolean; - // Updated shared state - publicProjectId?: string; - remoteFileBuckets?: string[]; - remoteFilesCounter?: number; -}; - -type FixHandler = (ctx: FixHandlerContext) => Promise; - -// Handler functions -async function handleFileMetadata( - ctx: FixHandlerContext, -): Promise { - const [, modulePath] = Internal.splitModuleFilePathAndModulePath( - ctx.sourcePath, - ); - - if (!ctx.valModule.source || !ctx.valModule.schema) { - return { - success: false, - errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`, - }; - } - - const fileSource = Internal.resolvePath( - modulePath, - ctx.valModule.source, - ctx.valModule.schema, - ); - - let filePath: string | null = null; - try { - filePath = path.join( - ctx.projectRoot, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (fileSource.source as any)?.[FILE_REF_PROP], - ); - await fs.access(filePath); - } catch { - if (filePath) { - return { - success: false, - errorMessage: `File ${filePath} does not exist`, - }; - } else { - return { - success: false, - errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`, - }; - } - } - - return { success: true, shouldApplyPatch: true }; -} - -async function handleKeyOfCheck( - ctx: FixHandlerContext, -): Promise { - if ( - !ctx.validationError.value || - typeof ctx.validationError.value !== "object" || - !("key" in ctx.validationError.value) || - !("sourcePath" in ctx.validationError.value) - ) { - return { - success: false, - errorMessage: `Unexpected error in ${ctx.sourcePath}: ${ctx.validationError.message} (Expected value to be an object with 'key' and 'sourcePath' properties - this is likely a bug in Val)`, - }; - } - - const { key, sourcePath } = ctx.validationError.value as { - key: unknown; - sourcePath: unknown; - }; - - if (typeof key !== "string") { - return { - success: false, - errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'key' to be a string - this is likely a bug in Val)`, - }; - } - - if (typeof sourcePath !== "string") { - return { - success: false, - errorMessage: `Unexpected error in ${sourcePath}: ${ctx.validationError.message} (Expected value property 'sourcePath' to be a string - this is likely a bug in Val)`, - }; - } - - const res = await checkKeyIsValid( - key, - sourcePath, - ctx.service, - ctx.keyOfCache, - ); - if (res.error) { - return { - success: false, - errorMessage: res.message, - }; - } - - return { success: true }; -} - -async function handleRemoteFileUpload( - ctx: FixHandlerContext, -): Promise { - if (!ctx.fix) { - return { - success: false, - errorMessage: `Remote file ${ctx.sourcePath} needs to be uploaded (use --fix to upload)`, - }; - } - - const [, modulePath] = Internal.splitModuleFilePathAndModulePath( - ctx.sourcePath, - ); - - if (!ctx.valModule.source || !ctx.valModule.schema) { - return { - success: false, - errorMessage: `Could not resolve source or schema for ${ctx.sourcePath}`, - }; - } - - const resolvedRemoteFileAtSourcePath = Internal.resolvePath( - modulePath, - ctx.valModule.source, - ctx.valModule.schema, - ); - - let filePath: string | null = null; - try { - filePath = path.join( - ctx.projectRoot, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (resolvedRemoteFileAtSourcePath.source as any)?.[FILE_REF_PROP], - ); - await fs.access(filePath); - } catch { - if (filePath) { - return { - success: false, - errorMessage: `File ${filePath} does not exist`, - }; - } else { - return { - success: false, - errorMessage: `Expected file to be defined at: ${ctx.sourcePath} but no file was found`, - }; - } - } - - const patFile = getPersonalAccessTokenPath(ctx.projectRoot); - try { - await fs.access(patFile); - } catch { - return { - success: false, - errorMessage: `File: ${path.join(ctx.projectRoot, ctx.file)} has remote images that are not uploaded and you are not logged in.\n\nFix this error by logging in:\n\t"npx val login"\n`, - }; - } - - const parsedPatFile = parsePersonalAccessTokenFile( - await fs.readFile(patFile, "utf-8"), - ); - if (!parsedPatFile.success) { - return { - success: false, - errorMessage: `Error parsing personal access token file: ${parsedPatFile.error}. You need to login again.`, - }; - } - const { pat } = parsedPatFile.data; - - if (ctx.remoteFiles[ctx.sourcePath]) { - console.log( - picocolors.yellow("⚠"), - `Remote file ${filePath} already uploaded`, - ); - return { success: true }; - } - - if (!resolvedRemoteFileAtSourcePath.schema) { - return { - success: false, - errorMessage: `Cannot upload remote file: schema not found for ${ctx.sourcePath}`, - }; - } - - const actualRemoteFileSource = resolvedRemoteFileAtSourcePath.source; - const fileSourceMetadata = Internal.isFile(actualRemoteFileSource) - ? actualRemoteFileSource.metadata - : undefined; - const resolveRemoteFileSchema = resolvedRemoteFileAtSourcePath.schema; - - if (!resolveRemoteFileSchema) { - return { - success: false, - errorMessage: `Could not resolve schema for remote file: ${ctx.sourcePath}`, - }; - } - - let publicProjectId = ctx.publicProjectId; - let remoteFileBuckets = ctx.remoteFileBuckets; - let remoteFilesCounter = ctx.remoteFilesCounter; - - if (!publicProjectId || !remoteFileBuckets) { - let projectName = process.env.VAL_PROJECT; - if (!projectName) { - projectName = ctx.valConfigFile?.project; - } - if (!projectName) { - return { - success: false, - errorMessage: - "Project name not found. Set VAL_PROJECT environment variable or add project name to val.config", - }; - } - const settingsRes = await getSettings(projectName, { pat }); - if (!settingsRes.success) { - return { - success: false, - errorMessage: `Could not get public project id: ${settingsRes.message}.`, - }; - } - publicProjectId = settingsRes.data.publicProjectId; - remoteFileBuckets = settingsRes.data.remoteFileBuckets.map((b) => b.bucket); - } - - if (!publicProjectId) { - return { - success: false, - errorMessage: "Could not get public project id", - }; - } - - if (!ctx.valConfigFile?.project) { - return { - success: false, - errorMessage: `Could not get project. Check that your val.config has the 'project' field set, or set it using the VAL_PROJECT environment variable`, - }; - } - - if ( - resolveRemoteFileSchema.type !== "image" && - resolveRemoteFileSchema.type !== "file" - ) { - return { - success: false, - errorMessage: `The schema is the remote is neither image nor file: ${ctx.sourcePath}`, - }; - } - - remoteFilesCounter += 1; - const bucket = - remoteFileBuckets[remoteFilesCounter % remoteFileBuckets.length]; - - if (!bucket) { - return { - success: false, - errorMessage: `Internal error: could not allocate a bucket for the remote file located at ${ctx.sourcePath}`, - }; - } - - let fileBuffer: Buffer; - try { - fileBuffer = await fs.readFile(filePath); - } catch (e) { - return { - success: false, - errorMessage: `Error reading file: ${e}`, - }; - } - - const relativeFilePath = path - .relative(ctx.projectRoot, filePath) - .split(path.sep) - .join("/") as `public/val/${string}`; - - if (!relativeFilePath.startsWith("public/val/")) { - return { - success: false, - errorMessage: `File path must be within the public/val/ directory (e.g. public/val/path/to/file.txt). Got: ${relativeFilePath}`, - }; - } - - const fileHash = Internal.remote.getFileHash(fileBuffer); - const coreVersion = Internal.VERSION.core || "unknown"; - const fileExt = getFileExt(filePath); - const schema = resolveRemoteFileSchema as - | SerializedImageSchema - | SerializedFileSchema; - const metadata = fileSourceMetadata; - const ref = Internal.remote.createRemoteRef(ctx.valRemoteHost, { - publicProjectId, - coreVersion, - bucket, - validationHash: Internal.remote.getValidationHash( - coreVersion, - schema, - fileExt, - metadata, - fileHash, - textEncoder, - ), - fileHash, - filePath: relativeFilePath, - }); - - console.log(picocolors.yellow("⚠"), `Uploading remote file: '${ref}'...`); - - const remoteFileUpload = await uploadRemoteFile( - ctx.contentHostUrl, - ctx.valConfigFile.project, - bucket, - fileHash, - fileExt, - fileBuffer, - { pat }, - ); - - if (!remoteFileUpload.success) { - return { - success: false, - errorMessage: `Could not upload remote file: '${ref}'. Error: ${remoteFileUpload.error}`, - }; - } - - console.log( - picocolors.green("✔"), - `Completed upload of remote file: '${ref}'`, - ); - - ctx.remoteFiles[ctx.sourcePath] = { - ref, - metadata: fileSourceMetadata, - }; - - return { - success: true, - shouldApplyPatch: true, - publicProjectId, - remoteFileBuckets, - remoteFilesCounter, - }; -} - -async function handleRemoteFileDownload( - ctx: FixHandlerContext, -): Promise { - if (ctx.fix) { - console.log( - picocolors.yellow("⚠"), - `Downloading remote file in ${ctx.sourcePath}...`, - ); - return { success: true, shouldApplyPatch: true }; - } else { - return { - success: false, - errorMessage: `Remote file ${ctx.sourcePath} needs to be downloaded (use --fix to download)`, - }; - } -} - -async function handleRemoteFileCheck(): Promise { - // Skip - no action needed - return { success: true, shouldApplyPatch: true }; -} - -// Helper function -async function checkKeyIsValid( - key: string, - sourcePath: string, - service: Service, - cache: KeyOfCache, -): Promise<{ error: false } | { error: true; message: string }> { - const [moduleFilePath, modulePath] = - Internal.splitModuleFilePathAndModulePath(sourcePath as SourcePath); - - const cacheKey = `${moduleFilePath}::${modulePath}`; - let keyOfModuleSource: unknown; - let keyOfModuleSchema: { type: string } | undefined; - - const cached = cache.get(cacheKey); - if (cached) { - keyOfModuleSource = cached.source; - keyOfModuleSchema = cached.schema; - } else { - const keyOfModule = await service.get(moduleFilePath, modulePath, { - source: true, - schema: true, - validate: false, - }); - keyOfModuleSource = keyOfModule.source; - keyOfModuleSchema = keyOfModule.schema as { type: string } | undefined; - cache.set(cacheKey, { - source: keyOfModuleSource, - schema: keyOfModuleSchema, - }); - } - - if (keyOfModuleSchema && keyOfModuleSchema.type !== "record") { - return { - error: true, - message: `Expected key at ${sourcePath} to be of type 'record'`, - }; - } - if ( - keyOfModuleSource && - typeof keyOfModuleSource === "object" && - key in keyOfModuleSource - ) { - return { error: false }; - } - if (!keyOfModuleSource || typeof keyOfModuleSource !== "object") { - return { - error: true, - message: `Expected ${sourcePath} to be a truthy object`, - }; - } - const alternatives = findSimilar(key, Object.keys(keyOfModuleSource)); - return { - error: true, - message: `Key '${key}' does not exist in ${sourcePath}. Closest match: '${alternatives[0].target}'. Other similar: ${alternatives - .slice(1, 4) - .map((a) => `'${a.target}'`) - .join(", ")}${alternatives.length > 4 ? ", ..." : ""}`, - }; -} - -/** - * Check if a route is valid by scanning all router modules - * and validating against include/exclude patterns - */ -async function checkRouteIsValid( - route: string, - include: SerializedRegExpPattern | undefined, - exclude: SerializedRegExpPattern | undefined, - service: Service, - valFiles: string[], - cache: RouterModulesCache, -): Promise<{ error: false } | { error: true; message: string }> { - // 1. Scan all val files to find modules with routers (use cache if available) - if (!cache.loaded) { - for (const file of valFiles) { - const moduleFilePath = `/${file}` as ModuleFilePath; - const valModule = await service.get(moduleFilePath, "" as ModulePath, { - source: true, - schema: true, - validate: false, - }); - - // Check if this module has a router defined - if (valModule.schema?.type === "record" && valModule.schema.router) { - if (valModule.source && typeof valModule.source === "object") { - cache.modules[moduleFilePath] = valModule.source as Record< - string, - unknown - >; - } - } - } - cache.loaded = true; - } - - const routerModules = cache.modules; - - // 2. Check if route exists in any router module - let foundInModule: string | null = null; - for (const [moduleFilePath, source] of Object.entries(routerModules)) { - if (route in source) { - foundInModule = moduleFilePath; - break; - } - } - - if (!foundInModule) { - // Route not found in any router module - let allRoutes = Object.values(routerModules).flatMap((source) => - Object.keys(source), - ); - - if (allRoutes.length === 0) { - return { - error: true, - message: `Route '${route}' could not be validated: No router modules found in the project. Use s.record(...).router(...) to define router modules.`, - }; - } - - // Filter routes by include/exclude patterns for suggestions - allRoutes = filterRoutesByPatterns(allRoutes, include, exclude); - - const alternatives = findSimilar(route, allRoutes); - - return { - error: true, - message: `Route '${route}' does not exist in any router module. ${ - alternatives.length > 0 - ? `Closest match: '${alternatives[0].target}'. Other similar: ${alternatives - .slice(1, 4) - .map((a) => `'${a.target}'`) - .join(", ")}${alternatives.length > 4 ? ", ..." : ""}` - : "No similar routes found." - }`, - }; - } - - // 3. Validate against include/exclude patterns - const patternValidation = validateRoutePatterns(route, include, exclude); - if (!patternValidation.valid) { - return { - error: true, - message: patternValidation.message, - }; - } - - return { error: false }; -} - -/** - * Handler for router:check-route validation fix - */ -async function handleRouteCheck( - ctx: FixHandlerContext, -): Promise { - const { sourcePath, validationError, service, valFiles, routerModulesCache } = - ctx; - - // Extract route and patterns from validation error value - const value = validationError.value as - | { - route: unknown; - include?: { source: string; flags: string }; - exclude?: { source: string; flags: string }; - } - | undefined; - - if (!value || typeof value.route !== "string") { - return { - success: false, - errorMessage: `Invalid route value in validation error: ${JSON.stringify(value)}`, - }; - } - - const route = value.route; - - // Check if the route is valid - const result = await checkRouteIsValid( - route, - value.include, - value.exclude, - service, - valFiles, - routerModulesCache, - ); - - if (result.error) { - return { - success: false, - errorMessage: `${sourcePath}: ${result.message}`, - }; - } - - return { success: true }; -} - -// Fix handler registry -const currentFixHandlers: Record = { - "image:check-metadata": handleFileMetadata, - "image:add-metadata": handleFileMetadata, - "file:check-metadata": handleFileMetadata, - "file:add-metadata": handleFileMetadata, - "keyof:check-keys": handleKeyOfCheck, - "router:check-route": handleRouteCheck, - "image:upload-remote": handleRemoteFileUpload, - "file:upload-remote": handleRemoteFileUpload, - "image:download-remote": handleRemoteFileDownload, - "file:download-remote": handleRemoteFileDownload, - "image:check-remote": handleRemoteFileCheck, - "file:check-remote": handleRemoteFileCheck, -}; -const deprecatedFixHandlers: Record = { - "image:replace-metadata": handleFileMetadata, -}; -const fixHandlers: Record = { - ...deprecatedFixHandlers, - ...currentFixHandlers, -}; +import { createDefaultValFSHost, runValidation } from "./runValidation"; export async function validate({ root, @@ -678,265 +14,174 @@ export async function validate({ root?: string; fix?: boolean; }) { - const valRemoteHost = process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST; - const contentHostUrl = process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST; const projectRoot = root ? path.resolve(root) : process.cwd(); + const valConfigFile = (await evalValConfigFile(projectRoot, "val.config.ts")) || (await evalValConfigFile(projectRoot, "val.config.js")); + + const resolvedValConfigFile = valConfigFile + ? { + ...valConfigFile, + project: process.env.VAL_PROJECT || valConfigFile.project, + } + : process.env.VAL_PROJECT + ? { project: process.env.VAL_PROJECT } + : undefined; + console.log( picocolors.greenBright( - `Validating project${valConfigFile?.project ? ` '${picocolors.inverse(valConfigFile?.project)}'` : ""}...`, + `Validating project${resolvedValConfigFile?.project ? ` '${picocolors.inverse(resolvedValConfigFile.project)}'` : ""}...`, ), ); - const service = await createService(projectRoot, {}); - let prettier; - try { - prettier = (await import("prettier")).default; - } catch { - console.log("Prettier not found, skipping formatting"); - } const valFiles: string[] = await glob("**/*.val.{js,ts}", { ignore: ["node_modules/**"], cwd: projectRoot, }); - let errors = 0; console.log(picocolors.greenBright(`Found ${valFiles.length} files...`)); - let publicProjectId: string | undefined; - let didFix = false; - - // Create caches that persist across all file validations - const keyOfCache: KeyOfCache = new Map(); - const routerModulesCache: RouterModulesCache = { loaded: false, modules: {} }; - - async function validateFile(file: string): Promise { - const moduleFilePath = `/${file}` as ModuleFilePath; // TODO: check if this always works? (Windows?) - const start = Date.now(); - const valModule = await service.get(moduleFilePath, "" as ModulePath, { - source: true, - schema: true, - validate: true, - }); - const remoteFiles: Record< - SourcePath, - { ref: string; metadata?: Record } - > = {}; - let remoteFileBuckets: string[] | undefined = undefined; - let remoteFilesCounter = 0; - if (!valModule.errors) { - console.log( - picocolors.green("✔"), - moduleFilePath, - "is valid (" + (Date.now() - start) + "ms)", - ); - return 0; - } else { - let errors = 0; - let fixedErrors = 0; - if (valModule.errors) { - if (valModule.errors.validation) { - for (const [sourcePath, validationErrors] of Object.entries( - valModule.errors.validation, - )) { - for (const v of validationErrors) { - if (!v.fixes || v.fixes.length === 0) { - // No fixes available - just report error - errors += 1; - console.log( - picocolors.red("✘"), - "Got error in", - `${sourcePath}:`, - v.message, - ); - continue; - } - - // Find and execute appropriate handler - const fixType = v.fixes[0]; // Take first fix - const handler = fixHandlers[fixType]; - if (!handler) { - console.log( - picocolors.red("✘"), - "Unknown fix", - v.fixes, - "for", - sourcePath, - ); - errors += 1; - continue; - } - - // Execute handler - const result = await handler({ - sourcePath: sourcePath as SourcePath, - validationError: v, - valModule, - projectRoot, - fix: !!fix, - service, - valFiles, - moduleFilePath, - file, - remoteFiles, - publicProjectId, - remoteFileBuckets, - remoteFilesCounter, - valRemoteHost, - contentHostUrl, - valConfigFile: valConfigFile ?? undefined, - keyOfCache, - routerModulesCache, - }); - - // Update shared state from handler result - if (result.publicProjectId !== undefined) { - publicProjectId = result.publicProjectId; - } - if (result.remoteFileBuckets !== undefined) { - remoteFileBuckets = result.remoteFileBuckets; - } - if (result.remoteFilesCounter !== undefined) { - remoteFilesCounter = result.remoteFilesCounter; - } - - if (!result.success) { - console.log(picocolors.red("✘"), result.errorMessage); - errors += 1; - continue; - } - - // Apply patch if needed - if (result.shouldApplyPatch) { - const fixPatch = await createFixPatch( - { projectRoot, remoteHost: valRemoteHost }, - !!fix, - sourcePath as SourcePath, - v, - remoteFiles, - valModule.source, - valModule.schema, - ); + let prettier; + try { + prettier = (await import("prettier")).default; + } catch { + console.log("Prettier not found, skipping formatting"); + } - if (fix && fixPatch?.patch && fixPatch?.patch.length > 0) { - await service.patch(moduleFilePath, fixPatch.patch); - didFix = true; - fixedErrors += 1; - console.log( - picocolors.yellow("⚠"), - "Applied fix for", - sourcePath, - ); - } + const fixedFiles = new Set(); + let totalErrors = 0; - fixPatch?.remainingErrors?.forEach((e) => { - errors += 1; - console.log( - e.fixes && e.fixes.length - ? picocolors.yellow("⚠") - : picocolors.red("✘"), - `Got ${e.fixes && e.fixes.length ? "fixable " : ""}error in`, - `${sourcePath}:`, - e.message, - ); - }); - } - } - } - } - if ( - fixedErrors === errors && - (!valModule.errors.fatal || valModule.errors.fatal.length == 0) - ) { - console.log( - picocolors.green("✔"), - moduleFilePath, - "is valid (" + (Date.now() - start) + "ms)", - ); - } - for (const fatalError of valModule.errors.fatal || []) { - errors += 1; - console.log( - picocolors.red("✘"), - moduleFilePath, - "is invalid:", - fatalError.message, - ); - } - } else { + for await (const event of runValidation({ + root: projectRoot, + fix: !!fix, + valFiles, + project: resolvedValConfigFile?.project, + remote: { + remoteHost: process.env.VAL_REMOTE_HOST || DEFAULT_VAL_REMOTE_HOST, + getSettings: (projectName, options) => + getSettings(projectName, options), + uploadFile: (project, bucket, fileHash, fileExt, fileBuffer, options) => + uploadRemoteFile( + process.env.VAL_CONTENT_URL || DEFAULT_CONTENT_HOST, + project, + bucket, + fileHash, + fileExt ?? "", + fileBuffer, + options, + ), + }, + fs: createDefaultValFSHost(), + })) { + switch (event.type) { + case "file-valid": console.log( picocolors.green("✔"), - moduleFilePath, - "is valid (" + (Date.now() - start) + "ms)", + event.file, + "is valid (" + event.durationMs + "ms)", ); - } - if (errors > 0) { + break; + case "file-error-count": console.log( picocolors.red("✘"), - `${`/${file}`} contains ${errors} error${errors > 1 ? "s" : ""}`, - " (" + (Date.now() - start) + "ms)", + `${event.file} contains ${event.errorCount} error${event.errorCount > 1 ? "s" : ""}`, + " (" + event.durationMs + "ms)", ); - } - return errors; + totalErrors += event.errorCount; + break; + case "validation-error": + console.log( + picocolors.red("✘"), + "Got error in", + `${event.sourcePath}:`, + event.message, + ); + break; + case "validation-fixable-error": + console.log( + event.fixable ? picocolors.yellow("⚠") : picocolors.red("✘"), + `Got ${event.fixable ? "fixable " : ""}error in`, + `${event.sourcePath}:`, + event.message, + ); + break; + case "unknown-fix": + console.log( + picocolors.red("✘"), + "Unknown fix", + event.fixes, + "for", + event.sourcePath, + ); + break; + case "fix-applied": + console.log( + picocolors.yellow("⚠"), + "Applied fix for", + event.sourcePath, + ); + fixedFiles.add(event.file); + break; + case "fatal-error": + console.log( + picocolors.red("✘"), + event.file, + "is invalid:", + event.message, + ); + break; + case "remote-uploading": + console.log( + picocolors.yellow("⚠"), + `Uploading remote file: '${event.ref}'...`, + ); + break; + case "remote-uploaded": + console.log( + picocolors.green("✔"), + `Completed upload of remote file: '${event.ref}'`, + ); + break; + case "remote-already-uploaded": + console.log( + picocolors.yellow("⚠"), + `Remote file ${event.filePath} already uploaded`, + ); + break; + case "remote-downloading": + console.log( + picocolors.yellow("⚠"), + `Downloading remote file in ${event.sourcePath}...`, + ); + break; + case "summary-errors": + case "summary-success": + break; } } - for (const file of valFiles.sort()) { - didFix = false; - errors += await validateFile(file); - if (prettier && didFix) { + // Run prettier on files that had fixes applied + if (prettier) { + for (const file of fixedFiles) { const filePath = path.join(projectRoot, file); const fileContent = await fs.readFile(filePath, "utf-8"); - const formattedContent = await prettier?.format(fileContent, { + const formattedContent = await prettier.format(fileContent, { filepath: filePath, }); await fs.writeFile(filePath, formattedContent); } } - if (errors > 0) { + + if (totalErrors > 0) { console.log( picocolors.red("✘"), "Got", - errors, - "error" + (errors > 1 ? "s" : ""), + totalErrors, + "error" + (totalErrors > 1 ? "s" : ""), ); process.exit(1); } else { console.log(picocolors.green("✔"), "No validation errors found"); } - - service.dispose(); - return; -} - -// GPT generated levenshtein distance algorithm: -const levenshtein = (a: string, b: string): number => { - const [m, n] = [a.length, b.length]; - if (!m || !n) return Math.max(m, n); - - const dp = Array.from({ length: m + 1 }, (_, i) => i); - - for (let j = 1; j <= n; j++) { - let prev = dp[0]; - dp[0] = j; - - for (let i = 1; i <= m; i++) { - const temp = dp[i]; - dp[i] = - a[i - 1] === b[j - 1] - ? prev - : Math.min(prev + 1, dp[i - 1] + 1, dp[i] + 1); - prev = temp; - } - } - - return dp[m]; -}; - -function findSimilar(key: string, targets: string[]) { - return targets - .map((target) => ({ target, distance: levenshtein(key, target) })) - .sort((a, b) => a.distance - b.distance); } diff --git a/packages/core/src/enrichFileImageRemoteSourceWithMetadata.test.ts b/packages/core/src/enrichFileImageRemoteSourceWithMetadata.test.ts new file mode 100644 index 000000000..87e79c1df --- /dev/null +++ b/packages/core/src/enrichFileImageRemoteSourceWithMetadata.test.ts @@ -0,0 +1,480 @@ +import { initVal } from "./initVal"; +import { enrichFileImageRemoteSourceWithMetadata } from "./module"; +import { Internal } from "."; + +describe("enrichFileImageRemoteSourceWithMetadata", () => { + test("should enrich image source with metadata from images module", () => { + const { c, s } = initVal(); + + // Define an images module (like media.val.ts) + const imagesModule = c.define( + "/content/images.val.ts", + s.images({ + accept: "image/webp", + directory: "/public/val/images", + }), + { + "/public/val/images/logo.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: "An example image", + }, + }, + ); + + const testSchema = s.object({ + test: s.image(imagesModule), + }); + + const testModule = c.define("/content/test.val.ts", testSchema, { + test: c.image("/public/val/images/logo.png"), + }); + const source = Internal.getSource(testModule); + const enrichedSource = enrichFileImageRemoteSourceWithMetadata( + source, + testSchema, + ); + expect(enrichedSource).toEqual({ + test: c.image("/public/val/images/logo.png", { + width: 800, + height: 600, + mimeType: "image/png", + alt: "An example image", + }), + }); + }); + + test("should enrich deeply nested schema with multiple images, files, records, arrays, unions, and richtext", () => { + const { c, s } = initVal(); + + // Define 3 different images modules + const avatarsModule = c.define( + "/content/avatars.val.ts", + s.images({ + accept: "image/*", + directory: "/public/val/avatars", + }), + { + "/public/val/avatars/john.png": { + width: 200, + height: 200, + mimeType: "image/png", + alt: "John's avatar", + }, + "/public/val/avatars/jane.png": { + width: 150, + height: 150, + mimeType: "image/png", + alt: "Jane's avatar", + }, + }, + ); + + // Remote images module - uses c.remote() instead of c.image() + const productsModule = c.define( + "/content/products.val.ts", + s + .images({ + accept: "image/*", + directory: "/public/val/products", + }) + .remote(), + { + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/widget123/p/public/val/products/widget.jpg": + { + width: 600, + height: 400, + mimeType: "image/jpeg", + alt: "Widget product", + }, + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/gadget456/p/public/val/products/gadget.jpg": + { + width: 800, + height: 600, + mimeType: "image/jpeg", + alt: "Gadget product", + }, + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/inline789/p/public/val/products/inline-product.png": + { + width: 100, + height: 100, + mimeType: "image/png", + alt: "Inline product image", + }, + }, + ); + + const bannersModule = c.define( + "/content/banners.val.ts", + s.images({ + accept: "image/*", + directory: "/public/val/banners", + }), + { + "/public/val/banners/hero.webp": { + width: 1920, + height: 1080, + mimeType: "image/webp", + alt: "Hero banner", + }, + "/public/val/banners/promo.webp": { + width: 1200, + height: 600, + mimeType: "image/webp", + alt: "Promo banner", + }, + }, + ); + + // Define 1 files module + const documentsModule = c.define( + "/content/documents.val.ts", + s.files({ + accept: "application/pdf", + directory: "/public/val/documents", + }), + { + "/public/val/documents/manual.pdf": { + mimeType: "application/pdf", + }, + "/public/val/documents/brochure.pdf": { + mimeType: "application/pdf", + }, + }, + ); + + // Create deeply nested schema with all combinations + const deepSchema = s.object({ + // Simple nested object with image + header: s.object({ + banner: s.image(bannersModule), + }), + + // Record -> Object -> Image + users: s.record( + s.object({ + name: s.string(), + avatar: s.image(avatarsModule), + }), + ), + + // Array -> Object -> Object -> Array -> Image (deep nesting) + products: s.array( + s.object({ + name: s.string(), + details: s.object({ + description: s.string(), + gallery: s.array(s.image(productsModule)), + }), + }), + ), + + // Array -> Union with different types at each variant + contentBlocks: s.array( + s.union( + "type", + s.object({ + type: s.literal("hero"), + backgroundImage: s.image(bannersModule), + }), + s.object({ + type: s.literal("product"), + productImage: s.image(productsModule), + }), + s.object({ + type: s.literal("document"), + file: s.file(documentsModule), + }), + s.object({ + type: s.literal("article"), + body: s.richtext({ + inline: { + img: s.image(productsModule), + }, + }), + }), + ), + ), + + // Union at object level + sidebar: s.union( + "variant", + s.object({ + variant: s.literal("promo"), + promoImage: s.image(bannersModule), + }), + s.object({ + variant: s.literal("download"), + downloadFile: s.file(documentsModule), + }), + ), + + // Deep 3-level object nesting with richtext at bottom + nested: s.object({ + level1: s.object({ + level2: s.object({ + level3: s.object({ + deepImage: s.image(avatarsModule), + richContent: s.richtext({ + inline: { + img: s.image(avatarsModule), + }, + }), + }), + }), + }), + }), + }); + + // Create test data + const testModule = c.define("/content/deep-test.val.ts", deepSchema, { + header: { + banner: c.image("/public/val/banners/hero.webp"), + }, + + users: { + john: { + name: "John Doe", + avatar: c.image("/public/val/avatars/john.png"), + }, + jane: { + name: "Jane Smith", + avatar: c.image("/public/val/avatars/jane.png"), + }, + }, + + products: [ + { + name: "Widget", + details: { + description: "A useful widget", + gallery: [ + c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/widget123/p/public/val/products/widget.jpg", + ), + c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/gadget456/p/public/val/products/gadget.jpg", + ), + ], + }, + }, + ], + + contentBlocks: [ + { + type: "hero", + backgroundImage: c.image("/public/val/banners/hero.webp"), + }, + { + type: "product", + productImage: c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/widget123/p/public/val/products/widget.jpg", + ), + }, + { + type: "document", + file: c.file("/public/val/documents/manual.pdf"), + }, + { + type: "article", + body: [ + { + tag: "p", + children: [ + "Check out this product: ", + { + tag: "img", + src: c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/inline789/p/public/val/products/inline-product.png", + ), + }, + ], + }, + ], + }, + ], + + sidebar: { + variant: "promo", + promoImage: c.image("/public/val/banners/promo.webp"), + }, + + nested: { + level1: { + level2: { + level3: { + deepImage: c.image("/public/val/avatars/john.png"), + richContent: [ + { + tag: "p", + children: [ + "Deep content with image: ", + { + tag: "img", + src: c.image("/public/val/avatars/jane.png"), + }, + ], + }, + ], + }, + }, + }, + }, + }); + + const source = Internal.getSource(testModule); + const enrichedSource = enrichFileImageRemoteSourceWithMetadata( + source, + deepSchema, + ); + + // Verify header banner + expect(enrichedSource.header.banner).toEqual( + c.image("/public/val/banners/hero.webp", { + width: 1920, + height: 1080, + mimeType: "image/webp", + alt: "Hero banner", + }), + ); + + // Verify record -> object -> image + expect(enrichedSource.users.john.avatar).toEqual( + c.image("/public/val/avatars/john.png", { + width: 200, + height: 200, + mimeType: "image/png", + alt: "John's avatar", + }), + ); + expect(enrichedSource.users.jane.avatar).toEqual( + c.image("/public/val/avatars/jane.png", { + width: 150, + height: 150, + mimeType: "image/png", + alt: "Jane's avatar", + }), + ); + + // Verify array -> object -> object -> array -> remote image + expect(enrichedSource.products[0].details.gallery[0]).toEqual( + c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/widget123/p/public/val/products/widget.jpg", + { + width: 600, + height: 400, + mimeType: "image/jpeg", + alt: "Widget product", + }, + ), + ); + expect(enrichedSource.products[0].details.gallery[1]).toEqual( + c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/gadget456/p/public/val/products/gadget.jpg", + { + width: 800, + height: 600, + mimeType: "image/jpeg", + alt: "Gadget product", + }, + ), + ); + + // Verify array -> union variants + expect(enrichedSource.contentBlocks[0]).toEqual({ + type: "hero", + backgroundImage: c.image("/public/val/banners/hero.webp", { + width: 1920, + height: 1080, + mimeType: "image/webp", + alt: "Hero banner", + }), + }); + + expect(enrichedSource.contentBlocks[1]).toEqual({ + type: "product", + productImage: c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/widget123/p/public/val/products/widget.jpg", + { + width: 600, + height: 400, + mimeType: "image/jpeg", + alt: "Widget product", + }, + ), + }); + + expect(enrichedSource.contentBlocks[2]).toEqual({ + type: "document", + file: c.file("/public/val/documents/manual.pdf", { + mimeType: "application/pdf", + }), + }); + + // Verify richtext inline remote image in union + expect(enrichedSource.contentBlocks[3]).toEqual({ + type: "article", + body: [ + { + tag: "p", + children: [ + "Check out this product: ", + { + tag: "img", + src: c.remote( + "https://example.com/file/p/test/b/default/v/1.0.0/h/abc123/f/inline789/p/public/val/products/inline-product.png", + { + width: 100, + height: 100, + mimeType: "image/png", + alt: "Inline product image", + }, + ), + }, + ], + }, + ], + }); + + // Verify sidebar union + expect(enrichedSource.sidebar).toEqual({ + variant: "promo", + promoImage: c.image("/public/val/banners/promo.webp", { + width: 1200, + height: 600, + mimeType: "image/webp", + alt: "Promo banner", + }), + }); + + // Verify deep nested object with image + expect(enrichedSource.nested.level1.level2.level3.deepImage).toEqual( + c.image("/public/val/avatars/john.png", { + width: 200, + height: 200, + mimeType: "image/png", + alt: "John's avatar", + }), + ); + + // Verify deep nested richtext inline image + expect(enrichedSource.nested.level1.level2.level3.richContent).toEqual([ + { + tag: "p", + children: [ + "Deep content with image: ", + { + tag: "img", + src: c.image("/public/val/avatars/jane.png", { + width: 150, + height: 150, + mimeType: "image/png", + alt: "Jane's avatar", + }), + }, + ], + }, + ]); + }); +}); diff --git a/packages/core/src/initSchema.ts b/packages/core/src/initSchema.ts index ca2eb98d1..91ebe6a0c 100644 --- a/packages/core/src/initSchema.ts +++ b/packages/core/src/initSchema.ts @@ -11,9 +11,11 @@ import { literal } from "./schema/literal"; import { keyOf } from "./schema/keyOf"; import { record } from "./schema/record"; import { file } from "./schema/file"; +import { files } from "./schema/files"; import { date } from "./schema/date"; import { route } from "./schema/route"; import { router } from "./schema/router"; +import { images } from "./schema/images"; // import { i18n, I18n } from "./schema/future/i18n"; // import { oneOf } from "./schema/future/oneOf"; @@ -200,6 +202,44 @@ export type InitSchema = { * @returns A RecordSchema configured as a router */ readonly router: typeof router; + /** + * Define a collection of images. + * + * @example + * ```typescript + * const schema = s.images({ + * accept: "image/webp", + * directory: "/public/val/images", + * alt: s.string().minLength(4), + * }); + * export default c.define("/content/images.val.ts", schema, { + * "/public/val/images/hero.webp": { + * width: 1920, + * height: 1080, + * mimeType: "image/webp", + * alt: "Hero image", + * }, + * }); + * ``` + */ + readonly images: typeof images; + /** + * Define a collection of files. + * + * @example + * ```typescript + * const schema = s.files({ + * accept: "application/pdf", + * directory: "/public/val/documents", + * }); + * export default c.define("/content/documents.val.ts", schema, { + * "/public/val/documents/report.pdf": { + * mimeType: "application/pdf", + * }, + * }); + * ``` + */ + readonly files: typeof files; }; // export type InitSchemaLocalized = { // readonly i18n: I18n; @@ -220,9 +260,11 @@ export function initSchema() { keyOf, record, file, + files, date, route, router, + images, // i18n: i18n(locales), }; } diff --git a/packages/core/src/module.ts b/packages/core/src/module.ts index 5e6840ad4..67ef43ca5 100644 --- a/packages/core/src/module.ts +++ b/packages/core/src/module.ts @@ -19,13 +19,13 @@ import { ImageSchema, SerializedImageSchema, } from "./schema/image"; -import { FileSource } from "./source/file"; +import { FILE_REF_PROP, FileSource } from "./source/file"; import { AllRichTextOptions, RichTextSource } from "./source/richtext"; import { RecordSchema, SerializedRecordSchema } from "./schema/record"; import { RawString } from "./schema/string"; import { ImageSelector } from "./selector/image"; import { ImageSource } from "./source/image"; -import { ModuleFilePathSep } from "."; +import { FileMetadata, FileSchema, ModuleFilePathSep } from "."; const brand = Symbol("ValModule"); export type ValModule = SelectorOf & @@ -55,9 +55,7 @@ export type ReplaceRawStringWithString = export function define>( id: string, // TODO: `/${string}` - schema: T, - source: ReplaceRawStringWithString>, ): ValModule> { return { @@ -67,12 +65,85 @@ export function define>( } as unknown as ValModule>; } -export function getSource(valModule: ValModule): Source { - const sourceOrExpr = valModule[GetSource]; - const source = sourceOrExpr; +export function enrichFileImageRemoteSourceWithMetadata< + T extends SelectorSource, +>(source: T, schema: Schema): T { + const addedModules = new Set(); + let filesLookup: Record = {}; + function traverseSchema(schema: Schema) { + if (schema instanceof ImageSchema || schema instanceof FileSchema) { + const moduleMetadata = + schema instanceof ImageSchema + ? schema["moduleMetadata"] + : schema["moduleMetadata"]; + for (const [modulePath, valModule] of Object.entries( + moduleMetadata || {}, + )) { + if (addedModules.has(modulePath)) { + continue; + } + addedModules.add(modulePath); + filesLookup = { + ...filesLookup, + ...valModule, + }; + } + } else if (schema instanceof ObjectSchema) { + for (const value of Object.values(schema["items"])) { + if (value instanceof Schema) { + traverseSchema(value); + } + } + } else if (schema instanceof ArraySchema) { + traverseSchema(schema["item"]); + } else if (schema instanceof RecordSchema) { + traverseSchema(schema["item"]); + } else if (schema instanceof UnionSchema) { + for (const value of schema["items"]) { + if (value instanceof ObjectSchema) { + traverseSchema(value); + } + } + } else if (schema instanceof RichTextSchema) { + if (schema["options"]?.inline?.img instanceof ImageSchema) { + traverseSchema(schema["options"].inline.img); + } + } + } + function injectMetadataIntoSource(source: SelectorSource): SelectorSource { + if (!source) { + return source; + } + if ( + typeof source === "object" && + FILE_REF_PROP in source && + typeof source[FILE_REF_PROP] === "string" + ) { + const fileRef = source[FILE_REF_PROP]; + const metadata = filesLookup[fileRef]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (source as any).metadata = metadata; + } else if (typeof source === "object") { + if (Array.isArray(source)) { + for (const item of source) { + injectMetadataIntoSource(item); + } + } else { + for (const value of Object.values(source)) { + injectMetadataIntoSource(value); + } + } + } + } + traverseSchema(schema); + injectMetadataIntoSource(source); return source; } +export function getSource(valModule: ValModule): T { + return valModule[GetSource] as T; +} + export function splitModuleFilePathAndModulePath( path: SourcePath | ModuleFilePath, ): [moduleId: ModuleFilePath, path: ModulePath] { diff --git a/packages/core/src/patch/util.ts b/packages/core/src/patch/util.ts index 397280a3c..56707ccd6 100644 --- a/packages/core/src/patch/util.ts +++ b/packages/core/src/patch/util.ts @@ -70,5 +70,6 @@ export function parseAndValidateArrayIndex( export function sourceToPatchPath(sourcePath: SourcePath) { const [, modulePath] = splitModuleFilePathAndModulePath(sourcePath); + if (!modulePath) return []; return modulePath.split(".").map((p) => JSON.parse(p).toString()); } diff --git a/packages/core/src/schema/deserialize.ts b/packages/core/src/schema/deserialize.ts index 1df84d462..fc6a973ae 100644 --- a/packages/core/src/schema/deserialize.ts +++ b/packages/core/src/schema/deserialize.ts @@ -106,6 +106,17 @@ export function deserializeSchema( serialized.key ? (deserializeSchema(serialized.key) as Schema) : null, + serialized.mediaType + ? { + type: serialized.mediaType, + accept: serialized.accept ?? "*/*", + directory: serialized.directory ?? "/public/val", + remote: serialized.remote ?? false, + altSchema: serialized.alt + ? deserializeSchema(serialized.alt) + : undefined, + } + : undefined, ); case "keyOf": return new KeyOfSchema( diff --git a/packages/core/src/schema/file.ts b/packages/core/src/schema/file.ts index 0b0514aca..523efa043 100644 --- a/packages/core/src/schema/file.ts +++ b/packages/core/src/schema/file.ts @@ -8,13 +8,15 @@ import { SerializedSchema, } from "."; import { VAL_EXTENSION } from "../source"; -import { SourcePath } from "../val"; +import { getValPath, ModulePath, SourcePath } from "../val"; import { ValidationError, ValidationErrors, } from "./validation/ValidationError"; -import { Internal, RemoteSource } from ".."; +import { Internal, RemoteSource, ValModule } from ".."; import { ReifiedRender } from "../render"; +import { FilesEntryMetadata } from "./files"; +import { getSource } from "../module"; export type FileOptions = { accept?: string; @@ -26,6 +28,7 @@ export type SerializedFileSchema = { remote?: boolean; opt: boolean; customValidate?: boolean; + referencedModule?: string; }; export type FileMetadata = { @@ -42,19 +45,32 @@ export class FileSchema< private readonly opt: boolean = false, protected readonly isRemote: boolean = false, private readonly customValidateFunctions: CustomValidateFunction[] = [], + private readonly moduleMetadata: Record< + ModulePath, + Record + > = {}, ) { super(); } remote(): FileSchema> { - return new FileSchema(this.options, this.opt, true); + return new FileSchema( + this.options, + this.opt, + true, + this.customValidateFunctions, + this.moduleMetadata, + ) as FileSchema>; } validate(validationFunction: CustomValidateFunction): FileSchema { - return new FileSchema(this.options, this.opt, this.isRemote, [ - ...this.customValidateFunctions, - validationFunction, - ]); + return new FileSchema( + this.options, + this.opt, + this.isRemote, + [...this.customValidateFunctions, validationFunction], + this.moduleMetadata, + ); } protected executeValidate(path: SourcePath, src: Src): ValidationErrors { @@ -298,10 +314,19 @@ export class FileSchema< } nullable(): FileSchema { - return new FileSchema(this.options, true); + return new FileSchema( + this.options, + true, + this.isRemote, + this.customValidateFunctions as CustomValidateFunction[], + this.moduleMetadata, + ); } protected executeSerialize(): SerializedSchema { + const modulePaths = this.moduleMetadata + ? Object.keys(this.moduleMetadata) + : []; return { type: "file", options: this.options, @@ -310,6 +335,8 @@ export class FileSchema< customValidate: this.customValidateFunctions && this.customValidateFunctions?.length > 0, + referencedModule: + modulePaths.length > 0 ? (modulePaths[0] as string) : undefined, }; } @@ -319,9 +346,32 @@ export class FileSchema< } export const file = ( - options?: FileOptions, -): FileSchema> => { - return new FileSchema(options); + options?: FileOptions | ValModule>, +): FileSchema> => { + const isModule = + !!options && + !!Internal.getValPath( + options as ValModule>, + ); + if (isModule) { + const allModules: Record> = {}; + for (const valModule of [ + options as ValModule>, + ]) { + const modulePath = getValPath(valModule) as ModulePath | undefined; + if (modulePath === undefined) { + throw new Error( + `Invalid argument passed to s.file(). Expected a ValModule constructed through c.define, but got an object without a valid module path.`, + ); + } + allModules[modulePath] = getSource(valModule) as Record< + string, + FilesEntryMetadata + >; + } + return new FileSchema({}, false, false, [], allModules); + } + return new FileSchema(options as FileOptions); }; export function convertFileSource< diff --git a/packages/core/src/schema/files.test.ts b/packages/core/src/schema/files.test.ts new file mode 100644 index 000000000..2019fb026 --- /dev/null +++ b/packages/core/src/schema/files.test.ts @@ -0,0 +1,510 @@ +import { SourcePath } from "../val"; +import { files, FilesEntryMetadata } from "./files"; + +describe("FilesSchema", () => { + describe("assert", () => { + test("should return success if src is a valid files object", () => { + const schema = files({ accept: "application/pdf" }); + const src: Record = { + "/public/val/document.pdf": { + mimeType: "application/pdf", + }, + }; + expect(schema["executeAssert"]("path" as SourcePath, src)).toEqual({ + success: true, + data: src, + }); + }); + + test("should return error if src is null (non-nullable)", () => { + const schema = files({ accept: "application/pdf" }); + const result = schema["executeAssert"]("path" as SourcePath, null); + expect(result.success).toEqual(false); + }); + + test("should return success if src is null (nullable)", () => { + const schema = files({ accept: "application/pdf" }).nullable(); + expect(schema["executeAssert"]("path" as SourcePath, null)).toEqual({ + success: true, + data: null, + }); + }); + + test("should return error if src is not an object", () => { + const schema = files({ accept: "application/pdf" }); + const result = schema["executeAssert"]("path" as SourcePath, "test"); + expect(result.success).toEqual(false); + }); + + test("should return error if src is an array", () => { + const schema = files({ accept: "application/pdf" }); + const result = schema["executeAssert"]("path" as SourcePath, []); + expect(result.success).toEqual(false); + }); + }); + + describe("validate", () => { + test("should validate directory prefix", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/documents", + }); + const src: Record = { + "/public/val/wrong/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + expect(Object.values(result as object)[0][0].message).toContain( + "must be within the /public/val/documents/ directory", + ); + }); + + test("should accept valid directory prefix", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/documents", + }); + const src: Record = { + "/public/val/documents/report.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + + test("should validate mimeType against accept pattern", () => { + const schema = files({ accept: "application/pdf" }); + const src: Record = { + "/public/val/document.docx": { + mimeType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + expect(Object.values(result as object)[0][0].message).toContain( + "Mime type mismatch", + ); + }); + + test("should accept wildcard mimeType patterns", () => { + const schema = files({ accept: "application/*" }); + const src: Record = { + "/public/val/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have mime type error + if (result) { + const errors = Object.values(result as object).flat(); + const hasMimeError = errors.some((e: { message: string }) => + e.message.includes("Mime type mismatch"), + ); + expect(hasMimeError).toBe(false); + } + }); + + test("should accept any mimeType with */*", () => { + const schema = files({ accept: "*/*" }); + const src: Record = { + "/public/val/anything.xyz": { + mimeType: "application/octet-stream", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have mime type error + if (result) { + const errors = Object.values(result as object).flat(); + const hasMimeError = errors.some((e: { message: string }) => + e.message.includes("Mime type mismatch"), + ); + expect(hasMimeError).toBe(false); + } + }); + + test("should use default directory /public/val", () => { + const schema = files({ accept: "application/pdf" }); + const src: Record = { + "/public/val/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + + test("should accept /public as directory", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public", + }); + const src: Record = { + "/public/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + + test("should validate mimeType is a string", () => { + const schema = files({ accept: "application/pdf" }); + const src = { + "/public/val/document.pdf": { + mimeType: 123, + }, + }; + const result = schema["executeValidate"]( + "path" as SourcePath, + src as unknown as Record, + ); + expect(result).toBeTruthy(); + }); + }); + + describe("serialization", () => { + test("should serialize with correct type", () => { + const schema = files({ accept: "application/pdf" }); + const serialized = schema["executeSerialize"](); + expect(serialized.type).toBe("record"); + expect((serialized as any).mediaType).toBe("files"); + expect(serialized.accept).toBe("application/pdf"); + expect(serialized.directory).toBe("/public/val"); + expect(serialized.opt).toBe(false); + expect(serialized.remote).toBe(false); + }); + + test("should serialize with custom directory", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/custom", + }); + const serialized = schema["executeSerialize"](); + expect(serialized.directory).toBe("/public/val/custom"); + }); + + test("should serialize remote flag", () => { + const schema = files({ accept: "application/pdf" }).remote(); + const serialized = schema["executeSerialize"](); + expect(serialized.remote).toBe(true); + }); + + test("should serialize nullable flag", () => { + const schema = files({ accept: "application/pdf" }).nullable(); + const serialized = schema["executeSerialize"](); + expect(serialized.opt).toBe(true); + }); + }); + + describe("remote", () => { + test("should create remote variant", () => { + const schema = files({ accept: "application/pdf" }); + const remoteSchema = schema.remote(); + expect(remoteSchema["executeSerialize"]().remote).toBe(true); + }); + + test("should reject remote URLs when remote is not enabled", () => { + const schema = files({ accept: "application/pdf" }); + const src: Record = { + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/document.pdf": + { + mimeType: "application/pdf", + }, + }; + 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); + }); + + test("should accept remote URLs when remote is enabled", () => { + const schema = files({ accept: "application/pdf" }).remote(); + const src: Record = { + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/document.pdf": + { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeFalsy(); + }); + + test("should accept local paths when remote is enabled", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/documents", + }).remote(); + const src: Record = { + "/public/val/documents/local.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have path errors + if (result) { + const errors = Object.values(result as object).flat(); + const hasPathError = errors.some( + (e: { message: string }) => + e.message.includes("directory") || e.message.includes("Remote"), + ); + expect(hasPathError).toBe(false); + } + }); + + test("should accept mixed remote and local when remote is enabled", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/documents", + }).remote(); + const src: Record = { + "/public/val/documents/local.pdf": { + mimeType: "application/pdf", + }, + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/documents/remote.pdf": + { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeFalsy(); + }); + + test("should reject invalid remote URLs", () => { + const schema = files({ accept: "application/pdf" }).remote(); + const src: Record = { + "not-a-valid-url": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasUrlError = errors.some((e: { message: string }) => + e.message.includes("Expected a remote URL"), + ); + expect(hasUrlError).toBe(true); + }); + + test("should reject paths outside directory when remote is enabled but path is not a URL", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/documents", + }).remote(); + const src: Record = { + "/public/other/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasError = errors.some((e: { message: string }) => + e.message.includes("Expected a remote URL"), + ); + expect(hasError).toBe(true); + }); + + test("should accept http URLs when remote is enabled", () => { + const schema = files({ accept: "application/pdf" }).remote(); + const src: Record = { + "http://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/document.pdf": + { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeFalsy(); + }); + + test("should reject non-Val remote URLs", () => { + const schema = files({ accept: "application/pdf" }).remote(); + const src: Record = { + "https://example.com/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasInvalidFormatError = errors.some((e: { message: string }) => + e.message.includes("Invalid remote URL format"), + ); + expect(hasInvalidFormatError).toBe(true); + }); + + test("should reject remote URLs with wrong directory in path", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/documents", + }).remote(); + const src: Record = { + // Remote URL with public/val/other instead of public/val/documents + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/other/document.pdf": + { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasDirectoryError = errors.some((e: { message: string }) => + e.message.includes("not in expected directory"), + ); + expect(hasDirectoryError).toBe(true); + }); + }); + + describe("directory validation", () => { + test("should reject paths with wrong prefix", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val/documents", + }); + const src: Record = { + "/wrong/path/document.pdf": { + mimeType: "application/pdf", + }, + }; + 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("directory"), + ); + expect(hasDirError).toBe(true); + }); + + test("should accept paths with exact directory match", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public", + }); + const src: Record = { + "/public/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + + test("should accept paths in subdirectories", () => { + const schema = files({ + accept: "application/pdf", + directory: "/public/val", + }); + const src: Record = { + "/public/val/nested/deep/document.pdf": { + mimeType: "application/pdf", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + }); + + describe("custom validation", () => { + test("should support custom validation function", () => { + const schema = files({ accept: "application/pdf" }).validate((src) => { + if (Object.keys(src ?? {}).length === 0) { + return "At least one file is required"; + } + return false; + }); + const src: Record = {}; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasCustomError = errors.some((e: { message: string }) => + e.message.includes("At least one file is required"), + ); + expect(hasCustomError).toBe(true); + }); + }); + + describe("accept patterns", () => { + test("should accept comma-separated mime types", () => { + const schema = files({ accept: "application/pdf, application/msword" }); + const src1: Record = { + "/public/val/document.pdf": { + mimeType: "application/pdf", + }, + }; + const src2: Record = { + "/public/val/document.doc": { + mimeType: "application/msword", + }, + }; + const result1 = schema["executeValidate"]("path" as SourcePath, src1); + const result2 = schema["executeValidate"]("path" as SourcePath, src2); + // Neither should have mime type errors + [result1, result2].forEach((result) => { + if (result) { + const errors = Object.values(result as object).flat(); + const hasMimeError = errors.some((e: { message: string }) => + e.message.includes("Mime type mismatch"), + ); + expect(hasMimeError).toBe(false); + } + }); + }); + + test("should reject mime types not in accept list", () => { + const schema = files({ accept: "application/pdf, application/msword" }); + const src: Record = { + "/public/val/document.txt": { + mimeType: "text/plain", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + 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); + }); + }); +}); diff --git a/packages/core/src/schema/files.ts b/packages/core/src/schema/files.ts new file mode 100644 index 000000000..ef645ac76 --- /dev/null +++ b/packages/core/src/schema/files.ts @@ -0,0 +1,74 @@ +import { Schema } from "."; +import type { SerializedRecordSchema } from "./record"; +import { RecordSchema } from "./record"; +import { ObjectSchema } from "./object"; +import { StringSchema } from "./string"; + +/** + * Options for s.files() + */ +export type FilesOptions = { + /** + * The accepted mime type pattern (e.g., "application/pdf", "text/*", "*\/*") + */ + accept: string; + /** + * The directory where files should be stored. + * Must start with "/public" (e.g., "/public/val/files") + * @default "/public/val" + */ + directory?: "/public" | `/public/${string}`; + /** + * Whether remote files are allowed + * @default false + */ + remote?: boolean; +}; + +/** + * Metadata for a file entry in the files record + */ +export type FilesEntryMetadata = { + mimeType: string; +}; + +export type SerializedFilesSchema = SerializedRecordSchema; + +type FilesItemProps = { mimeType: StringSchema }; +type FilesItemSrc = { mimeType: string }; + +/** + * Define a collection of files. + * + * @example + * ```typescript + * const schema = s.files({ + * accept: "application/pdf", + * directory: "/public/val/documents", + * }); + * export default c.define("/content/documents.val.ts", schema, { + * "/public/val/documents/report.pdf": { + * mimeType: "application/pdf", + * }, + * }); + * ``` + */ +export const files = ( + options: FilesOptions, +): RecordSchema< + ObjectSchema, + Schema, + Record +> => { + const directory = options.directory ?? "/public/val"; + const itemSchema = new ObjectSchema( + { mimeType: new StringSchema({}, false) }, + false, + ); + return new RecordSchema(itemSchema, false, [], null, null, { + type: "files", + accept: options.accept, + directory, + remote: options.remote ?? false, + }); +}; diff --git a/packages/core/src/schema/image.ts b/packages/core/src/schema/image.ts index b15da5d4d..c956aaa2f 100644 --- a/packages/core/src/schema/image.ts +++ b/packages/core/src/schema/image.ts @@ -8,14 +8,16 @@ import { import { VAL_EXTENSION } from "../source"; import { FileSource, FILE_REF_PROP } from "../source/file"; import { ImageSource } from "../source/image"; -import { SourcePath } from "../val"; +import { getValPath, ModulePath, SourcePath } from "../val"; import { ValidationError, ValidationErrors, } from "./validation/ValidationError"; -import { FileMetadata, FileSchema, Internal } from ".."; +import { FileMetadata, Internal, ValModule } from ".."; import { RemoteSource } from "../source/remote"; import { ReifiedRender } from "../render"; +import { ImagesEntryMetadata } from "./images"; +import { getSource } from "../module"; export type ImageOptions = { ext?: ["jpg"] | ["webp"]; @@ -30,11 +32,13 @@ export type SerializedImageSchema = { opt: boolean; remote?: boolean; customValidate?: boolean; + referencedModule?: string; }; -export type ImageMetadata = FileMetadata & { - width: number; - height: number; +export type ImageMetadata = { + width?: number; + height?: number; + mimeType?: string; alt?: string; hotspot?: { x: number; @@ -52,19 +56,32 @@ export class ImageSchema< private readonly opt: boolean = false, protected readonly isRemote: boolean = false, private readonly customValidateFunctions: CustomValidateFunction[] = [], + private readonly moduleMetadata: Record< + ModulePath, + Record + > = {}, ) { super(); } remote(): ImageSchema> { - return new ImageSchema(this.options, this.opt, true); + return new ImageSchema( + this.options, + this.opt, + true, + this.customValidateFunctions, + this.moduleMetadata, + ) as ImageSchema>; } validate(validationFunction: CustomValidateFunction): ImageSchema { - return new ImageSchema(this.options, this.opt, this.isRemote, [ - ...this.customValidateFunctions, - validationFunction, - ]); + return new ImageSchema( + this.options, + this.opt, + this.isRemote, + [...this.customValidateFunctions, validationFunction], + this.moduleMetadata, + ); } protected executeValidate(path: SourcePath, src: Src): ValidationErrors { @@ -152,7 +169,7 @@ export class ImageSchema< } const { accept } = this.options || {}; - const { mimeType } = src.metadata || {}; + const mimeType = src.metadata?.mimeType ?? ""; if (accept && mimeType && !mimeType.includes("/")) { return { @@ -252,6 +269,16 @@ export class ImageSchema< } as ValidationErrors; } + const isReferencedModule = Object.keys(this.moduleMetadata).length > 0; + if (src.metadata === undefined && isReferencedModule) { + if (customValidationErrors.length === 0) { + return false; + } + return { + [path]: [...customValidationErrors], + } as ValidationErrors; + } + return { [path]: [ ...customValidationErrors, @@ -330,10 +357,19 @@ export class ImageSchema< } nullable(): ImageSchema { - return new ImageSchema(this.options, true, this.isRemote); + return new ImageSchema( + this.options, + true, + this.isRemote, + this.customValidateFunctions as CustomValidateFunction[], + this.moduleMetadata, + ); } protected executeSerialize(): SerializedSchema { + const modulePaths = this.moduleMetadata + ? Object.keys(this.moduleMetadata) + : []; return { type: "image", options: this.options, @@ -342,6 +378,8 @@ export class ImageSchema< customValidate: this.customValidateFunctions && this.customValidateFunctions?.length > 0, + referencedModule: + modulePaths.length > 0 ? (modulePaths[0] as string) : undefined, }; } @@ -350,6 +388,31 @@ export class ImageSchema< } } -export const image = (options?: ImageOptions): ImageSchema => { - return new ImageSchema(options); +export const image = ( + options?: ImageOptions | ValModule>, +): ImageSchema> => { + const isModule = + !!options && + !!Internal.getValPath( + options as ValModule>, + ); + if (isModule) { + const allModules: Record> = {}; + for (const valModule of [ + options as ValModule>, + ]) { + const modulePath = getValPath(valModule) as ModulePath | undefined; + if (modulePath === undefined) { + throw new Error( + `Invalid argument passed to s.image(). Expected a ValModule constructed through c.define, but got an object without a valid module path.`, + ); + } + allModules[modulePath] = getSource(valModule) as Record< + string, + ImagesEntryMetadata + >; + } + return new ImageSchema({}, false, false, [], allModules); + } + return new ImageSchema(options as ImageOptions); }; diff --git a/packages/core/src/schema/images.test.ts b/packages/core/src/schema/images.test.ts new file mode 100644 index 000000000..a3413a90e --- /dev/null +++ b/packages/core/src/schema/images.test.ts @@ -0,0 +1,545 @@ +import { SourcePath } from "../val"; +import { images, ImagesEntryMetadata } from "./images"; +import { string } from "./string"; + +describe("ImagesSchema", () => { + describe("assert", () => { + test("should return success if src is a valid images object", () => { + const schema = images({ accept: "image/webp" }); + const src: Record = { + "/public/val/test.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Test image", + }, + }; + expect(schema["executeAssert"]("path" as SourcePath, src)).toEqual({ + success: true, + data: src, + }); + }); + + test("should return error if src is null (non-nullable)", () => { + const schema = images({ accept: "image/webp" }); + const result = schema["executeAssert"]("path" as SourcePath, null); + expect(result.success).toEqual(false); + }); + + test("should return success if src is null (nullable)", () => { + const schema = images({ accept: "image/webp" }).nullable(); + expect(schema["executeAssert"]("path" as SourcePath, null)).toEqual({ + success: true, + data: null, + }); + }); + + test("should return error if src is not an object", () => { + const schema = images({ accept: "image/webp" }); + const result = schema["executeAssert"]("path" as SourcePath, "test"); + expect(result.success).toEqual(false); + }); + + test("should return error if src is an array", () => { + const schema = images({ accept: "image/webp" }); + const result = schema["executeAssert"]("path" as SourcePath, []); + expect(result.success).toEqual(false); + }); + }); + + describe("validate", () => { + test("should validate directory prefix", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val/images", + }); + const src: Record = { + "/public/val/wrong/test.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Test image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + expect(Object.values(result as object)[0][0].message).toContain( + "must be within the /public/val/images/ directory", + ); + }); + + test("should accept valid directory prefix", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val/images", + }); + const src: Record = { + "/public/val/images/test.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Test image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error since path is valid + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + + test("should validate mimeType against accept pattern", () => { + const schema = images({ accept: "image/webp" }); + const src: Record = { + "/public/val/test.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: "Test image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + expect(Object.values(result as object)[0][0].message).toContain( + "Mime type mismatch", + ); + }); + + test("should accept wildcard mimeType patterns", () => { + const schema = images({ accept: "image/*" }); + const src: Record = { + "/public/val/test.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: "Test image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have mime type error (but may have metadata check error) + if (result) { + const errors = Object.values(result as object).flat(); + const hasMimeError = errors.some((e: { message: string }) => + e.message.includes("Mime type mismatch"), + ); + expect(hasMimeError).toBe(false); + } + }); + + test("should validate required width and height", () => { + const schema = images({ accept: "image/webp" }); + const src = { + "/public/val/test.webp": { + mimeType: "image/webp", + alt: "Test image", + }, + }; + const result = schema["executeValidate"]( + "path" as SourcePath, + src as unknown as Record, + ); + expect(result).toBeTruthy(); + }); + + test("should validate alt with custom alt schema", () => { + const schema = images({ + accept: "image/webp", + alt: string().minLength(10), + }); + const src: Record = { + "/public/val/test.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Short", // Less than 10 chars + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + }); + + test("should allow null alt when using nullable alt schema", () => { + const schema = images({ + accept: "image/webp", + alt: string().nullable(), + }); + const src: Record = { + "/public/val/test.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: null, + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have alt-related errors + if (result) { + const errors = Object.values(result as object).flat(); + const hasAltError = errors.some( + (e: { message: string }) => + e.message.includes("alt") || e.message.includes("string"), + ); + expect(hasAltError).toBe(false); + } + }); + + test("should use default directory /public/val", () => { + const schema = images({ accept: "image/webp" }); + const src: Record = { + "/public/val/test.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Test", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + + test("should validate hotspot if present", () => { + const schema = images({ accept: "image/webp" }); + const src = { + "/public/val/test.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Test", + hotspot: { x: "invalid", y: 0.5 }, + }, + }; + const result = schema["executeValidate"]( + "path" as SourcePath, + src as unknown as Record, + ); + expect(result).toBeTruthy(); + }); + }); + + describe("serialization", () => { + test("should serialize with correct type", () => { + const schema = images({ accept: "image/webp" }); + const serialized = schema["executeSerialize"](); + expect(serialized.type).toBe("record"); + expect((serialized as any).mediaType).toBe("images"); + expect(serialized.accept).toBe("image/webp"); + expect(serialized.directory).toBe("/public/val"); + expect(serialized.opt).toBe(false); + expect(serialized.remote).toBe(false); + }); + + test("should serialize with custom directory", () => { + const schema = images({ + accept: "image/png", + directory: "/public/val/custom", + }); + const serialized = schema["executeSerialize"](); + expect(serialized.directory).toBe("/public/val/custom"); + }); + + test("should serialize remote flag", () => { + const schema = images({ accept: "image/webp" }).remote(); + const serialized = schema["executeSerialize"](); + expect(serialized.remote).toBe(true); + }); + + test("should serialize nullable flag", () => { + const schema = images({ accept: "image/webp" }).nullable(); + const serialized = schema["executeSerialize"](); + expect(serialized.opt).toBe(true); + }); + }); + + describe("remote", () => { + test("should create remote variant", () => { + const schema = images({ accept: "image/webp" }); + const remoteSchema = schema.remote(); + expect(remoteSchema["executeSerialize"]().remote).toBe(true); + }); + + test("should reject remote URLs when remote is not enabled", () => { + const schema = images({ accept: "image/webp" }); + 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: "Remote image", + }, + }; + 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); + }); + + test("should accept remote URLs when remote is enabled", () => { + const schema = images({ accept: "image/webp" }).remote(); + 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: "Remote image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeFalsy(); + }); + + test("should accept local paths when remote is enabled", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val/images", + }).remote(); + const src: Record = { + "/public/val/images/local.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Local image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have path errors + if (result) { + const errors = Object.values(result as object).flat(); + const hasPathError = errors.some( + (e: { message: string }) => + e.message.includes("directory") || e.message.includes("Remote"), + ); + expect(hasPathError).toBe(false); + } + }); + + test("should accept mixed remote and local when remote is enabled", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val/images", + }).remote(); + const src: Record = { + "/public/val/images/local.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Local image", + }, + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/images/remote.webp": + { + width: 1920, + height: 1080, + mimeType: "image/webp", + alt: "Remote image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeFalsy(); + }); + + test("should reject invalid remote URLs", () => { + const schema = images({ accept: "image/webp" }).remote(); + const src: Record = { + "not-a-valid-url": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Invalid URL", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasUrlError = errors.some((e: { message: string }) => + e.message.includes("Expected a remote URL"), + ); + expect(hasUrlError).toBe(true); + }); + + test("should reject paths outside directory when remote is enabled but path is not a URL", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val/images", + }).remote(); + const src: Record = { + "/public/other/image.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Wrong directory", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasError = errors.some((e: { message: string }) => + e.message.includes("Expected a remote URL"), + ); + expect(hasError).toBe(true); + }); + + test("should accept http URLs when remote is enabled", () => { + const schema = images({ accept: "image/webp" }).remote(); + const src: Record = { + "http://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: "HTTP image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeFalsy(); + }); + + test("should reject non-Val remote URLs", () => { + const schema = images({ accept: "image/webp" }).remote(); + const src: Record = { + "https://example.com/image.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "External image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasInvalidFormatError = errors.some((e: { message: string }) => + e.message.includes("Invalid remote URL format"), + ); + expect(hasInvalidFormatError).toBe(true); + }); + + test("should reject remote URLs with wrong directory in path", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val/images", + }).remote(); + const src: Record = { + // Remote URL with public/val/other instead of public/val/images + "https://remote.val.build/file/p/proj123/b/01/v/1.0.0/h/abc123/f/def456/p/public/val/other/image.webp": + { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Wrong directory in remote", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasDirectoryError = errors.some((e: { message: string }) => + e.message.includes("not in expected directory"), + ); + expect(hasDirectoryError).toBe(true); + }); + }); + + describe("directory validation", () => { + test("should reject paths with wrong prefix", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val/images", + }); + const src: Record = { + "/wrong/path/image.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Wrong path", + }, + }; + 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("directory"), + ); + expect(hasDirError).toBe(true); + }); + + test("should accept paths with exact directory match", () => { + const schema = images({ + accept: "image/webp", + directory: "/public", + }); + const src: Record = { + "/public/image.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Public root image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + + test("should accept paths in subdirectories", () => { + const schema = images({ + accept: "image/webp", + directory: "/public/val", + }); + const src: Record = { + "/public/val/nested/deep/image.webp": { + width: 800, + height: 600, + mimeType: "image/webp", + alt: "Nested image", + }, + }; + const result = schema["executeValidate"]("path" as SourcePath, src); + // Should not have directory error + if (result) { + const errors = Object.values(result as object).flat(); + const hasDirError = errors.some((e: { message: string }) => + e.message.includes("directory"), + ); + expect(hasDirError).toBe(false); + } + }); + }); + + describe("custom validation", () => { + test("should support custom validation function", () => { + const schema = images({ accept: "image/webp" }).validate((src) => { + if (Object.keys(src ?? {}).length === 0) { + return "At least one image is required"; + } + return false; + }); + const src: Record = {}; + const result = schema["executeValidate"]("path" as SourcePath, src); + expect(result).toBeTruthy(); + const errors = Object.values(result as object).flat(); + const hasCustomError = errors.some((e: { message: string }) => + e.message.includes("At least one image is required"), + ); + expect(hasCustomError).toBe(true); + }); + }); +}); diff --git a/packages/core/src/schema/images.ts b/packages/core/src/schema/images.ts new file mode 100644 index 000000000..38b308002 --- /dev/null +++ b/packages/core/src/schema/images.ts @@ -0,0 +1,119 @@ +import { Schema } from "."; +import type { SerializedRecordSchema } from "./record"; +import { RecordSchema } from "./record"; +import { ObjectSchema } from "./object"; +import { StringSchema, string } from "./string"; +import { NumberSchema } from "./number"; + +/** + * Alt schema type - can be a string, nullable string, or a record of locale to string + */ +export type AltSchema = + | StringSchema + | StringSchema + | RecordSchema, Schema, Record>; + +/** + * Options for s.images() + */ +export type ImagesOptions = { + /** + * The accepted mime type pattern. Must be an image type (e.g., "image/png", "image/webp", "image/*") + */ + accept: Accept; + /** + * The directory where images should be stored. + * Must start with "/public" (e.g., "/public/val/images") + * @default "/public/val" + */ + directory?: "/public" | `/public/${string}`; + /** + * Alt text schema. Can be: + * - s.string() for required alt text + * - s.string().nullable() for optional alt text (default) + * - s.record(s.string(), s.string()) for locale-based alt text + */ + alt?: AltSchema; + /** + * Whether remote images are allowed + * @default false + */ + remote?: boolean; +}; + +/** + * Metadata for an image entry in the images record + */ +export type ImagesEntryMetadata = { + width: number; + height: number; + mimeType: string; + alt: string | null; + hotspot?: { + x: number; + y: number; + }; +}; + +export type SerializedImagesSchema = SerializedRecordSchema; + +// Item schema types for images (alt simplified to string | null for typing) +type ImagesItemProps = { + width: NumberSchema; + height: NumberSchema; + mimeType: StringSchema; + alt: StringSchema; +}; +type ImagesItemSrc = { + width: number; + height: number; + mimeType: string; + alt: string | null; +}; + +/** + * Define a collection of images. + * + * @example + * ```typescript + * const schema = s.images({ + * accept: "image/webp", + * directory: "/public/val/images", + * alt: s.string().minLength(4), + * }); + * export default c.define("/content/images.val.ts", schema, { + * "/public/val/images/hero.webp": { + * width: 1920, + * height: 1080, + * mimeType: "image/webp", + * alt: "Hero image", + * }, + * }); + * ``` + */ +export const images = ( + options: ImagesOptions, +): RecordSchema< + ObjectSchema, + Schema, + Record +> => { + const directory = options.directory ?? "/public/val"; + const altSchema = options.alt ?? string().nullable(); + const itemSchema = new ObjectSchema( + { + width: new NumberSchema(undefined, false), + height: new NumberSchema(undefined, false), + mimeType: new StringSchema({}, false), + alt: altSchema, + }, + false, + ) as ObjectSchema; + return new RecordSchema(itemSchema, false, [], null, null, { + type: "images", + accept: options.accept, + directory, + remote: options.remote ?? false, + altSchema, + }); +}; diff --git a/packages/core/src/schema/record.ts b/packages/core/src/schema/record.ts index 884fb24c6..f3c50f96f 100644 --- a/packages/core/src/schema/record.ts +++ b/packages/core/src/schema/record.ts @@ -21,6 +21,15 @@ import { ValidationError, ValidationErrors, } from "./validation/ValidationError"; +import { splitRemoteRef } from "../remote/splitRemoteRef"; + +type MediaOptions = { + type: "files" | "images"; + accept: string; + directory: string; + remote: boolean; + altSchema?: Schema; +}; export type SerializedRecordSchema = { type: "record"; @@ -29,6 +38,12 @@ export type SerializedRecordSchema = { opt: boolean; router?: string; customValidate?: boolean; + // Optional media collection marker for files/images that are backed by a record + mediaType?: "files" | "images"; + accept?: string; + directory?: string; + remote?: boolean; + alt?: SerializedSchema; }; export class RecordSchema< @@ -42,6 +57,7 @@ export class RecordSchema< private readonly customValidateFunctions: CustomValidateFunction[] = [], private readonly currentRouter: ValRouter | null = null, private readonly keySchema: Schema | null = null, + private readonly mediaOptions?: MediaOptions, ) { super(); } @@ -55,6 +71,7 @@ export class RecordSchema< [...this.customValidateFunctions, validationFunction], this.currentRouter, this.keySchema, + this.mediaOptions, ); } @@ -144,6 +161,16 @@ export class RecordSchema< } at key ${elem}`, // Should! never happen src, ); + } 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; + } + const entryErr = this.validateMediaEntry(subPath, elem); + if (entryErr) { + error = error ? { ...error, ...entryErr } : entryErr; + } } else { const subError = this.item["executeValidate"]( subPath, @@ -162,6 +189,208 @@ export class RecordSchema< return error; } + private isRemoteUrl(url: string): boolean { + return url.startsWith("https://") || url.startsWith("http://"); + } + + private validateMediaKey(path: SourcePath, key: string): ValidationErrors { + if (!this.mediaOptions) { + return false; + } + const { directory, remote: isRemote, type } = this.mediaOptions; + const mediaLabel = type === "images" ? "images" : "files"; + const checkRemoteFix = + type === "images" ? "images:check-remote" : "files:check-remote"; + + const isRemoteUrl = this.isRemoteUrl(key); + const isLocalPath = key.startsWith(directory); + + if (isRemote) { + // When remote is enabled, accept either remote URLs or local paths + if (isRemoteUrl) { + // Validate remote URL format using splitRemoteRef + const remoteResult = splitRemoteRef(key); + if (remoteResult.status === "error") { + return { + [path]: [ + { + message: `Invalid remote URL format. Use Val tooling (CLI, VS Code extension, or Val Studio) to upload ${mediaLabel}. Got: ${key}`, + value: key, + fixes: [checkRemoteFix], + }, + ], + }; + } + // Check that the file path in the remote URL matches our directory constraint + const remotePath = "/" + remoteResult.filePath; + if (!remotePath.startsWith(directory)) { + return { + [path]: [ + { + message: `Remote file path '${remotePath}' is not in expected directory '${directory}'. Use Val tooling to upload ${mediaLabel} to the correct directory.`, + value: key, + fixes: [checkRemoteFix], + }, + ], + }; + } + return false; + } + if (!isLocalPath) { + return { + [path]: [ + { + message: `Expected a remote URL (https://...) or a local path starting with ${directory}/. Got: ${key}`, + value: key, + }, + ], + }; + } + } else { + // When remote is disabled, only accept local paths + if (isRemoteUrl) { + return { + [path]: [ + { + message: `Remote URLs are not allowed. Use .remote() to enable remote ${mediaLabel}. Got: ${key}`, + value: key, + fixes: [checkRemoteFix], + }, + ], + }; + } + if (!isLocalPath) { + return { + [path]: [ + { + message: `File path must be within the ${directory}/ directory. Got: ${key}`, + value: key, + }, + ], + }; + } + } + + return false; + } + + private validateMediaEntry( + path: SourcePath, + entry: unknown, + ): ValidationErrors { + if (!this.mediaOptions) { + return false; + } + const { type, accept, altSchema } = this.mediaOptions; + + if (typeof entry !== "object" || entry === null) { + return { + [path]: [ + { message: `Expected 'object', got '${typeof entry}'`, value: entry }, + ], + }; + } + + const entryObj = entry as Record; + const errors: ValidationError[] = []; + + if (type === "images") { + // Validate width + if (typeof entryObj.width !== "number" || entryObj.width <= 0) { + errors.push({ + message: `Expected 'width' to be a positive number, got '${entryObj.width}'`, + value: entry, + }); + } + + // Validate height + if (typeof entryObj.height !== "number" || entryObj.height <= 0) { + errors.push({ + message: `Expected 'height' to be a positive number, got '${entryObj.height}'`, + value: entry, + }); + } + } + + // Validate mimeType + if (typeof entryObj.mimeType !== "string") { + errors.push({ + message: `Expected 'mimeType' to be a string, got '${typeof entryObj.mimeType}'`, + value: entry, + }); + } else { + const mimeTypeError = this.validateMediaMimeType(entryObj.mimeType, accept); + if (mimeTypeError) { + errors.push({ message: mimeTypeError, value: entry }); + } + } + + if (type === "images") { + // Validate hotspot if present + if (entryObj.hotspot !== undefined) { + const hs = entryObj.hotspot as Record; + if ( + typeof entryObj.hotspot !== "object" || + typeof hs.x !== "number" || + typeof hs.y !== "number" + ) { + errors.push({ + message: `Hotspot must be an object with x and y as numbers.`, + value: entry, + }); + } + } + + // Validate alt using the alt schema + const altPath = createValPathOfItem(path, "alt"); + if (altPath && altSchema) { + const altError = altSchema["executeValidate"]( + altPath, + entryObj.alt as SelectorSource, + ); + if (altError) { + return errors.length > 0 + ? { ...altError, [path]: errors } + : altError; + } + } + } + + if (errors.length > 0) { + return { [path]: errors }; + } + + return false; + } + + private validateMediaMimeType(mimeType: string, accept: string): string | null { + if (!mimeType.includes("/")) { + return `Invalid mime type format. Got: '${mimeType}'`; + } + + const acceptedTypes = accept.split(",").map((type) => type.trim()); + + const isValidMimeType = acceptedTypes.some((acceptedType) => { + if (acceptedType === "*/*") { + return true; + } + if (acceptedType === "image/*") { + return mimeType.startsWith("image/"); + } + if (acceptedType.endsWith("/*")) { + const baseType = acceptedType.slice(0, -2); + return mimeType.startsWith(baseType); + } + return acceptedType === mimeType; + }); + + if (!isValidMimeType) { + return `Mime type mismatch. Found '${mimeType}' but schema accepts '${accept}'`; + } + + return null; + } + protected executeAssert( path: SourcePath, src: unknown, @@ -172,6 +401,16 @@ export class RecordSchema< data: src, } as SchemaAssertResult; } + if (src === null) { + return { + success: false, + errors: { + [path]: [ + { message: `Expected 'object', got 'null'`, typeError: true }, + ], + }, + }; + } if (typeof src !== "object") { return { success: false, @@ -208,6 +447,7 @@ export class RecordSchema< this.customValidateFunctions, this.currentRouter, this.keySchema, + this.mediaOptions, ) as RecordSchema; } @@ -218,6 +458,18 @@ export class RecordSchema< this.customValidateFunctions, router, this.keySchema, + this.mediaOptions, + ); + } + + remote(): RecordSchema { + return new RecordSchema( + this.item, + this.opt, + this.customValidateFunctions, + this.currentRouter, + this.keySchema, + this.mediaOptions ? { ...this.mediaOptions, remote: true } : undefined, ); } @@ -284,7 +536,7 @@ export class RecordSchema< } protected executeSerialize(): SerializedRecordSchema { - return { + const result: SerializedRecordSchema = { type: "record", item: this.item["executeSerialize"](), key: this.keySchema?.["executeSerialize"](), @@ -294,6 +546,16 @@ export class RecordSchema< this.customValidateFunctions && this.customValidateFunctions?.length > 0, }; + if (this.mediaOptions) { + result.mediaType = this.mediaOptions.type; + result.accept = this.mediaOptions.accept; + result.directory = this.mediaOptions.directory; + result.remote = this.mediaOptions.remote; + if (this.mediaOptions.altSchema) { + result.alt = this.mediaOptions.altSchema["executeSerialize"](); + } + } + return result; } private renderInput: { diff --git a/packages/core/src/schema/richtext.ts b/packages/core/src/schema/richtext.ts index fd28085a1..b8273652c 100644 --- a/packages/core/src/schema/richtext.ts +++ b/packages/core/src/schema/richtext.ts @@ -8,13 +8,14 @@ import { import { ReifiedRender } from "../render"; import { unsafeCreateSourcePath } from "../selector/SelectorProxy"; import { ImageSource } from "../source/image"; +import { RemoteSource } from "../source/remote"; import { RichTextSource, RichTextOptions, SerializedRichTextOptions, } from "../source/richtext"; import { SourcePath } from "../val"; -import { ImageSchema, SerializedImageSchema } from "./image"; +import { ImageMetadata, ImageSchema, SerializedImageSchema } from "./image"; import { RouteSchema, SerializedRouteSchema } from "./route"; import { SerializedStringSchema, StringSchema } from "./string"; import { @@ -298,11 +299,18 @@ export class RichTextSchema< }; } const srcPath = unsafeCreateSourcePath(path, "src"); + const imgSchema = this.options.inline?.img; const imageValidationErrors = - typeof this.options.inline?.img === "object" - ? this.options.inline?.img["executeValidate"]( + typeof imgSchema === "object" + ? ( + imgSchema as ImageSchema< + ImageSource | RemoteSource + > + )["executeValidate"]( srcPath, - node.src as ImageSource, + node.src as + | ImageSource + | RemoteSource, ) : new ImageSchema({}, false, false)["executeValidate"]( srcPath, diff --git a/packages/core/src/schema/string.ts b/packages/core/src/schema/string.ts index 737020edf..05fce0665 100644 --- a/packages/core/src/schema/string.ts +++ b/packages/core/src/schema/string.ts @@ -115,6 +115,16 @@ export class StringSchema extends Schema { if (this.opt && (src === null || src === undefined)) { return errors.length > 0 ? { [path]: errors } : false; } + if (!this.opt && src === null) { + return { + [path]: [ + { + message: `Expected a non-empty value`, + value: src, + }, + ], + }; + } if (typeof src !== "string") { return { [path]: [ diff --git a/packages/core/src/schema/validation/ValidationFix.ts b/packages/core/src/schema/validation/ValidationFix.ts index 8040b1948..2aeff31e1 100644 --- a/packages/core/src/schema/validation/ValidationFix.ts +++ b/packages/core/src/schema/validation/ValidationFix.ts @@ -4,11 +4,13 @@ export const ValidationFix = [ "image:upload-remote", "image:download-remote", "image:check-remote", + "images:check-remote", "file:add-metadata", "file:check-metadata", "file:upload-remote", "file:download-remote", "file:check-remote", + "files:check-remote", "keyof:check-keys", "router:check-route", ] as const; diff --git a/packages/core/src/selector/index.ts b/packages/core/src/selector/index.ts index 3fe3bbd80..fc3e676ae 100644 --- a/packages/core/src/selector/index.ts +++ b/packages/core/src/selector/index.ts @@ -45,6 +45,7 @@ export type SelectorSource = | { [key: string]: SelectorSource; } + | ImageSource | FileSource | RemoteSource | RichTextSource diff --git a/packages/core/src/source/file.ts b/packages/core/src/source/file.ts index 98f41707c..e8e00e632 100644 --- a/packages/core/src/source/file.ts +++ b/packages/core/src/source/file.ts @@ -5,7 +5,9 @@ import { Json } from "../Json"; export const FILE_REF_PROP = "_ref" as const; export const FILE_REF_SUBTYPE_TAG = "_tag" as const; // TODO: used earlier by c.rt.image, when we remove c.rt we can remove this -export type FileMetadata = { mimeType?: string }; +export type FileMetadata = { + mimeType?: string; +}; /** * A file source represents the path to a (local) file. diff --git a/packages/core/src/source/index.ts b/packages/core/src/source/index.ts index a5432a125..47de4448a 100644 --- a/packages/core/src/source/index.ts +++ b/packages/core/src/source/index.ts @@ -1,4 +1,5 @@ import { FileSource } from "./file"; +import { ImageSource } from "./image"; import { RemoteSource } from "./remote"; import { RichTextOptions, RichTextSource } from "./richtext"; @@ -8,6 +9,7 @@ export type Source = | SourceArray | RemoteSource | FileSource + | ImageSource | RichTextSource; export type SourceObject = { [key in string]: Source } & { diff --git a/packages/core/src/source/remote.ts b/packages/core/src/source/remote.ts index 602874b93..56b25130e 100644 --- a/packages/core/src/source/remote.ts +++ b/packages/core/src/source/remote.ts @@ -19,7 +19,7 @@ export type RemoteSource< export const initRemote = (config?: ValConfig) => { function remote( ref: RemoteRef, - metadata: Metadata, + metadata?: Metadata, ): RemoteSource { return { [FILE_REF_PROP]: ref, diff --git a/packages/core/src/source/richtext.ts b/packages/core/src/source/richtext.ts index d9dc2a659..efdbf1529 100644 --- a/packages/core/src/source/richtext.ts +++ b/packages/core/src/source/richtext.ts @@ -29,7 +29,11 @@ export type RichTextOptions = Partial<{ }>; inline: Partial<{ a: boolean | RouteSchema | StringSchema; - img: boolean | ImageSchema>; + img: + | boolean + | ImageSchema + | ImageSchema> + | ImageSchema>; // custom: Record>; }>; }>; @@ -142,7 +146,7 @@ export type ImageNode = NonNullable< >["img"] extends true ? { tag: "img"; src: ImageSource | RemoteSource } : NonNullable["img"] extends ImageSchema - ? Src extends RemoteSource | FileSource + ? Src extends RemoteSource | FileSource | ImageSource ? { tag: "img"; src: Src } : never : never; diff --git a/packages/react/src/stega/stegaEncode.ts b/packages/react/src/stega/stegaEncode.ts index 3df9dd2d5..97854d7f8 100644 --- a/packages/react/src/stega/stegaEncode.ts +++ b/packages/react/src/stega/stegaEncode.ts @@ -10,6 +10,8 @@ import { SerializedObjectSchema, SerializedUnionSchema, SerializedLiteralSchema, + SerializedFileSchema, + SerializedImageSchema, ImageMetadata, FileMetadata, RichTextOptions, @@ -417,14 +419,30 @@ export function stegaEncode( sourceOrSelector[VAL_EXTENSION] === "file" && typeof sourceOrSelector[FILE_REF_PROP] === "string" ) { - const fileSelector = Internal.convertFileSource(sourceOrSelector); + const source = + recOpts?.schema && opts.getModule + ? augmentWithReferencedModuleMetadata( + sourceOrSelector, + recOpts.schema, + opts.getModule, + ) + : sourceOrSelector; + const fileSelector = Internal.convertFileSource(source); const url = fileSelector.url; return { ...fileSelector, url: rec(url, recOpts), }; } else if (sourceOrSelector[VAL_EXTENSION] === "remote") { - const remoteSelector = Internal.convertFileSource(sourceOrSelector); + const source = + recOpts?.schema && opts.getModule + ? augmentWithReferencedModuleMetadata( + sourceOrSelector, + recOpts.schema, + opts.getModule, + ) + : sourceOrSelector; + const remoteSelector = Internal.convertFileSource(source); const url = remoteSelector.url; return { ...remoteSelector, @@ -556,6 +574,75 @@ function isObjectSchema( return schema?.type === "object"; } +function isFileSchema( + schema: SerializedSchema | undefined, +): schema is SerializedFileSchema { + return schema?.type === "file"; +} + +function isImageSchema( + schema: SerializedSchema | undefined, +): schema is SerializedImageSchema { + return schema?.type === "image"; +} + +function augmentWithReferencedModuleMetadata( + source: any, + schema: SerializedSchema, + getModule: (path: string) => any, +): any { + if ( + source.metadata || + !(isFileSchema(schema) || isImageSchema(schema)) || + !schema.referencedModule + ) { + return source; + } + const moduleSource = getModule(schema.referencedModule); + if ( + !moduleSource || + typeof moduleSource !== "object" || + Array.isArray(moduleSource) + ) { + return source; + } + const ref: string = source[FILE_REF_PROP]; + // For local files the ref is the file path directly; for remote refs we + // need to extract the file path from the remote URL. + let fileRef: string | null = ref in moduleSource ? ref : null; + if (!fileRef) { + const splitResult = Internal.remote.splitRemoteRef(ref); + if (splitResult.status === "success" && splitResult.filePath in moduleSource) { + fileRef = splitResult.filePath; + } + } + if (!fileRef) { + return source; + } + return { ...source, metadata: moduleSource[fileRef] }; +} + +function collectReferencedModulesFromSchema( + schema: SerializedSchema, + acc: Set, +): void { + if (isFileSchema(schema) || isImageSchema(schema)) { + if (schema.referencedModule) { + acc.add(schema.referencedModule); + } + } else if (schema.type === "object") { + for (const v of Object.values(schema.items)) { + collectReferencedModulesFromSchema(v, acc); + } + } else if (schema.type === "array" || schema.type === "record") { + collectReferencedModulesFromSchema(schema.item, acc); + } else if (schema.type === "union") { + for (const item of schema.items) { + collectReferencedModulesFromSchema(item, acc); + } + } +} + export function stegaClean(source: string) { return vercelStegaSplit(source).cleaned; } @@ -570,6 +657,13 @@ export function getModuleIds(input: any): string[] { const selectorPath = Internal.getValPath(sourceOrSelector); if (selectorPath) { modules.add(selectorPath); + const schema = Internal.getSchema(sourceOrSelector); + if (schema) { + const serialized = schema["executeSerialize"](); + if (serialized) { + collectReferencedModulesFromSchema(serialized, modules); + } + } return; } diff --git a/packages/server/src/Service.ts b/packages/server/src/Service.ts index 4edfa1347..9b2c677cd 100644 --- a/packages/server/src/Service.ts +++ b/packages/server/src/Service.ts @@ -42,6 +42,13 @@ export async function createService( ); }, rmFile: fs.rmSync, + readBuffer: (fileName) => { + try { + return fs.readFileSync(fileName); + } catch { + return undefined; + } + }, }, loader?: ValModuleLoader, ): Promise { diff --git a/packages/server/src/ValFS.ts b/packages/server/src/ValFS.ts index c739ae1dc..7a9c25dd9 100644 --- a/packages/server/src/ValFS.ts +++ b/packages/server/src/ValFS.ts @@ -22,6 +22,8 @@ export interface ValFS { readFile(filePath: string): string | undefined; + readBuffer(filePath: string): Buffer | undefined; + rmFile(filePath: string): void; realpath(path: string): string; diff --git a/packages/server/src/ValFSHost.ts b/packages/server/src/ValFSHost.ts index 4624f63f8..06628f24b 100644 --- a/packages/server/src/ValFSHost.ts +++ b/packages/server/src/ValFSHost.ts @@ -17,6 +17,7 @@ export interface IValFSHost encoding: "binary" | "utf8", ): void; rmFile(fileName: string): void; + readBuffer(fileName: string): Buffer | undefined; } export class ValFSHost implements IValFSHost { @@ -73,6 +74,10 @@ export class ValFSHost implements IValFSHost { return this.valFS.readFile(fileName); } + readBuffer(fileName: string): Buffer | undefined { + return this.valFS.readBuffer(fileName); + } + realpath(path: string): string { return this.valFS.realpath(path); } diff --git a/packages/server/src/ValModuleLoader.ts b/packages/server/src/ValModuleLoader.ts index 16a44d078..7d7ff6f04 100644 --- a/packages/server/src/ValModuleLoader.ts +++ b/packages/server/src/ValModuleLoader.ts @@ -27,6 +27,13 @@ export const createModuleLoader = ( ); }, rmFile: fs.rmSync, + readBuffer: (fileName) => { + try { + return fs.readFileSync(fileName); + } catch { + return undefined; + } + }, }, ): ValModuleLoader => { const compilerOptions = getCompilerOptions(rootDir, host); @@ -66,6 +73,13 @@ export class ValModuleLoader { ); }, rmFile: fs.rmSync, + readBuffer: (fileName) => { + try { + return fs.readFileSync(fileName); + } catch { + return undefined; + } + }, }, private readonly disableCache: boolean = false, ) { diff --git a/packages/server/src/ValOps.ts b/packages/server/src/ValOps.ts index 04635c911..5bdd9e84c 100644 --- a/packages/server/src/ValOps.ts +++ b/packages/server/src/ValOps.ts @@ -403,6 +403,7 @@ export abstract class ValOps { { patchId: PatchId; remote: boolean; + isDelete: boolean; } > = {}; for (const patch of sortedPatches) { @@ -415,7 +416,9 @@ export abstract class ValOps { fileLastUpdatedByPatchId[filePath] = { patchId: patch.patchId, remote: op.remote, + isDelete: op.value === null, }; + continue; } const path = patch.path; if (!patchesByModule[path]) { @@ -508,19 +511,23 @@ export abstract class ValOps { const fileFixOps: Record = {}; for (const op of patchData.patch) { if (op.op === "file") { - // NOTE: We insert the last patch_id that modify a file - // when constructing the url we use the patch id (and the file path) - // to fetch the right file - // NOTE: overwrite and use last patch_id if multiple patches modify the same file - fileFixOps[op.path.join("/")] = [ - { - op: "add", - path: op.path - .concat(...(op.nestedFilePath || [])) - .concat("patch_id"), - value: patchId, - }, - ]; + if (op.value !== null) { + // NOTE: We insert the last patch_id that modify a file + // when constructing the url we use the patch id (and the file path) + // to fetch the right file + // NOTE: overwrite and use last patch_id if multiple patches modify the same file + fileFixOps[op.path.join("/")] = [ + { + op: "add", + path: op.path + .concat(...(op.nestedFilePath || [])) + .concat("patch_id"), + value: patchId, + }, + ]; + } + // null value = delete: no patch_id to inject; the "remove" op in + // the patch already removes the metadata entry from the source } else { applicableOps.push(op); } @@ -1061,7 +1068,7 @@ export abstract class ValOps { patchAnalysis: PatchAnalysis & OrderedPatches, ): Promise { const { patchesByModule, fileLastUpdatedByPatchId } = patchAnalysis; - const patchedSourceFiles: Record = {}; + const patchedSourceFiles: Record = {}; const previousSourceFiles: Record = {}; const applySourceFilePatches = async ( @@ -1233,15 +1240,20 @@ export abstract class ValOps { await Promise.all( Object.entries(fileLastUpdatedByPatchId).map( async ([filePath, patchData]) => { - const { patchId, remote } = patchData; + const { patchId, remote, isDelete } = patchData; if (globalAppliedPatches.includes(patchId)) { - // TODO: do we want to make sure the file is there? Then again, it should be rare that it happens (unless there's a Val bug) so it might be enough to fail later (at commit) - // TODO: include sha256? This way we can make sure we pick the right file since theoretically there could be multiple files with the same path in the same patch - // or is that the case? We are picking the latest file by path so, that should be enough? - patchedBinaryFilesDescriptors[filePath] = { - patchId, - remote, - }; + if (isDelete) { + // Signal file deletion via patchedSourceFiles null entry + patchedSourceFiles[filePath] = null; + } else { + // TODO: do we want to make sure the file is there? Then again, it should be rare that it happens (unless there's a Val bug) so it might be enough to fail later (at commit) + // TODO: include sha256? This way we can make sure we pick the right file since theoretically there could be multiple files with the same path in the same patch + // or is that the case? We are picking the latest file by path so, that should be enough? + patchedBinaryFilesDescriptors[filePath] = { + patchId, + remote, + }; + } } else { hasErrors = true; binaryFilePatchErrors[filePath] = { @@ -1338,9 +1350,9 @@ export abstract class ValOps { filePath: string, parentRef: ParentRef, patchId: PatchId, - data: string, + data: string | null, type: "file" | "image", - metadata: MetadataOfType<"file" | "image">, + metadata: MetadataOfType<"file" | "image"> | undefined, ): Promise>; abstract getBase64EncodedBinaryFileFromPatch( filePath: string, @@ -1431,7 +1443,7 @@ export type PatchAnalysis = { }; fileLastUpdatedByPatchId: Record< string, - { patchId: PatchId; remote: boolean } + { patchId: PatchId; remote: boolean; isDelete: boolean } >; }; @@ -1467,9 +1479,10 @@ export type BinaryFileType = "file" | "image"; export type PreparedCommit = { /** - * Updated / new source files that are ready to be committed / saved + * Updated / new source files that are ready to be committed / saved. + * A null value signals that the file at that path should be deleted. */ - patchedSourceFiles: Record; + patchedSourceFiles: Record; /** * Previous source files that were patched */ diff --git a/packages/server/src/ValOpsFS.ts b/packages/server/src/ValOpsFS.ts index 98bd36b28..a6e430156 100644 --- a/packages/server/src/ValOpsFS.ts +++ b/packages/server/src/ValOpsFS.ts @@ -671,14 +671,19 @@ export class ValOpsFS extends ValOps { filePath: string, parentRef: ParentRef, patchId: PatchId, - data: string, + data: string | null, _type: BinaryFileType, - metadata: MetadataOfType, + metadata: MetadataOfType | undefined, ): Promise> { const patchDir = this.getParentPatchIdFromParentRef(parentRef); const patchFilePath = this.getBinaryFilePath(filePath, patchDir); const metadataFilePath = this.getBinaryFileMetadataPath(filePath, patchDir); try { + if (data === null) { + this.host.deleteFile(patchFilePath); + this.host.deleteFile(metadataFilePath); + return { patchId, filePath }; + } const buffer = bufferFromDataUrl(data); if (!buffer) { return { @@ -1007,7 +1012,11 @@ export class ValOpsFS extends ValOps { )) { const absPath = fsPath.join(this.rootDir, ...filePath.split("/")); try { - this.host.writeUf8File(absPath, data); + if (data === null) { + this.host.deleteFile(absPath); + } else { + this.host.writeUf8File(absPath, data); + } updatedFiles.push(absPath); } catch (err) { errors[absPath] = { @@ -1178,6 +1187,12 @@ class FSOpsHost { } } + deleteFile(path: string) { + if (this.fileExists(path)) { + fs.rmSync(path); + } + } + moveDir(from: string, to: string) { fs.renameSync(from, to); } diff --git a/packages/server/src/ValOpsHttp.ts b/packages/server/src/ValOpsHttp.ts index 05ddd07a3..a090e74c1 100644 --- a/packages/server/src/ValOpsHttp.ts +++ b/packages/server/src/ValOpsHttp.ts @@ -850,9 +850,9 @@ export class ValOpsHttp extends ValOps { filePathOrRef: string, parentRef: ParentRef, patchId: PatchId, - data: string, + data: string | null, type: BinaryFileType, - metadata: MetadataOfType, + metadata: MetadataOfType | undefined, ): Promise> { const filePath: string = filePathOrRef; diff --git a/packages/server/src/ValServer.ts b/packages/server/src/ValServer.ts index 660a942ec..2f4e76d6d 100644 --- a/packages/server/src/ValServer.ts +++ b/packages/server/src/ValServer.ts @@ -1590,11 +1590,13 @@ export const ValServer = ( patchIds, excludePatchOps: false, }); + console.log("Got patches for save", patches); const analysis = serverOps.analyzePatches( patches.patches, patches.commits, commit, ); + console.log("Got analysis for save", analysis); const preparedCommit = await serverOps.prepare({ ...analysis, ...patches, diff --git a/packages/server/src/ValSourceFileHandler.ts b/packages/server/src/ValSourceFileHandler.ts index 9c096d69b..4dd68c0fe 100644 --- a/packages/server/src/ValSourceFileHandler.ts +++ b/packages/server/src/ValSourceFileHandler.ts @@ -18,6 +18,13 @@ export class ValSourceFileHandler { ); }, rmFile: fs.rmSync, + readBuffer: (fileName) => { + try { + return fs.readFileSync(fileName); + } catch { + return undefined; + } + }, }, ) {} diff --git a/packages/server/src/patch/ts/ops.ts b/packages/server/src/patch/ts/ops.ts index ad8afc879..2a8f90e67 100644 --- a/packages/server/src/patch/ts/ops.ts +++ b/packages/server/src/patch/ts/ops.ts @@ -656,6 +656,10 @@ function removeFromNode( result.map((index: number) => removeAt(document, node.elements, index)), ); } else if (ts.isObjectLiteralExpression(node)) { + console.log( + "ABOUT TO REMOVE FROM NODE", + printer.printNode(ts.EmitHint.Unspecified, node, document), + ); return pipe( findObjectPropertyAssignment(node, key), result.flatMap( @@ -665,7 +669,7 @@ function removeFromNode( if (!assignment) { return result.err( new PatchError( - "Cannot replace object element which does not exist", + "Cannot remove object element which does not exist", ), ); } diff --git a/packages/shared/src/internal/zod/SerializedSchema.ts b/packages/shared/src/internal/zod/SerializedSchema.ts index 7bcd8fd25..1850aa778 100644 --- a/packages/shared/src/internal/zod/SerializedSchema.ts +++ b/packages/shared/src/internal/zod/SerializedSchema.ts @@ -157,11 +157,25 @@ export const SerializedRichTextSchema: z.ZodType = export const SerializedRecordSchema: z.ZodType = z.lazy(() => { - return z.object({ - type: z.literal("record"), - item: SerializedSchema, - opt: z.boolean(), - }); + return z + .object({ + type: z.literal("record"), + item: SerializedSchema, + opt: z.boolean(), + // Optional gallery marker for files/images + mediaType: z + .union([z.literal("files"), z.literal("images")]) + .optional(), + // Optional legacy gallery metadata + accept: z.string().optional(), + directory: z.string().optional(), + remote: z.boolean().optional(), + alt: SerializedSchema.optional(), + moduleMetadata: z + .record(z.string(), z.record(z.string(), z.any())) + .optional(), + }) + .passthrough(); }); export const SerializedKeyOfSchema: z.ZodType = z.lazy( diff --git a/packages/ui/package.json b/packages/ui/package.json index d360e392b..8918350a2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -176,5 +176,8 @@ "files": [ "dist", "server" - ] + ], + "dependencies": { + "@tanstack/react-virtual": "^3.13.18" + } } diff --git a/packages/ui/public/sample-image-1.jpg b/packages/ui/public/sample-image-1.jpg new file mode 100644 index 000000000..f73cf9027 Binary files /dev/null and b/packages/ui/public/sample-image-1.jpg differ diff --git a/packages/ui/public/sample-image-2.jpg b/packages/ui/public/sample-image-2.jpg new file mode 100644 index 000000000..138c4c78d Binary files /dev/null and b/packages/ui/public/sample-image-2.jpg differ diff --git a/packages/ui/public/sample-image-3.jpg b/packages/ui/public/sample-image-3.jpg new file mode 100644 index 000000000..e36b50dbd Binary files /dev/null and b/packages/ui/public/sample-image-3.jpg differ diff --git a/packages/ui/public/sample-video.mp4 b/packages/ui/public/sample-video.mp4 new file mode 100644 index 000000000..0a4dd5b40 Binary files /dev/null and b/packages/ui/public/sample-video.mp4 differ diff --git a/packages/ui/spa/components/Field.tsx b/packages/ui/spa/components/Field.tsx index a25a7118c..e2c16bcb6 100644 --- a/packages/ui/spa/components/Field.tsx +++ b/packages/ui/spa/components/Field.tsx @@ -58,9 +58,9 @@ export function Field({ } }, [sourceAtPath, schemaAtPath]); const source = "data" in sourceAtPath ? sourceAtPath.data : undefined; - const isBoolean = - "data" in schemaAtPath && schemaAtPath.data?.type === "boolean"; - const isNullable = "data" in schemaAtPath && schemaAtPath.data?.opt === true; + const schema = "data" in schemaAtPath ? schemaAtPath.data : undefined; + const isBoolean = schema?.type === "boolean"; + const isNullable = schema?.opt === true; return (
- {!isBoolean && isNullable && ( + {schema && !isBoolean && (isNullable || source === null) && ( { if ( - (schemaAtPath.data.type === "image" || - schemaAtPath.data.type === "file") && + (schema.type === "image" || schema.type === "file") && source === null ) { setShowEmptyFileOrImage(true); @@ -89,12 +88,12 @@ export function Field({ op: "replace", path: patchPath, value: emptyOf({ - ...schemaAtPath.data, + ...schema, opt: false, // empty of nullable is null, so we override }) as JSONValue, }, ], - schemaAtPath.data.type, + schema.type, ); } else { addPatch( @@ -105,7 +104,7 @@ export function Field({ value: null, }, ], - schemaAtPath.data.type, + schema.type, ); } } diff --git a/packages/ui/spa/components/FileGallery/FileGallery.tsx b/packages/ui/spa/components/FileGallery/FileGallery.tsx new file mode 100644 index 000000000..05ea093bd --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FileGallery.tsx @@ -0,0 +1,293 @@ +import * as React from "react"; +import { + FolderOpen, + Grid, + LayoutGrid, + List, + Loader2, + Plus, + Search, +} from "lucide-react"; +import { cn } from "../designSystem/cn"; +import { Input } from "../designSystem/input"; +import { Skeleton } from "../designSystem/skeleton"; +import { FileGalleryItem } from "./FileGalleryItem"; +import { FileGalleryListView } from "./FileGalleryListView"; +import { FilePropertiesModal } from "./FilePropertiesModal"; +import { useValPortal } from "../ValPortalProvider"; +import type { + FileGalleryProps, + SortDirection, + SortField, + ViewMode, +} from "./types"; + +export function FileGallery({ + files, + parentPath, + onFileRename, + onAltTextChange, + onFileDelete, + className, + defaultViewMode = "list", + showSearch = true, + imageMode = false, + loading = false, + disabled = false, + onUploadClick, + uploading = false, +}: FileGalleryProps) { + const portalContainer = useValPortal(); + const [selectedIndex, setSelectedIndex] = React.useState(null); + const [isPropertiesOpen, setIsPropertiesOpen] = React.useState(false); + const [viewMode, setViewMode] = React.useState(defaultViewMode); + const [searchQuery, setSearchQuery] = React.useState(""); + const [sortField, setSortField] = React.useState("name"); + const [sortDirection, setSortDirection] = + React.useState("asc"); + + // Filter files based on search query + const filteredFiles = React.useMemo(() => { + let result = files; + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + result = result.filter( + (file) => + file.filename.toLowerCase().includes(query) || + (file.metadata.alt ?? "").toLowerCase().includes(query), + ); + } + + // Apply sorting for list view + if (viewMode === "list") { + result = [...result].sort((a, b) => { + let comparison = 0; + + switch (sortField) { + case "name": + comparison = a.filename.localeCompare(b.filename); + break; + case "description": + comparison = (a.metadata.alt ?? "").localeCompare( + b.metadata.alt ?? "", + ); + break; + case "type": + comparison = a.metadata.mimeType.localeCompare(b.metadata.mimeType); + break; + } + + return sortDirection === "asc" ? comparison : -comparison; + }); + } + + return result; + }, [files, searchQuery, viewMode, sortField, sortDirection]); + + // Map filtered index back to original index for selection + const getOriginalIndex = React.useCallback( + (filteredIndex: number) => { + const filteredFile = filteredFiles[filteredIndex]; + return files.findIndex( + (f) => + f.folder === filteredFile.folder && + f.filename === filteredFile.filename, + ); + }, + [files, filteredFiles], + ); + + const handleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); + } else { + setSortField(field); + setSortDirection("asc"); + } + }; + + const selectedFile = + selectedIndex !== null ? (files[selectedIndex] ?? null) : null; + + const handleItemClick = (filteredIndex: number) => { + const originalIndex = getOriginalIndex(filteredIndex); + setSelectedIndex(originalIndex); + setIsPropertiesOpen(true); + }; + + // Empty state content - shown inside the main layout to preserve toolbar + const emptyContent = ( +
+
+ +
+

No files

+

+ There are no files to display +

+
+
+
+ ); + + // Loading skeleton content + const loadingContent = ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+
+ +
+ +
+
+
+ ))} +
+ ); + + return ( +
+ {/* Toolbar */} +
+ {showSearch && ( +
+ + setSearchQuery(e.target.value)} + className="pl-8" + /> +
+ )} +
+ {onUploadClick && ( + + )} + {/* TODO: fix overflow in Masonry */} + {/* */} + + +
+
+ + {/* Gallery content */} +
+ {loading ? ( + loadingContent + ) : files.length === 0 ? ( + emptyContent + ) : filteredFiles.length === 0 ? ( +
+ +

+ No files match "{searchQuery}" +

+
+ ) : viewMode === "masonry" ? ( +
+ {filteredFiles.map((file, index) => ( + handleItemClick(index)} + viewMode={viewMode} + imageMode={imageMode} + /> + ))} +
+ ) : viewMode === "grid" ? ( +
+ {filteredFiles.map((file, index) => ( + handleItemClick(index)} + viewMode={viewMode} + imageMode={imageMode} + /> + ))} +
+ ) : ( + + )} +
+ + +
+ ); +} diff --git a/packages/ui/spa/components/FileGallery/FileGalleryItem.tsx b/packages/ui/spa/components/FileGallery/FileGalleryItem.tsx new file mode 100644 index 000000000..d1f7d3b82 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FileGalleryItem.tsx @@ -0,0 +1,80 @@ +import { cn } from "../designSystem/cn"; +import { FilePreview } from "./FilePreview"; +import type { GalleryFile, ViewMode } from "./types"; + +interface FileGalleryItemProps { + file: GalleryFile; + onClick: () => void; + viewMode: ViewMode; + imageMode?: boolean; +} + +export function FileGalleryItem({ + file, + onClick, + viewMode, + imageMode, +}: FileGalleryItemProps) { + // Calculate aspect ratio for masonry layout + const hasValidDimensions = + file.metadata.width > 0 && file.metadata.height > 0; + const aspectRatio = hasValidDimensions + ? file.metadata.width / file.metadata.height + : 1; + + const hasErrors = + (file.validationErrors && file.validationErrors.length > 0) || + (file.fieldSpecificErrors && + Object.values(file.fieldSpecificErrors).some( + (errs) => errs && errs.length > 0, + )); + + const buttonContent = ( + + ); + + if (viewMode === "masonry") { + return
{buttonContent}
; + } + + return buttonContent; +} diff --git a/packages/ui/spa/components/FileGallery/FileGalleryListView.tsx b/packages/ui/spa/components/FileGallery/FileGalleryListView.tsx new file mode 100644 index 000000000..251391d59 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FileGalleryListView.tsx @@ -0,0 +1,231 @@ +import * as React from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; +import { cn } from "../designSystem/cn"; +import { FilePreview } from "./FilePreview"; +import type { GalleryFile, SortDirection, SortField } from "./types"; + +interface FileGalleryListViewProps { + files: GalleryFile[]; + onItemClick: (index: number) => void; + sortField: SortField; + sortDirection: SortDirection; + onSort: (field: SortField) => void; +} + +const ROW_HEIGHT = 50; + +function SortIcon({ + field, + currentField, + direction, +}: { + field: SortField; + currentField: SortField; + direction: SortDirection; +}) { + if (field !== currentField) { + return ; + } + return direction === "asc" ? ( + + ) : ( + + ); +} + +function formatMimeType(mimeType: string): string { + // Show a simplified version, e.g., "JPEG" instead of "image/jpeg" + const [, subtype] = mimeType.split("/"); + return subtype?.toUpperCase() ?? mimeType; +} + +export function FileGalleryListView({ + files, + onItemClick, + sortField, + sortDirection, + onSort, +}: FileGalleryListViewProps) { + const parentRef = React.useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: files.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 10, + }); + + const showAltColumn = files.some((f) => f.metadata.alt !== undefined); + // Without alt: icon(56px) | name(1fr) | type(96px) + // With alt: icon(56px) | description(1fr) | name(192px) | type(96px) + const gridCols = showAltColumn + ? "grid-cols-[56px_1fr_192px_96px]" + : "grid-cols-[56px_1fr_96px]"; + + return ( +
+ {/* Header */} +
+
+
+ {showAltColumn ? ( +
+ +
+ ) : ( +
+ +
+ )} + {showAltColumn && ( +
+ +
+ )} +
+ +
+
+
+ + {/* Virtualized body */} +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const file = files[virtualRow.index]; + const hasErrors = + (file.validationErrors && file.validationErrors.length > 0) || + (file.fieldSpecificErrors && + Object.values(file.fieldSpecificErrors).some( + (errs) => errs && errs.length > 0, + )); + + const isFirstRow = virtualRow.index === 0; + const isLastRow = virtualRow.index === files.length - 1; + const showTopErrorBorder = + hasErrors && + (virtualRow.index === 0 || + !( + files[virtualRow.index - 1].validationErrors && + files[virtualRow.index - 1].validationErrors!.length > 0 + )); + return ( +
onItemClick(virtualRow.index)} + className={cn( + "absolute left-0 top-0 grid w-full cursor-pointer border border-b-0 transition-colors", + isFirstRow && "border-t-0", + isLastRow && "border-b", + gridCols, + "border-border-secondary hover:bg-bg-secondary", + hasErrors && + "border border-bg-error-primary hover:border-bg-error-primary-hover", + !showTopErrorBorder && hasErrors && "border-t-0", + isLastRow && "rounded-b-lg", + )} + style={{ + transform: `translateY(${virtualRow.start}px)`, + }} + title={ + hasErrors ? file.validationErrors?.join(", ") : undefined + } + > +
+
+ +
+
+ {showAltColumn ? ( + <> +
+ + {file.metadata.alt ?? ""} + +
+
+ + {file.filename} + +
+ + ) : ( +
+ + {file.filename} + +
+ )} +
+ + {formatMimeType(file.metadata.mimeType)} + +
+
+ ); + })} +
+
+
+ ); +} diff --git a/packages/ui/spa/components/FileGallery/FilePreview.tsx b/packages/ui/spa/components/FileGallery/FilePreview.tsx new file mode 100644 index 000000000..26423d15f --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FilePreview.tsx @@ -0,0 +1,80 @@ +import { File, FileAudio, FileText, FileVideo } from "lucide-react"; +import { cn } from "../designSystem/cn"; +import type { GalleryFile } from "./types"; + +interface FilePreviewProps { + file: GalleryFile; + className?: string; +} + +function getMimeCategory(mimeType: string): string { + const [category] = mimeType.split("/"); + return category; +} + +export function FilePreview({ file, className }: FilePreviewProps) { + const category = getMimeCategory(file.metadata.mimeType); + + if (category === "image") { + return ( + {file.metadata.alt + ); + } + + if (category === "video") { + return ( +
+
+ ); + } + + if (category === "audio") { + return ( +
+ +
+ ); + } + + if (category === "text" || file.metadata.mimeType === "application/json") { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/packages/ui/spa/components/FileGallery/FilePreviewModal.tsx b/packages/ui/spa/components/FileGallery/FilePreviewModal.tsx new file mode 100644 index 000000000..d9a847c66 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FilePreviewModal.tsx @@ -0,0 +1,83 @@ +import { Dialog, DialogContent, DialogTitle } from "../designSystem/dialog"; +import type { GalleryFile } from "./types"; + +interface FilePreviewModalProps { + file: GalleryFile | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function getMimeCategory(mimeType: string): string { + const [category] = mimeType.split("/"); + return category; +} + +export function FilePreviewModal({ + file, + open, + onOpenChange, +}: FilePreviewModalProps) { + if (!file) return null; + + const category = getMimeCategory(file.metadata.mimeType); + + return ( + + + {file.filename} +
+ {category === "image" && ( + {file.filename} + )} + {category === "video" && ( + + )} + {category === "audio" && ( +
+

{file.filename}

+ +
+ )} + {category !== "image" && + category !== "video" && + category !== "audio" && ( +
+

+ {file.filename} +

+

+ Preview not available for this file type +

+
+ )} +
+
+

{file.filename}

+

+ {file.folder} • {file.metadata.mimeType} + {file.metadata.width > 0 && + ` • ${file.metadata.width}×${file.metadata.height}px`} +

+
+
+
+ ); +} diff --git a/packages/ui/spa/components/FileGallery/FileProperties.tsx b/packages/ui/spa/components/FileGallery/FileProperties.tsx new file mode 100644 index 000000000..dd65c04a9 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FileProperties.tsx @@ -0,0 +1,191 @@ +import * as React from "react"; +import { cn } from "../designSystem/cn"; +import { Input } from "../designSystem/input"; +import type { GalleryFile } from "./types"; + +interface FilePropertiesProps { + file: GalleryFile; + fileIndex: number; + onFileRename?: (index: number, newFilename: string) => void; + onAltTextChange?: (index: number, newAltText: string) => void; + imageMode?: boolean; + className?: string; +} + +export function FileProperties({ + file, + fileIndex, + onFileRename, + onAltTextChange, + imageMode, + className, +}: FilePropertiesProps) { + const [isEditingFilename, setIsEditingFilename] = React.useState(false); + const [editedFilename, setEditedFilename] = React.useState(file.filename); + const [isEditingAlt, setIsEditingAlt] = React.useState(false); + const [editedAlt, setEditedAlt] = React.useState(file.metadata.alt ?? ""); + + React.useEffect(() => { + setEditedFilename(file.filename); + setIsEditingFilename(false); + setEditedAlt(file.metadata.alt ?? ""); + setIsEditingAlt(false); + }, [file.filename, file.metadata.alt]); + + const handleSaveFilename = () => { + if (editedFilename.trim() && editedFilename !== file.filename) { + onFileRename?.(fileIndex, editedFilename.trim()); + } + setIsEditingFilename(false); + }; + + const handleSaveAlt = () => { + if (editedAlt !== (file.metadata.alt ?? "")) { + onAltTextChange?.(fileIndex, editedAlt); + } + setIsEditingAlt(false); + }; + + const handleFilenameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSaveFilename(); + } else if (e.key === "Escape") { + setEditedFilename(file.filename); + setIsEditingFilename(false); + } + }; + + const handleAltKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSaveAlt(); + } else if (e.key === "Escape") { + setEditedAlt(file.metadata.alt ?? ""); + setIsEditingAlt(false); + } + }; + + const isImage = file.metadata.mimeType.startsWith("image/"); + + // Format date for display + const formattedDate = file.createdAt + ? new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(file.createdAt) + : null; + + return ( +
+

Properties

+ +
+
+ + {isEditingFilename ? ( + setEditedFilename(e.target.value)} + onBlur={handleSaveFilename} + onKeyDown={handleFilenameKeyDown} + autoFocus + className="h-8 text-sm" + /> + ) : ( + + )} +
+ + {imageMode && isImage && ( +
+ + {isEditingAlt ? ( + setEditedAlt(e.target.value)} + onBlur={handleSaveAlt} + onKeyDown={handleAltKeyDown} + autoFocus + placeholder="Describe this image..." + className="h-8 text-sm" + /> + ) : ( + + )} +
+ )} + +
+ +

+ {file.folder} +

+
+ +
+ +

{file.metadata.mimeType}

+
+ + {(file.metadata.width > 0 || file.metadata.height > 0) && ( +
+ +

+ {file.metadata.width} × {file.metadata.height} px +

+
+ )} + + {formattedDate && ( +
+ +

{formattedDate}

+
+ )} +
+
+ ); +} diff --git a/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx b/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx new file mode 100644 index 000000000..ccdbf109b --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FilePropertiesModal.tsx @@ -0,0 +1,304 @@ +import * as React from "react"; +import { Check, ExternalLink, Link, Trash2 } from "lucide-react"; +import { Internal, ModuleFilePath } from "@valbuild/core"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "../designSystem/dialog"; +import { cn } from "../designSystem/cn"; +import { Input } from "../designSystem/input"; +import { FilePreview } from "./FilePreview"; +import { FilenameInput } from "./FilenameInput"; +import type { GalleryFile } from "./types"; +import { FieldValidationError } from "../FieldValidationError"; +import { useReferencedFiles } from "../useReferencedFiles"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../designSystem/tooltip"; +import { useNavigation } from "../ValRouter"; +import { ValPath } from "../ValPath"; +import { prettifyFilename } from "../../utils/prettifyFilename"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../designSystem/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "../designSystem/command"; + +interface FilePropertiesModalProps { + file: GalleryFile | null; + fileIndex: number | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onFileRename?: (index: number, newFilename: string) => void; + onAltTextChange?: (index: number, newAltText: string) => void; + onFileDelete?: (index: number) => void; + parentPath?: string; + imageMode?: boolean; + loading?: boolean; + disabled?: boolean; + container?: HTMLElement | null; +} + +export function FilePropertiesModal({ + file, + fileIndex, + open, + onOpenChange, + onFileRename, + onAltTextChange, + onFileDelete, + parentPath, + imageMode, + loading, + disabled, + container, +}: FilePropertiesModalProps) { + const refs = useReferencedFiles(parentPath as ModuleFilePath | undefined, file?.ref); + const { navigate, currentSourcePath } = useNavigation(); + const [refsOpen, setRefsOpen] = React.useState(false); + + if (!file || fileIndex === null) return null; + + const handleFilenameChange = (newFilename: string) => { + onFileRename?.(fileIndex, newFilename); + }; + + const handleOpenInNewTab = () => { + window.open(file.url, "_blank", "noopener,noreferrer"); + }; + + const isImage = file.metadata.mimeType.startsWith("image/"); + + return ( + + + + File Properties + + +
+ {/* Preview */} +
+
+ +
+
+ + {/* Properties */} +
+ {/* Filename */} + {onFileRename && ( +
+ + +
+ )} + + {/* Alt Text (only for images in imageMode) */} + {imageMode && isImage && onAltTextChange && ( +
0, + })} + > + + +
+ { + onAltTextChange?.(fileIndex, e.target.value); + }} + autoFocus + placeholder="Describe this image..." + /> + {file.fieldSpecificErrors?.alt && + file.fieldSpecificErrors.alt.length > 0 && ( +
    + {file.fieldSpecificErrors.alt.map((error, i) => ( +
  • + +
  • + ))} +
+ )} +
+
+ )} + + {/* Metadata grid */} +
+
+ + Folder + + + {file.folder} + +
+ +
+ + Type + + + {file.metadata.mimeType} + +
+ + {(file.metadata.width > 0 || file.metadata.height > 0) && ( +
+ + Dimensions + + + {file.metadata.width} × {file.metadata.height} px + +
+ )} +
+ + {/* Validation errors */} + {file.validationErrors && file.validationErrors.length > 0 && ( +
+ + Validation Errors + +
    + {file.validationErrors.map((error, i) => ( +
  • {error}
  • + ))} +
+
+ )} +
+
+ + {/* Actions */} +
+ + {onFileDelete && fileIndex !== null && ( +
+ {refs.length > 0 && ( + + + + + + + + + No references found. + + {refs.map((ref) => { + const [refModuleFilePath, modulePath] = + Internal.splitModuleFilePathAndModulePath(ref); + const patchPath = + Internal.createPatchPath(modulePath); + const label = `${prettifyFilename(Internal.splitModuleFilePath(refModuleFilePath).pop() || "")}${modulePath ? ` → ${Internal.splitModulePath(modulePath).join(" → ")}` : ""}`; + const isCurrent = currentSourcePath === ref; + return ( + { + navigate(ref); + setRefsOpen(false); + }} + > + + + + ); + })} + + + + + + )} + + + + + + + + {refs.length > 0 && ( + + Cannot delete: referenced in {refs.length}{" "} + {refs.length === 1 ? "place" : "places"} + + )} + + +
+ )} +
+
+
+ ); +} diff --git a/packages/ui/spa/components/FileGallery/FilenameInput.tsx b/packages/ui/spa/components/FileGallery/FilenameInput.tsx new file mode 100644 index 000000000..433743ae7 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/FilenameInput.tsx @@ -0,0 +1,122 @@ +import * as React from "react"; +import { Check, Pencil, X } from "lucide-react"; +import { cn } from "../designSystem/cn"; +import { Input } from "../designSystem/input"; + +interface FilenameInputProps { + filename: string; + onSave: (newFilename: string) => void; + disabled?: boolean; + className?: string; +} + +/** + * Splits a filename into name and extension. + * E.g., "photo.jpg" -> ["photo", ".jpg"] + * E.g., "archive.tar.gz" -> ["archive.tar", ".gz"] + * E.g., "README" -> ["README", ""] + */ +function splitFilename(filename: string): [string, string] { + const lastDotIndex = filename.lastIndexOf("."); + if (lastDotIndex <= 0) { + // No extension or starts with dot (hidden file) + return [filename, ""]; + } + return [filename.slice(0, lastDotIndex), filename.slice(lastDotIndex)]; +} + +export function FilenameInput({ + filename, + onSave, + disabled = false, + className, +}: FilenameInputProps) { + const [isEditing, setIsEditing] = React.useState(false); + const [name, extension] = splitFilename(filename); + const [editedName, setEditedName] = React.useState(name); + + React.useEffect(() => { + const [newName] = splitFilename(filename); + setEditedName(newName); + setIsEditing(false); + }, [filename]); + + const handleSave = () => { + const trimmedName = editedName.trim(); + if (trimmedName && trimmedName !== name) { + onSave(trimmedName + extension); + } + setIsEditing(false); + }; + + const handleCancel = () => { + setEditedName(name); + setIsEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSave(); + } else if (e.key === "Escape") { + handleCancel(); + } + }; + + if (isEditing) { + return ( +
+
+ setEditedName(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + className="h-8 flex-1 rounded-r-none text-sm" + /> + {extension && ( + + {extension} + + )} +
+ + +
+ ); + } + + return ( +
+ + {filename} + + {!disabled && ( + + )} +
+ ); +} diff --git a/packages/ui/spa/components/FileGallery/index.ts b/packages/ui/spa/components/FileGallery/index.ts new file mode 100644 index 000000000..014b0d061 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/index.ts @@ -0,0 +1,14 @@ +export { FileGallery } from "./FileGallery"; +export { FileGalleryItem } from "./FileGalleryItem"; +export { FileGalleryListView } from "./FileGalleryListView"; +export { FilenameInput } from "./FilenameInput"; +export { FilePreview } from "./FilePreview"; +export { FilePropertiesModal } from "./FilePropertiesModal"; +export type { + FileGalleryProps, + FileMetadata, + GalleryFile, + SortDirection, + SortField, + ViewMode, +} from "./types"; diff --git a/packages/ui/spa/components/FileGallery/stories/FileGallery.stories.tsx b/packages/ui/spa/components/FileGallery/stories/FileGallery.stories.tsx new file mode 100644 index 000000000..aae6b1f63 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/stories/FileGallery.stories.tsx @@ -0,0 +1,348 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { FileGallery } from "../FileGallery"; +import type { GalleryFile } from "../types"; +import { ValPortalProvider } from "../../ValPortalProvider"; +import { ValThemeProvider } from "../../ValThemeProvider"; + +const meta: Meta = { + title: "Components/FileGallery", + component: FileGallery, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( + {}} config={undefined}> + + + + + ), + ], +}; + +export default meta; +type Story = StoryObj; + +const imageFiles: GalleryFile[] = [ + { + ref: "/images/photos/landscape.jpg", + url: "/sample-image-1.jpg", + filename: "landscape.jpg", + folder: "/images/photos", + metadata: { + width: 800, + height: 600, + mimeType: "image/jpeg", + alt: "A beautiful landscape", + }, + createdAt: new Date("2025-12-15T10:30:00"), + }, + { + ref: "/images/photos/portrait.jpg", + url: "/sample-image-2.jpg", + filename: "portrait.jpg", + folder: "/images/photos", + metadata: { + width: 600, + height: 800, + mimeType: "image/jpeg", + }, + createdAt: new Date("2025-12-20T14:45:00"), + }, + { + ref: "/images/banners/wide-shot.jpg", + url: "/sample-image-3.jpg", + filename: "wide-shot.jpg", + folder: "/images/banners", + metadata: { + width: 1200, + height: 800, + mimeType: "image/jpeg", + alt: "Wide panoramic shot", + }, + }, +]; + +const mixedFiles: GalleryFile[] = [ + ...imageFiles, + { + ref: "/videos/demo-video.mp4", + url: "/sample-video.mp4", + filename: "demo-video.mp4", + folder: "/videos", + metadata: { + width: 320, + height: 176, + mimeType: "video/mp4", + }, + createdAt: new Date("2025-11-10T09:00:00"), + }, + { + ref: "/audio/background-music.mp3", + url: "#", + filename: "background-music.mp3", + folder: "/audio", + metadata: { + width: 0, + height: 0, + mimeType: "audio/mpeg", + }, + createdAt: new Date("2026-01-05T16:20:00"), + }, + { + ref: "/data/config.json", + url: "#", + filename: "config.json", + folder: "/data", + metadata: { + width: 0, + height: 0, + mimeType: "application/json", + }, + createdAt: new Date("2025-10-01T12:00:00"), + }, + { + ref: "/docs/readme.txt", + url: "#", + filename: "readme.txt", + folder: "/docs", + metadata: { + width: 0, + height: 0, + mimeType: "text/plain", + }, + createdAt: new Date("2026-02-01T08:30:00"), + }, + { + ref: "/downloads/archive.zip", + url: "#", + filename: "archive.zip", + folder: "/downloads", + metadata: { + width: 0, + height: 0, + mimeType: "application/zip", + }, + createdAt: new Date("2025-12-25T00:00:00"), + }, +]; + +export const Empty: Story = { + render: () => , +}; + +export const ImagesOnly: Story = { + render: () => , +}; + +export const MixedMedia: Story = { + render: () => , +}; + +export const WithRenameHandler: Story = { + render: function Render() { + const [files, setFiles] = useState(imageFiles); + + const handleRename = (index: number, newFilename: string) => { + setFiles((prev) => + prev.map((file, i) => + i === index ? { ...file, filename: newFilename } : file, + ), + ); + }; + + return ; + }, +}; + +export const SingleFile: Story = { + render: () => , +}; + +export const ManyFiles: Story = { + render: () => ( + ({ + ...f, + filename: `copy-${i + 1}-${f.filename}`, + })), + ...imageFiles.map((f, i) => ({ + ...f, + filename: `backup-${i + 1}-${f.filename}`, + })), + ]} + /> + ), +}; + +export const GridView: Story = { + render: () => ( + ({ + ...f, + filename: `copy-${i + 1}-${f.filename}`, + })), + ]} + defaultViewMode="grid" + /> + ), +}; + +export const WithoutSearch: Story = { + render: () => , +}; + +export const ImageModeWithAltText: Story = { + render: function Render() { + const [files, setFiles] = useState(imageFiles); + + const handleRename = (index: number, newFilename: string) => { + setFiles((prev) => + prev.map((file, i) => + i === index ? { ...file, filename: newFilename } : file, + ), + ); + }; + + const handleAltTextChange = (index: number, newAltText: string) => { + setFiles((prev) => + prev.map((file, i) => + i === index + ? { ...file, metadata: { ...file.metadata, alt: newAltText } } + : file, + ), + ); + }; + + return ( + + ); + }, +}; + +export const ListView: Story = { + render: () => ( + + ), +}; + +export const WithValidationErrors: Story = { + render: () => ( + + ), +}; + +export const Loading: Story = { + render: () => , +}; + +export const Disabled: Story = { + render: function Render() { + const handleRename = (index: number, newFilename: string) => { + console.log(`Rename file ${index} to: ${newFilename}`); + }; + + const handleAltTextChange = (index: number, newAltText: string) => { + console.log(`Alt text for ${index}: ${newAltText}`); + }; + + return ( + + ); + }, +}; + +// Generate many files for virtualization testing +function generateManyFiles(count: number): GalleryFile[] { + const baseFiles = [ + { + url: "/sample-image-1.jpg", + filename: "landscape", + folder: "/images/photos", + metadata: { width: 800, height: 600, mimeType: "image/jpeg" }, + }, + { + url: "/sample-image-2.jpg", + filename: "portrait", + folder: "/images/photos", + metadata: { width: 600, height: 800, mimeType: "image/jpeg" }, + }, + { + url: "/sample-image-3.jpg", + filename: "wide-shot", + folder: "/images/banners", + metadata: { width: 1200, height: 800, mimeType: "image/jpeg" }, + }, + { + url: "/sample-video.mp4", + filename: "video", + folder: "/media/videos", + metadata: { width: 1920, height: 1080, mimeType: "video/mp4" }, + }, + { + url: "/document.pdf", + filename: "document", + folder: "/docs", + metadata: { width: 0, height: 0, mimeType: "application/pdf" }, + }, + ]; + + return Array.from({ length: count }, (_, i) => { + const base = baseFiles[i % baseFiles.length]; + const filename = `${base.filename}-${i + 1}.${base.metadata.mimeType.split("/")[1]}`; + return { + ...base, + ref: `${base.folder}/${filename}`, + filename, + createdAt: new Date(Date.now() - i * 1000 * 60 * 60 * 24), + }; + }); +} + +export const VirtualizedList: Story = { + render: () => ( + + ), +}; + +export const VirtualizedMasonry: Story = { + render: () => ( + + ), +}; diff --git a/packages/ui/spa/components/FileGallery/stories/FileProperties.stories.tsx b/packages/ui/spa/components/FileGallery/stories/FileProperties.stories.tsx new file mode 100644 index 000000000..5db8324c5 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/stories/FileProperties.stories.tsx @@ -0,0 +1,167 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FileProperties } from "../FileProperties"; +import type { GalleryFile } from "../types"; + +const meta: Meta = { + title: "FileGallery/FileProperties", + component: FileProperties, + parameters: { + layout: "padded", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +const sampleImageFile: GalleryFile = { + ref: "/public/val/images/sample-landscape.jpg", + url: "/sample-landscape.jpg", + filename: "sample-landscape.jpg", + folder: "/public/val/images", + metadata: { + width: 1920, + height: 1080, + mimeType: "image/jpeg", + alt: "A beautiful mountain landscape", + }, + createdAt: new Date("2024-01-15T10:30:00"), +}; + +const sampleVideoFile: GalleryFile = { + ref: "/public/val/videos/promotional-video.mp4", + url: "/sample-video.mp4", + filename: "promotional-video.mp4", + folder: "/public/val/videos", + metadata: { + width: 1920, + height: 1080, + mimeType: "video/mp4", + }, + createdAt: new Date("2024-02-20T14:45:00"), +}; + +const sampleDocumentFile: GalleryFile = { + ref: "/public/val/documents/annual-report-2024.pdf", + url: "/document.pdf", + filename: "annual-report-2024.pdf", + folder: "/public/val/documents", + metadata: { + width: 0, + height: 0, + mimeType: "application/pdf", + }, +}; + +const fileWithoutAlt: GalleryFile = { + ref: "/public/val/images/no-alt-image.png", + url: "/no-alt-image.png", + filename: "no-alt-image.png", + folder: "/public/val/images", + metadata: { + width: 800, + height: 600, + mimeType: "image/png", + }, + createdAt: new Date("2024-03-10T09:00:00"), +}; + +export const ImageFile: Story = { + args: { + file: sampleImageFile, + fileIndex: 0, + imageMode: true, + onFileRename: (index, newFilename) => + console.log(`Rename file ${index} to: ${newFilename}`), + onAltTextChange: (index, newAltText) => + console.log(`Alt text for ${index}: ${newAltText}`), + }, +}; + +export const ImageFileReadOnly: Story = { + args: { + file: sampleImageFile, + fileIndex: 0, + imageMode: true, + }, +}; + +export const ImageWithoutAltText: Story = { + args: { + file: fileWithoutAlt, + fileIndex: 0, + imageMode: true, + onFileRename: (index, newFilename) => + console.log(`Rename file ${index} to: ${newFilename}`), + onAltTextChange: (index, newAltText) => + console.log(`Alt text for ${index}: ${newAltText}`), + }, +}; + +export const VideoFile: Story = { + args: { + file: sampleVideoFile, + fileIndex: 0, + onFileRename: (index, newFilename) => + console.log(`Rename file ${index} to: ${newFilename}`), + }, +}; + +export const DocumentFile: Story = { + args: { + file: sampleDocumentFile, + fileIndex: 0, + onFileRename: (index, newFilename) => + console.log(`Rename file ${index} to: ${newFilename}`), + }, +}; + +export const WithoutCreatedDate: Story = { + args: { + file: sampleDocumentFile, + fileIndex: 0, + }, +}; + +export const ImageModeOff: Story = { + args: { + file: sampleImageFile, + fileIndex: 0, + imageMode: false, + onFileRename: (index, newFilename) => + console.log(`Rename file ${index} to: ${newFilename}`), + onAltTextChange: (index, newAltText) => + console.log(`Alt text for ${index}: ${newAltText}`), + }, +}; + +export const LongFilename: Story = { + args: { + file: { + ...sampleImageFile, + filename: + "this-is-a-very-long-filename-that-should-be-truncated-properly-in-the-ui.jpg", + }, + fileIndex: 0, + imageMode: true, + onFileRename: (index, newFilename) => + console.log(`Rename file ${index} to: ${newFilename}`), + }, +}; + +export const LongFolderPath: Story = { + args: { + file: { + ...sampleImageFile, + folder: "/public/val/images/deeply/nested/folder/structure/here", + }, + fileIndex: 0, + imageMode: true, + }, +}; diff --git a/packages/ui/spa/components/FileGallery/stories/FilenameInput.stories.tsx b/packages/ui/spa/components/FileGallery/stories/FilenameInput.stories.tsx new file mode 100644 index 000000000..fbefa3fa8 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/stories/FilenameInput.stories.tsx @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { FilenameInput } from "../FilenameInput"; + +const meta: Meta = { + title: "FileGallery/FilenameInput", + component: FilenameInput, + parameters: { + layout: "padded", + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + filename: "landscape.jpg", + onSave: (newFilename) => console.log("Save:", newFilename), + }, +}; + +export const WithLongFilename: Story = { + args: { + filename: "this-is-a-very-long-filename-that-should-truncate.jpg", + onSave: (newFilename) => console.log("Save:", newFilename), + }, +}; + +export const NoExtension: Story = { + args: { + filename: "README", + onSave: (newFilename) => console.log("Save:", newFilename), + }, +}; + +export const MultipleExtensions: Story = { + args: { + filename: "archive.tar.gz", + onSave: (newFilename) => console.log("Save:", newFilename), + }, +}; + +export const HiddenFile: Story = { + args: { + filename: ".gitignore", + onSave: (newFilename) => console.log("Save:", newFilename), + }, +}; + +export const Disabled: Story = { + args: { + filename: "readonly-file.pdf", + onSave: (newFilename) => console.log("Save:", newFilename), + disabled: true, + }, +}; + +export const Interactive: Story = { + render: function Render() { + const [filename, setFilename] = useState("my-photo.jpg"); + + return ( +
+ +

+ Current filename: {filename} +

+
+ ); + }, +}; + +export const DifferentExtensions: Story = { + render: () => ( +
+ console.log(f)} /> + console.log(f)} /> + console.log(f)} /> + console.log(f)} /> +
+ ), +}; diff --git a/packages/ui/spa/components/FileGallery/types.ts b/packages/ui/spa/components/FileGallery/types.ts new file mode 100644 index 000000000..876d74947 --- /dev/null +++ b/packages/ui/spa/components/FileGallery/types.ts @@ -0,0 +1,40 @@ +export interface FileMetadata { + width: number; + height: number; + mimeType: string; + alt?: string; +} + +export interface GalleryFile { + ref: string; + url: string; + filename: string; + folder: string; + metadata: FileMetadata; + createdAt?: Date; + validationErrors?: string[]; + fieldSpecificErrors?: { + alt?: string[]; + }; +} + +export type ViewMode = "masonry" | "grid" | "list"; + +export type SortField = "name" | "description" | "type"; +export type SortDirection = "asc" | "desc"; + +export interface FileGalleryProps { + files: GalleryFile[]; + parentPath?: string; + onFileRename?: (index: number, newFilename: string) => void; + onAltTextChange?: (index: number, newAltText: string) => void; + onFileDelete?: (index: number) => void; + className?: string; + defaultViewMode?: ViewMode; + showSearch?: boolean; + imageMode?: boolean; + loading?: boolean; + disabled?: boolean; + onUploadClick?: () => void; + uploading?: boolean; +} diff --git a/packages/ui/spa/components/MediaPicker/GalleryUploadTarget.tsx b/packages/ui/spa/components/MediaPicker/GalleryUploadTarget.tsx new file mode 100644 index 000000000..d5bde7885 --- /dev/null +++ b/packages/ui/spa/components/MediaPicker/GalleryUploadTarget.tsx @@ -0,0 +1,97 @@ +import * as React from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../designSystem/select"; +import { Folder } from "lucide-react"; + +export interface GalleryUploadTargetProps { + modulePaths: string[]; + selectedPath?: string; + onSelect?: (path: string) => void; + /** Portal container for the select dropdown (shadow DOM support) */ + portalContainer?: HTMLElement | null; +} + +/** + * Derive a human-friendly display name from a module path. + * + * Examples: + * "/content/media.val.ts" → "media" + * "/content/blog-images.val.ts" → "blog images" + * "/content/pages/hero.val.ts" → "pages / hero" + * "/schema/product_photos.val.ts" → "product photos" + */ +export function prettyModuleName(modulePath: string): string { + // Strip leading slash and the .val.ts (or .val.js) suffix + const stripped = modulePath + .replace(/^\//, "") + .replace(/\.val\.(ts|js)$/, ""); + + // Split on "/" to get folder segments + const segments = stripped.split("/"); + + // Drop "content" or "schema" if it's the first segment and there's more + if ( + segments.length > 1 && + (segments[0] === "content" || segments[0] === "schema") + ) { + segments.shift(); + } + + // Replace dashes/underscores with spaces in each segment and join with " / " + return segments.map((s) => s.replace(/[-_]/g, " ")).join(" / "); +} + +/** + * When multiple gallery modules are referenced by a field, + * this lets the user pick which gallery uploaded files should be added to. + */ +export function GalleryUploadTarget({ + modulePaths, + selectedPath, + onSelect, + portalContainer, +}: GalleryUploadTargetProps) { + const [value, setValue] = React.useState(selectedPath || modulePaths[0]); + + return ( +
+ Add uploads to: + +
+ ); +} diff --git a/packages/ui/spa/components/MediaPicker/MediaPicker.tsx b/packages/ui/spa/components/MediaPicker/MediaPicker.tsx new file mode 100644 index 000000000..943195c13 --- /dev/null +++ b/packages/ui/spa/components/MediaPicker/MediaPicker.tsx @@ -0,0 +1,402 @@ +import * as React from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "../designSystem/popover"; +import { Button } from "../designSystem/button"; +import { cn } from "../designSystem/cn"; +import { + Check, + ChevronsUpDown, + ImageIcon, + FileIcon, + Search, +} from "lucide-react"; +import { prettyModuleName } from "./GalleryUploadTarget"; +import { ModuleFilePath } from "@valbuild/core"; +import { useFilePatchIds, useSourceAtPath } from "../ValFieldProvider"; + +export interface GalleryEntry { + /** The file path key (e.g. "/public/val/images/logo.png") */ + filePath: string; + /** Metadata for the entry */ + metadata: Record; + /** Which module this entry belongs to */ + modulePath: string; +} + +export interface MediaPickerProps { + /** Gallery entries grouped by module: Record> */ + moduleEntries: Record>>; + /** Currently selected file ref, if any */ + selectedRef?: string | null; + /** Called when user selects a gallery entry */ + onSelect: (entry: GalleryEntry) => void; + /** Whether this is for images (shows thumbnails) or files */ + isImage?: boolean; + disabled?: boolean; + /** Portal container for the popover (shadow DOM support) */ + portalContainer?: HTMLElement | null; + /** Converts a gallery file path to a displayable URL (e.g. for patch-state files) */ + getUrl?: (filePath: string) => string; +} + +const ROW_HEIGHT = 48; + +/** A flat row for the virtualized list. Can be a heading or an entry. */ +type PickerRow = + | { kind: "heading"; modulePath: string } + | { + kind: "entry"; + filePath: string; + metadata: Record; + modulePath: string; + }; + +function buildRows( + moduleEntries: Record>>, + filter: string, + showHeadings: boolean, +): PickerRow[] { + const rows: PickerRow[] = []; + const lowerFilter = filter.toLowerCase(); + + for (const modulePath of Object.keys(moduleEntries)) { + const entries = moduleEntries[modulePath]; + const filtered = Object.keys(entries).filter((fp) => { + if (!lowerFilter) return true; + const filename = fp.split("/").pop() || fp; + const alt = + typeof entries[fp].alt === "string" ? (entries[fp].alt as string) : ""; + return ( + filename.toLowerCase().includes(lowerFilter) || + alt.toLowerCase().includes(lowerFilter) + ); + }); + + if (filtered.length === 0) continue; + + if (showHeadings) { + rows.push({ kind: "heading", modulePath }); + } + + for (const filePath of filtered) { + rows.push({ + kind: "entry", + filePath, + metadata: entries[filePath] || {}, + modulePath, + }); + } + } + + return rows; +} + +export function MediaPicker({ + moduleEntries, + selectedRef, + onSelect, + isImage = false, + disabled = false, + portalContainer, + getUrl, +}: MediaPickerProps) { + const [open, setOpen] = React.useState(false); + const [filter, setFilter] = React.useState(""); + const [activeIndex, setActiveIndex] = React.useState(-1); + + const inputRef = React.useRef(null); + const scrollRef = React.useRef(null); + + const modulePaths = Object.keys(moduleEntries); + const showHeadings = modulePaths.length > 1; + + const rows = React.useMemo( + () => buildRows(moduleEntries, filter, showHeadings), + [moduleEntries, filter, showHeadings], + ); + + // Only entry rows are selectable + const entryIndices = React.useMemo( + () => + rows.reduce((acc, row, i) => { + if (row.kind === "entry") acc.push(i); + return acc; + }, []), + [rows], + ); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => (rows[index].kind === "heading" ? 28 : ROW_HEIGHT), + overscan: 10, + }); + + // Reset active index when filter changes + React.useEffect(() => { + setActiveIndex(entryIndices.length > 0 ? entryIndices[0] : -1); + }, [filter, entryIndices]); + + // Focus input and re-measure virtualizer when opening + React.useEffect(() => { + if (open) { + // Delay until after the popover open animation completes so the + // scroll container has its real dimensions for the virtualizer. + const t = setTimeout(() => { + inputRef.current?.focus(); + virtualizer.measure(); + }, 100); + return () => clearTimeout(t); + } else { + setFilter(""); + setActiveIndex(-1); + } + }, [open, virtualizer]); + + const selectRow = React.useCallback( + (index: number) => { + const row = rows[index]; + if (row?.kind === "entry") { + onSelect({ + filePath: row.filePath, + metadata: row.metadata, + modulePath: row.modulePath, + }); + setOpen(false); + } + }, + [rows, onSelect], + ); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (entryIndices.length === 0) return; + + const currentPos = entryIndices.indexOf(activeIndex); + + if (e.key === "ArrowDown") { + e.preventDefault(); + const next = + currentPos < entryIndices.length - 1 + ? entryIndices[currentPos + 1] + : entryIndices[0]; + setActiveIndex(next); + virtualizer.scrollToIndex(next, { align: "auto" }); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + const prev = + currentPos > 0 + ? entryIndices[currentPos - 1] + : entryIndices[entryIndices.length - 1]; + setActiveIndex(prev); + virtualizer.scrollToIndex(prev, { align: "auto" }); + } else if (e.key === "Enter") { + e.preventDefault(); + if (activeIndex >= 0) { + selectRow(activeIndex); + } + } else if (e.key === "Escape") { + setOpen(false); + } + }, + [entryIndices, activeIndex, virtualizer, selectRow], + ); + + if (modulePaths.length === 0) { + return null; + } + + const selectedFilename = selectedRef + ? selectedRef.split("/").pop() || selectedRef + : null; + + const totalEntries = Object.values(moduleEntries).reduce( + (sum, entries) => sum + Object.keys(entries).length, + 0, + ); + + const maxHeight = Math.min(totalEntries * ROW_HEIGHT + 60, 320); + + return ( + + + + + + {/* Search input */} +
+ + setFilter(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={isImage ? "Search images..." : "Search files..."} + className="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-fg-secondary disabled:cursor-not-allowed disabled:opacity-50" + /> +
+ + {/* Virtualized list */} + {rows.length === 0 ? ( +
+ {isImage ? "No images found." : "No files found."} +
+ ) : ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const row = rows[virtualRow.index]; + + if (row.kind === "heading") { + return ( +
+ {prettyModuleName(row.modulePath)} +
+ ); + } + + const filename = row.filePath.split("/").pop() || row.filePath; + const isSelected = selectedRef === row.filePath; + const isActive = virtualRow.index === activeIndex; + const alt = + typeof row.metadata.alt === "string" + ? row.metadata.alt + : undefined; + const mimeType = + typeof row.metadata.mimeType === "string" + ? row.metadata.mimeType + : undefined; + + return ( +
setActiveIndex(virtualRow.index)} + onClick={() => selectRow(virtualRow.index)} + > + + {isImage && mimeType?.startsWith("image/") ? ( +
+ {alt +
+ ) : ( +
+ {isImage ? ( + + ) : ( + + )} +
+ )} +
+ {filename} + {alt && ( + + {alt} + + )} +
+
+ ); + })} +
+
+ )} +
+
+ ); +} + +export function ModuleMediaPicker({ + modulePath, + ...rest +}: Omit & { modulePath: ModuleFilePath }) { + const source = useSourceAtPath(modulePath); + const filePatchIds = useFilePatchIds(); + + const getUrl = React.useCallback( + (filePath: string): string => { + const patchId = filePatchIds.get(filePath); + if (patchId) { + return filePath.startsWith("/public") + ? `/api/val/files${filePath}?patch_id=${patchId}` + : `${filePath}?patch_id=${patchId}`; + } + return filePath.startsWith("/public") + ? filePath.slice("/public".length) + : filePath; + }, + [filePatchIds], + ); + + if (source.status !== "success") { + return null; + } + const moduleEntries = { + [modulePath]: source.data as Record>, + }; + return ( + + ); +} diff --git a/packages/ui/spa/components/MediaPicker/index.ts b/packages/ui/spa/components/MediaPicker/index.ts new file mode 100644 index 000000000..1e883100c --- /dev/null +++ b/packages/ui/spa/components/MediaPicker/index.ts @@ -0,0 +1,4 @@ +export { MediaPicker } from "./MediaPicker"; +export { GalleryUploadTarget, prettyModuleName } from "./GalleryUploadTarget"; +export type { GalleryEntry, MediaPickerProps } from "./MediaPicker"; +export type { GalleryUploadTargetProps } from "./GalleryUploadTarget"; diff --git a/packages/ui/spa/components/MediaPicker/stories/GalleryUploadTarget.stories.tsx b/packages/ui/spa/components/MediaPicker/stories/GalleryUploadTarget.stories.tsx new file mode 100644 index 000000000..677a2d8e3 --- /dev/null +++ b/packages/ui/spa/components/MediaPicker/stories/GalleryUploadTarget.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { GalleryUploadTarget } from "../GalleryUploadTarget"; + +const meta: Meta = { + title: "Components/MediaPicker/GalleryUploadTarget", + component: GalleryUploadTarget, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +export const TwoModules: Story = { + render: function Render() { + const [selected, setSelected] = useState("/content/media.val.ts"); + return ( +
+ +

+ Raw path: {selected} +

+
+ ); + }, +}; + +export const ThreeModules: Story = { + render: function Render() { + const [selected, setSelected] = useState("/content/media.val.ts"); + return ( +
+ +
+ ); + }, +}; + +export const NestedModulePaths: Story = { + render: function Render() { + const [selected, setSelected] = useState( + "/content/pages/hero-images.val.ts", + ); + return ( +
+ +

+ Shows: "pages / hero images", "pages / gallery", "product photos" +

+
+ ); + }, +}; + +export const DefaultSelection: Story = { + render: () => ( +
+ +
+ ), +}; diff --git a/packages/ui/spa/components/MediaPicker/stories/MediaPicker.stories.tsx b/packages/ui/spa/components/MediaPicker/stories/MediaPicker.stories.tsx new file mode 100644 index 000000000..c94f68ac4 --- /dev/null +++ b/packages/ui/spa/components/MediaPicker/stories/MediaPicker.stories.tsx @@ -0,0 +1,322 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { MediaPicker } from "../MediaPicker"; +import type { GalleryEntry } from "../MediaPicker"; + +const meta: Meta = { + title: "Components/MediaPicker", + component: MediaPicker, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +const singleModuleImages: Record< + string, + Record> +> = { + "/content/media.val.ts": { + "/public/val/images/logo.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: "Company logo", + }, + "/public/val/images/hero.jpg": { + width: 1920, + height: 1080, + mimeType: "image/jpeg", + alt: "Hero banner image", + }, + "/public/val/images/team.webp": { + width: 640, + height: 480, + mimeType: "image/webp", + alt: "Team photo", + }, + "/public/val/images/product-shot.png": { + width: 1200, + height: 900, + mimeType: "image/png", + alt: "Product screenshot", + }, + }, +}; + +const multiModuleImages: Record< + string, + Record> +> = { + "/content/media.val.ts": { + "/public/val/images/logo.png": { + width: 800, + height: 600, + mimeType: "image/png", + alt: "Company logo", + }, + "/public/val/images/hero.jpg": { + width: 1920, + height: 1080, + mimeType: "image/jpeg", + alt: "Hero banner", + }, + }, + "/content/blog-images.val.ts": { + "/public/val/blog/post-1.jpg": { + width: 800, + height: 400, + mimeType: "image/jpeg", + alt: "Blog post 1 cover", + }, + "/public/val/blog/post-2.png": { + width: 1200, + height: 630, + mimeType: "image/png", + alt: "Blog post 2 cover", + }, + "/public/val/blog/thumbnail.webp": { + width: 300, + height: 300, + mimeType: "image/webp", + }, + }, +}; + +const singleModuleFiles: Record< + string, + Record> +> = { + "/content/documents.val.ts": { + "/public/val/docs/readme.pdf": { + mimeType: "application/pdf", + }, + "/public/val/docs/guide.pdf": { + mimeType: "application/pdf", + }, + "/public/val/docs/changelog.txt": { + mimeType: "text/plain", + }, + "/public/val/docs/data.json": { + mimeType: "application/json", + }, + }, +}; + +export const ImageGallery: Story = { + render: function Render() { + const [selected, setSelected] = useState(null); + return ( +
+ setSelected(entry.filePath)} + isImage + /> + {selected && ( +

+ Selected: {selected} +

+ )} +
+ ); + }, +}; + +export const ImageGalleryPreselected: Story = { + render: () => ( +
+ {}} + isImage + /> +
+ ), +}; + +export const MultipleModules: Story = { + render: function Render() { + const [selected, setSelected] = useState(null); + const [lastModule, setLastModule] = useState(null); + return ( +
+ { + setSelected(entry.filePath); + setLastModule(entry.modulePath); + }} + isImage + /> + {selected && ( +
+

Selected: {selected}

+

From: {lastModule}

+
+ )} +
+ ); + }, +}; + +export const FileGallery: Story = { + render: function Render() { + const [selected, setSelected] = useState(null); + return ( +
+ setSelected(entry.filePath)} + isImage={false} + /> + {selected && ( +

+ Selected: {selected} +

+ )} +
+ ); + }, +}; + +export const Disabled: Story = { + render: () => ( +
+ {}} + isImage + disabled + /> +
+ ), +}; + +export const EmptyGallery: Story = { + render: () => ( +
+ {}} + isImage + /> +
+ ), +}; + +export const NoModules: Story = { + render: () => ( +
+ {}} isImage /> +
+ ), +}; + +/** + * Generate a large set of gallery entries for virtualization testing. + * Creates `count` images spread across `moduleCount` modules. + */ +function generateManyImages( + count: number, + moduleCount = 1, +): Record>> { + const folders = [ + "photos", + "banners", + "icons", + "backgrounds", + "avatars", + "screenshots", + "illustrations", + "thumbnails", + ]; + const extensions = ["jpg", "png", "webp"]; + const widths = [800, 1200, 1920, 640, 400, 300]; + const heights = [600, 800, 1080, 480, 400, 300]; + + const modules: Record>> = {}; + + for (let m = 0; m < moduleCount; m++) { + const modName = + moduleCount === 1 + ? "/content/media.val.ts" + : `/content/${folders[m % folders.length]}-gallery.val.ts`; + modules[modName] = {}; + } + + const moduleKeys = Object.keys(modules); + for (let i = 0; i < count; i++) { + const mod = moduleKeys[i % moduleKeys.length]; + const folder = folders[i % folders.length]; + const ext = extensions[i % extensions.length]; + const w = widths[i % widths.length]; + const h = heights[i % heights.length]; + + modules[mod][`/public/val/images/${folder}/image-${i + 1}.${ext}`] = { + width: w, + height: h, + mimeType: `image/${ext === "jpg" ? "jpeg" : ext}`, + alt: `${folder} image ${i + 1}`, + }; + } + + return modules; +} + +export const VirtualizedLargeGallery: Story = { + render: function Render() { + const [selected, setSelected] = useState(null); + const manyImages = generateManyImages(500); + return ( +
+

+ 500 images, virtualized list +

+ setSelected(entry.filePath)} + isImage + /> + {selected && ( +

+ Selected: {selected} +

+ )} +
+ ); + }, +}; + +export const VirtualizedMultiModule: Story = { + render: function Render() { + const [selected, setSelected] = useState(null); + const manyImages = generateManyImages(300, 4); + return ( +
+

+ 300 images across 4 modules, virtualized with headings +

+ setSelected(entry.filePath)} + isImage + /> + {selected && ( +

+ Selected: {selected} +

+ )} +
+ ); + }, +}; diff --git a/packages/ui/spa/components/Module.tsx b/packages/ui/spa/components/Module.tsx index 6b91ce4d2..3580a792e 100644 --- a/packages/ui/spa/components/Module.tsx +++ b/packages/ui/spa/components/Module.tsx @@ -99,6 +99,7 @@ export function Module({ path }: { path: SourcePath }) { // Check if the current schema is a router record const isCurrentRouter = schema.type === "record" && Boolean(schema.router); + const isMediaGallery = schema.type === "record" && Boolean(schema.mediaType); return (
@@ -137,9 +138,11 @@ export function Module({ path }: { path: SourcePath }) { {showNumber && ( #{Number(last.text)} )} -
- -
+ {!isMediaGallery && ( +
+ +
+ )}
{keyErrors.length > 0 && ( diff --git a/packages/ui/spa/components/NavMenu/ExplorerSection.tsx b/packages/ui/spa/components/NavMenu/ExplorerSection.tsx index 0b375a15d..e323262d0 100644 --- a/packages/ui/spa/components/NavMenu/ExplorerSection.tsx +++ b/packages/ui/spa/components/NavMenu/ExplorerSection.tsx @@ -27,7 +27,7 @@ export function ExplorerSection({ maxHeight = "100%", }: ExplorerSectionProps) { return ( - + [] = [ blogPages, articles, settings, @@ -474,7 +476,7 @@ function createMockData() { config, ]; const schemas: Record = {}; - const sources: Record = {}; + const sources: Record = {}; const renders: Record = {}; for (const module of modules) { const moduleFilePath = Internal.getValPath(module); diff --git a/packages/ui/spa/components/ValFieldProvider.tsx b/packages/ui/spa/components/ValFieldProvider.tsx index c3f32348a..067bc3366 100644 --- a/packages/ui/spa/components/ValFieldProvider.tsx +++ b/packages/ui/spa/components/ValFieldProvider.tsx @@ -1016,4 +1016,26 @@ export function useSourceAtPath(sourcePath: SourcePath | ModuleFilePath): }, [sourceSnapshot, initializedAt, modulePath, moduleFilePath]); } +export function useFilePatchIds(): ReadonlyMap { + const { syncEngine } = useContext(ValFieldContext); + const patchesSnapshot = useSyncExternalStore( + syncEngine.subscribe("all-patches"), + () => syncEngine.getAllPatchesSnapshot(), + () => syncEngine.getAllPatchesSnapshot(), + ); + return useMemo(() => { + const map = new Map(); + for (const [patchId, data] of Object.entries(patchesSnapshot ?? {})) { + if (data && !data.isCommitted) { + for (const op of data.patch) { + if (op.op === "file" && "filePath" in op) { + map.set(op.filePath as string, patchId); + } + } + } + } + return map; + }, [patchesSnapshot]); +} + export type { ShallowSource }; diff --git a/packages/ui/spa/components/ValStudio.tsx b/packages/ui/spa/components/ValStudio.tsx index 209ecc4ae..cd6144569 100644 --- a/packages/ui/spa/components/ValStudio.tsx +++ b/packages/ui/spa/components/ValStudio.tsx @@ -1,5 +1,5 @@ import { FC } from "react"; -import { ValClient } from "@valbuild/shared/src/internal/ValClient"; +import { ValClient } from "@valbuild/shared/internal"; import { ValProvider } from "./ValProvider"; import { Themes } from "./ValThemeProvider"; import { Layout } from "./Layout"; diff --git a/packages/ui/spa/components/designSystem/input.tsx b/packages/ui/spa/components/designSystem/input.tsx index c09091fac..520cf2cf5 100644 --- a/packages/ui/spa/components/designSystem/input.tsx +++ b/packages/ui/spa/components/designSystem/input.tsx @@ -10,7 +10,7 @@ const Input = React.forwardRef( { + directory: string | undefined = "/public/val", + skipMetadataInReplace: boolean = false, +): Promise<{ patch: Patch; filePath: string }> { const newFilePath = Internal.createFilename( data, filename, @@ -59,7 +65,7 @@ export async function createFilePatch( fileHash, ); if (!newFilePath || !metadata) { - return []; + return { patch: [], filePath: "" }; } const filePath = `${directory}/${newFilePath}`; @@ -78,29 +84,32 @@ export async function createFilePatch( textEncoder, ), fileHash: remoteFileHash, - filePath: `${directory.slice(1) as `public/val`}/${newFilePath}`, + filePath: `${(directory ?? "/public/val").slice(1) as `public/val/${string}`}/${newFilePath}`, }) : filePath; - return [ - { - value: { - [FILE_REF_PROP]: ref, - [VAL_EXTENSION]: remote ? "remote" : "file", - ...(subType !== "file" ? { [FILE_REF_SUBTYPE_TAG]: subType } : {}), + return { + patch: [ + { + value: { + [FILE_REF_PROP]: ref, + [VAL_EXTENSION]: remote ? "remote" : "file", + ...(subType !== "file" ? { [FILE_REF_SUBTYPE_TAG]: subType } : {}), + ...(skipMetadataInReplace ? {} : { metadata }), + }, + op: "replace", + path, + }, + { + value: data, metadata, + op: "file", + path, + filePath: ref, + remote: remote !== null, }, - op: "replace", - path, - }, - { - value: data, - metadata, - op: "file", - path, - filePath: ref, - remote: remote !== null, - }, - ]; + ], + filePath, + }; } export function FileField({ path }: { path: SourcePath }) { @@ -108,16 +117,24 @@ export function FileField({ path }: { path: SourcePath }) { const config = useValConfig(); const currentRemoteFileBucket = useCurrentRemoteFileBucket(); const remoteFiles = useRemoteFiles(); + const schemas = useSchemas(); const schemaAtPath = useSchemaAtPath(path); const sourceAtPath = useShallowSourceAtPath(path, type); const [showAsVideo, setShowAsVideo] = useState(false); const [error, setError] = useState(null); const [url, setUrl] = useState(null); const [loading, setLoading] = useState(false); - const { patchPath, addAndUploadPatchWithFileOps } = useAddPatch(path); + const { + addPatch, + patchPath, + addAndUploadPatchWithFileOps, + addModuleFilePatch, + } = useAddPatch(path); + const portalContainer = useValPortal(); const [progressPercentage, setProgressPercentage] = useState( null, ); + const filePatchIds = useFilePatchIds(); const maybeSourceData = "data" in sourceAtPath && sourceAtPath.data; const maybeClientSideOnly = sourceAtPath.status === "success" && sourceAtPath.clientSideOnly; @@ -126,23 +143,26 @@ export function FileField({ path }: { path: SourcePath }) { if (maybeSourceData.metadata) { // We can't set the url before it is server side (since the we will be loading) if (!maybeClientSideOnly) { + const patchId = filePatchIds.get(maybeSourceData[FILE_REF_PROP]); const nextUrl = VAL_EXTENSION in maybeSourceData && maybeSourceData[VAL_EXTENSION] === "remote" ? Internal.convertRemoteSource({ ...maybeSourceData, [VAL_EXTENSION]: "remote", + ...(patchId ? { patch_id: patchId } : {}), }).url : Internal.convertFileSource({ ...maybeSourceData, [VAL_EXTENSION]: "file", + ...(patchId ? { patch_id: patchId } : {}), }).url; setUrl(nextUrl); setLoading(false); } } } - }, [sourceAtPath]); + }, [sourceAtPath, filePatchIds]); useEffect(() => { // We want to show video if only video is accepted // If source is defined we also show a video if the mimeType is video @@ -208,7 +228,36 @@ export function FileField({ path }: { path: SourcePath }) { schemaAtPath.data.type === "file" && schemaAtPath.data.remote && remoteFiles.status !== "ready"; - const disabled = remoteFileUploadDisabled; + const referencedModule = schemaAtPath.data.referencedModule; + const missingModules = + referencedModule && schemas.status === "success" + ? schemas.data[referencedModule as ModuleFilePath] + ? [] + : [referencedModule] + : []; + const disabled = remoteFileUploadDisabled || missingModules.length > 0; + const acceptOptions = useMemo(() => { + if ( + schemaAtPath.data.type !== "file" || + !referencedModule || + schemas.status !== "success" + ) { + return undefined; + } + if (schemaAtPath.data.options?.accept) { + return schemaAtPath.data.options.accept; + } + const moduleSchema = schemas.data[referencedModule as ModuleFilePath]; + if (moduleSchema?.type === "record" && moduleSchema.accept) { + return moduleSchema.accept; + } + return undefined; + }, [schemaAtPath.data, referencedModule, schemas]); + const moduleDirectory = useMemo(() => { + if (!referencedModule || schemas.status !== "success") return undefined; + const moduleSchema = schemas.data[referencedModule as ModuleFilePath]; + return moduleSchema?.type === "record" ? moduleSchema.directory : undefined; + }, [referencedModule, schemas]); const remoteData = schemaAtPath.data.remote && remoteFiles.status === "ready" && @@ -242,6 +291,13 @@ export function FileField({ path }: { path: SourcePath }) { return (
+ {missingModules.length > 0 && ( +
+ {missingModules.length === 1 + ? `The module '${missingModules[0]}' is referenced by this field but is not added to val.modules. Add it to val.modules to enable uploads.` + : `The following modules are referenced by this field but are not added to val.modules: ${missingModules.join(", ")}. Add them to val.modules to enable uploads.`} +
+ )} {error && (
{error} @@ -266,6 +322,31 @@ export function FileField({ path }: { path: SourcePath }) { )}
)} + {referencedModule && ( + { + addPatch( + [ + { + op: "replace", + path: patchPath, + value: { + [FILE_REF_PROP]: entry.filePath, + [VAL_EXTENSION]: "file", + metadata: entry.metadata as JSONValue, + }, + }, + ], + "file", + ); + }} + isImage={false} + disabled={disabled} + portalContainer={portalContainer} + /> + )}
{source && (showAsVideo ? ( @@ -286,13 +367,13 @@ export function FileField({ path }: { path: SourcePath }) { }).url } /> -
) : ( <> -