From d5d2c2967e6237530385bc5a56fe24700c447cb4 Mon Sep 17 00:00:00 2001 From: Ben Life Date: Wed, 26 Aug 2026 14:35:01 -0400 Subject: [PATCH 1/7] validate command --- packages/visual-editor/package.json | 7 +- .../convertTemplatesToSectionLibrary.ts | 2 +- .../exportDirectoryLocatorSectionLibrary.ts | 8 +- .../src/cli/commands/validate.ts | 53 ++ packages/visual-editor/src/cli/output.ts | 52 ++ packages/visual-editor/src/cli/yextve.test.ts | 92 ++++ packages/visual-editor/src/cli/yextve.ts | 90 ++++ packages/visual-editor/src/index.ts | 2 +- .../stages/code/importRules.ts | 141 +++++ .../stages/code/validateCode.test.ts | 178 +++++++ .../stages/code/validateCode.ts | 184 +++++++ .../stages/code/xssRules.ts | 140 +++++ .../stages/metadata/libraryMetadata.test.ts | 125 +++++ .../stages/metadata/libraryMetadata.ts | 102 ++++ .../stages/structure/structure.test.ts | 422 +++++++++++++++ .../stages/structure/structure.ts | 503 ++++++++++++++++++ .../sectionLibraryValidation/testUtils.ts | 18 + .../sectionLibraryValidation/types.ts | 49 ++ .../validateSectionLibrary.test.ts | 148 ++++++ .../validateSectionLibrary.ts | 75 +++ .../validationError.ts | 17 + packages/visual-editor/src/sectionLibrary.ts | 68 --- .../visual-editor/src/types/sectionLibrary.ts | 126 +++++ .../local-editor/artifacts.test.ts | 2 +- .../src/vite-plugin/local-editor/artifacts.ts | 2 +- .../vite-plugin/local-editor/config.test.ts | 2 +- .../src/vite-plugin/local-editor/config.ts | 2 +- .../src/vite-plugin/local-editor/data.test.ts | 2 +- .../src/vite-plugin/local-editor/data.ts | 2 +- .../local-editor/generatedFiles.ts | 2 +- .../src/vite-plugin/local-editor/server.ts | 2 +- .../src/vite-plugin/local-editor/types.ts | 2 +- .../visual-editor/src/vite-plugin/plugin.ts | 2 +- .../section-library/sectionFrontmatter.ts | 13 +- .../sectionLibraryGenerator.test.ts | 29 +- .../sectionLibraryGenerator.ts | 411 ++------------ .../sharedComponentRegistry.ts | 28 +- pnpm-lock.yaml | 3 + 38 files changed, 2608 insertions(+), 498 deletions(-) create mode 100644 packages/visual-editor/src/cli/commands/validate.ts create mode 100644 packages/visual-editor/src/cli/output.ts create mode 100644 packages/visual-editor/src/cli/yextve.test.ts create mode 100644 packages/visual-editor/src/cli/yextve.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.test.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.test.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/testUtils.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/types.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.test.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.ts create mode 100644 packages/visual-editor/src/internal/sectionLibraryValidation/validationError.ts delete mode 100644 packages/visual-editor/src/sectionLibrary.ts create mode 100644 packages/visual-editor/src/types/sectionLibrary.ts diff --git a/packages/visual-editor/package.json b/packages/visual-editor/package.json index 45548d989..ed103a43c 100644 --- a/packages/visual-editor/package.json +++ b/packages/visual-editor/package.json @@ -14,6 +14,9 @@ "files": [ "dist" ], + "bin": { + "yextve": "dist/cli/yextve.js" + }, "module": "dist/visual-editor.js", "exports": { ".": { @@ -45,7 +48,8 @@ "scripts": { "build:components": "tsc && vite build", "build:plugin": "rm -rf dist/plugin && tsc -p tsconfig.plugin.json --emitDeclarationOnly && vite build --config vite.config.plugin.ts", - "build": "pnpm run build:components && pnpm run build:plugin", + "build:cli": "tsup src/cli/yextve.ts --format esm --platform node --target node20 --out-dir dist/cli", + "build": "pnpm run build:components && pnpm run build:plugin && pnpm run build:cli", "export-section-library-directory-locator": "tsx scripts/exportDirectoryLocatorSectionLibrary.ts", "convert-templates-to-section-library": "tsx scripts/convertTemplatesToSectionLibrary.ts", "test": "pnpm run test:editor && pnpm run test:components", @@ -98,6 +102,7 @@ "lucide-react": "^0.414.0", "lz-string": "1.5.0", "next-themes": "^0.3.0", + "picocolors": "^1.1.1", "pure-react-carousel": "^1.32.0", "react-collapsed": "^4.1.2", "react-color": "^2.19.3", diff --git a/packages/visual-editor/scripts/convertTemplatesToSectionLibrary.ts b/packages/visual-editor/scripts/convertTemplatesToSectionLibrary.ts index d0d0ecf7d..01a249e75 100644 --- a/packages/visual-editor/scripts/convertTemplatesToSectionLibrary.ts +++ b/packages/visual-editor/scripts/convertTemplatesToSectionLibrary.ts @@ -9,7 +9,7 @@ import { type EntityLayoutMetadata, type LibraryMetadata, type PageSetType, -} from "../src/sectionLibrary.ts"; +} from "../src/types/sectionLibrary.ts"; import { exportDirectoryLocatorSectionLibrary } from "./exportDirectoryLocatorSectionLibrary.ts"; const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]*$/; diff --git a/packages/visual-editor/scripts/exportDirectoryLocatorSectionLibrary.ts b/packages/visual-editor/scripts/exportDirectoryLocatorSectionLibrary.ts index f6de3f0c9..7f8188a2e 100644 --- a/packages/visual-editor/scripts/exportDirectoryLocatorSectionLibrary.ts +++ b/packages/visual-editor/scripts/exportDirectoryLocatorSectionLibrary.ts @@ -394,18 +394,18 @@ const writeComponentRegistry = ( 'import { DropZone, type Config } from "@puckeditor/core";', 'import { resolveDirectoryRootProps } from "@yext/visual-editor/section-library-support";', "", - 'const rootStyle = { display: "flex", flexDirection: "column", minHeight: "100vh" } as const;', - "", + "// The Puck Root configuration for directory page sets", 'export const directoryRootConfig: NonNullable = {', " resolveData: (data: any, params: any) => ({", " ...data,", " props: resolveDirectoryRootProps(data.props ?? {}, params.metadata?.streamDocument ?? {}),", " }),", - ' render: () => ,', + ' render: () => ,', "};", "", + "// The Puck Root configuration for locator page sets", 'export const locatorRootConfig: NonNullable = {', - ' render: () => ,', + ' render: () => ,', "};", "", ].join("\n") diff --git a/packages/visual-editor/src/cli/commands/validate.ts b/packages/visual-editor/src/cli/commands/validate.ts new file mode 100644 index 000000000..4e478fe22 --- /dev/null +++ b/packages/visual-editor/src/cli/commands/validate.ts @@ -0,0 +1,53 @@ +import { parseArgs } from "node:util"; +import { + createValidationContext, + validateSectionLibrary, +} from "../../internal/sectionLibraryValidation/validateSectionLibrary.ts"; +import type { ValidationStage } from "../../internal/sectionLibraryValidation/types.ts"; +import { renderValidationResult } from "../output.ts"; + +type ValidateCommandIo = { + stdout: Pick; +}; + +export const runValidateCommand = ( + args: string[], + io: ValidateCommandIo, + rootDir: string +): number => { + const { values } = parseArgs({ + args, + strict: true, + allowPositionals: false, + options: { + yextCI: { type: "boolean" }, + "skip-api-check": { type: "boolean" }, + "skip-repo-structure-check": { type: "boolean" }, + "skip-code-check": { type: "boolean" }, + }, + }); + + const skippedStages = new Set(); + if (values["skip-api-check"]) { + skippedStages.add("api"); + } + if (values["skip-repo-structure-check"]) { + skippedStages.add("structure"); + } + if (values["skip-code-check"]) { + skippedStages.add("code"); + } + + const context = createValidationContext(rootDir, { + yextCI: values.yextCI, + skippedStages, + }); + + const result = validateSectionLibrary(context); + + io.stdout.write( + renderValidationResult(result, !!io.stdout.isTTY && !context.yextCI) + ); + + return result.issues.length === 0 ? 0 : 1; +}; diff --git a/packages/visual-editor/src/cli/output.ts b/packages/visual-editor/src/cli/output.ts new file mode 100644 index 000000000..0429e38f7 --- /dev/null +++ b/packages/visual-editor/src/cli/output.ts @@ -0,0 +1,52 @@ +import pc from "picocolors"; +import { formatValidationIssue } from "../internal/sectionLibraryValidation/validationError.ts"; +import type { + ValidationResult, + ValidationStage, +} from "../internal/sectionLibraryValidation/types.ts"; + +const stages: { stage: ValidationStage; label: string }[] = [ + { stage: "api", label: "Library metadata" }, + { stage: "structure", label: "Repository structure" }, + { stage: "code", label: "Code checks" }, +]; + +export const renderValidationResult = ( + result: ValidationResult, + colorEnabled: boolean +): string => { + const colors = pc.createColors(colorEnabled && pc.isColorSupported); + const lines: string[] = []; + + for (const { stage, label } of stages) { + if (result.context.skippedStages.has(stage)) { + lines.push(`${label}: ${colors.yellow("skipped")}`); + continue; + } + + const issues = result.issues.filter((issue) => issue.category === stage); + + if (issues.length === 0) { + lines.push(`${label}: ${colors.green("passed")}`); + continue; + } + + lines.push( + `${label}: ${colors.red( + `failed (${issues.length} ${issues.length === 1 ? "error" : "errors"})` + )}` + ); + + lines.push(...issues.map((issue) => ` ${formatValidationIssue(issue)}`)); + } + + lines.push(""); + lines.push( + result.issues.length === 0 + ? colors.green("Validation passed. 0 errors.") + : colors.red( + `Validation failed. ${result.issues.length} ${result.issues.length === 1 ? "error" : "errors"}.` + ) + ); + return `${lines.join("\n")}\n`; +}; diff --git a/packages/visual-editor/src/cli/yextve.test.ts b/packages/visual-editor/src/cli/yextve.test.ts new file mode 100644 index 000000000..43eac7568 --- /dev/null +++ b/packages/visual-editor/src/cli/yextve.test.ts @@ -0,0 +1,92 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { createTempRoot } from "../internal/sectionLibraryValidation/testUtils.ts"; +import { runCli } from "./yextve.ts"; +import packageJson from "../../package.json" with { type: "json" }; + +describe("yextve", () => { + it("prints help and version", () => { + const help = invoke(["--help"]); + expect(help.exitCode).toBe(0); + expect(help.stdout).toContain( + "npx --package=@yext/visual-editor@latest yextve validate" + ); + expect(help.stdout).not.toContain("scripts"); + }); + + it("prints version", () => { + const version = invoke(["--version"]); + expect(version).toEqual({ + exitCode: 0, + stdout: `${packageJson.version}\n`, + stderr: "", + }); + }); + + it.each([ + { args: [] }, + { args: ["unknown"] }, + { args: ["validate", "project-path"] }, + { args: ["validate", "--unknown"] }, + ])("returns usage exit 2 for invalid arguments: $args", ({ args }) => { + const result = invoke(args); + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("Usage:"); + }); + + it("renders all skipped stages and succeeds", () => { + const result = invoke([ + "validate", + "--skip-api-check", + "--skip-repo-structure-check", + "--skip-code-check", + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout.match(/skipped/g)).toHaveLength(3); + expect(result.stdout).toContain("Validation passed. 0 errors."); + expect(result.stdout).not.toContain("\u001b["); + }); + + it("skips only API validation in Yext CI", () => { + const rootDir = createTempRoot(); + fs.outputFileSync( + path.join(rootDir, "src", "library", "Unsafe.ts"), + "eval(code);" + ); + + const result = invoke(["validate", "--yextCI"], rootDir); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("Library metadata: skipped"); + expect(result.stdout).toContain("Repository structure: failed"); + expect(result.stdout).toContain("Code checks: failed"); + }); +}); + +// invoke calls the cli and records stdout and stderr +const invoke = (args: string[], rootDir: string = createTempRoot()) => { + let stdout = ""; + let stderr = ""; + const exitCode = runCli( + args, + { + stdout: { + isTTY: false, + write: (value) => { + stdout += value; + return true; + }, + }, + stderr: { + write: (value) => { + stderr += value; + return true; + }, + }, + }, + rootDir + ); + return { exitCode, stdout, stderr }; +}; diff --git a/packages/visual-editor/src/cli/yextve.ts b/packages/visual-editor/src/cli/yextve.ts new file mode 100644 index 000000000..8f739ce28 --- /dev/null +++ b/packages/visual-editor/src/cli/yextve.ts @@ -0,0 +1,90 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import packageJson from "../../package.json" with { type: "json" }; +import { runValidateCommand } from "./commands/validate.ts"; + +const usage = `Usage: + yextve validate [--skip-api-check] [--skip-repo-structure-check] [--skip-code-check] + yextve --help + yextve --version + +Validate the Section Library in the current working directory. + +Options: + --skip-api-check Skip library.json metadata validation. + --skip-repo-structure-check Skip repository structure validation. + --skip-code-check Skip import and code-safety validation. + --help Show this help. + --version Show the package version. + +Examples: + npx --package=@yext/visual-editor@latest yextve validate +`; + +type CliIo = { + stdout: Pick; + stderr: Pick; +}; + +export const runCli = ( + args: string[], + io: CliIo = { stdout: process.stdout, stderr: process.stderr }, + rootDir: string = process.cwd() +): number => { + try { + // handle help + if (args.length === 1 && args[0] === "--help") { + io.stdout.write(usage); + return 0; + } + + // handle invalid help calls + if (args.slice(1).includes("--help")) { + if (args.length === 2) { + io.stdout.write(usage); + return 0; + } + io.stderr.write(usage); + return 2; + } + + // handle version + if (args.length === 1 && args[0] === "--version") { + io.stdout.write(`${packageJson.version}\n`); + return 0; + } + + // handle invalid args + if (args[0] !== "validate") { + io.stderr.write(usage); + return 2; + } + + // run command + return runValidateCommand(args.slice(1), io, rootDir); + } catch (error) { + if ( + error instanceof TypeError && + (error as TypeError & { code?: string }).code?.startsWith( + "ERR_PARSE_ARGS" + ) + ) { + io.stderr.write(`${error.message}\n\n${usage}`); + return 2; + } + + io.stderr.write( + `Validation could not be completed: ${error instanceof Error ? error.message : String(error)}\n` + ); + return 2; + } +}; + +if ( + process.argv[1] && + fs.realpathSync(process.argv[1]) === + fs.realpathSync(fileURLToPath(import.meta.url)) +) { + process.exitCode = runCli(process.argv.slice(2)); +} diff --git a/packages/visual-editor/src/index.ts b/packages/visual-editor/src/index.ts index e1c407667..1655dc14f 100644 --- a/packages/visual-editor/src/index.ts +++ b/packages/visual-editor/src/index.ts @@ -4,5 +4,5 @@ export * from "./types/index.ts"; export * from "./editor/index.ts"; export * from "./components/index.ts"; export * from "./fields/index.ts"; -export type { SectionConfig } from "./sectionLibrary.ts"; +export type { SectionConfig } from "./types/sectionLibrary.ts"; export { LocalEditorShell } from "./local-editor/LocalEditorShell.tsx"; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts new file mode 100644 index 000000000..04faccd2d --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts @@ -0,0 +1,141 @@ +import { builtinModules } from "node:module"; +import type { ValidationIssue } from "../../types.ts"; + +// TODO: Review this deny list +const deniedPackages = new Set([ + "child-process-promise", + "cross-spawn", + "execa", + "shelljs", + "@hapi/hapi", + "@nestjs/core", + "express", + "fastify", + "koa", + "electron", + "playwright", + "playwright-core", + "puppeteer", + "puppeteer-core", + "selenium-webdriver", + "chokidar", + "fs-extra", + "glob", + "rimraf", + "better-sqlite3", + "ioredis", + "mongoose", + "mysql2", + "pg", + "redis", + "sqlite3", +]); + +export type ImportSyntaxKind = + | "import" + | "export" + | "require" + | "dynamic-import"; + +export type ImportReference = { + moduleSpecifier: string; + syntaxKind: ImportSyntaxKind; + isTypeOnly: boolean; + filePath: string; + line: number; + column: number; +}; + +type ClassifiedImport = + | { kind: "local" } + | { kind: "node"; moduleName: string } + | { kind: "package"; packageName: string }; + +const nodeBuiltins = new Set( + builtinModules.map((name) => + name.startsWith("node:") ? name.slice(5) : name + ) +); + +const classifyImport = (moduleSpecifier: string): ClassifiedImport => { + if ( + moduleSpecifier.startsWith(".") || + moduleSpecifier.startsWith("/") || + moduleSpecifier.startsWith("#") + ) { + return { kind: "local" }; + } + const withoutNodePrefix = moduleSpecifier.startsWith("node:") + ? moduleSpecifier.slice(5) + : moduleSpecifier; + const nodeRoot = [...nodeBuiltins].find( + (name) => + withoutNodePrefix === name || withoutNodePrefix.startsWith(`${name}/`) + ); + if (nodeRoot) { + return { kind: "node", moduleName: withoutNodePrefix }; + } + const parts = moduleSpecifier.split("/"); + return { + kind: "package", + packageName: moduleSpecifier.startsWith("@") + ? parts.slice(0, 2).join("/") + : parts[0], + }; +}; + +const createIssue = ( + reference: ImportReference, + rule: string, + message: string +): ValidationIssue => ({ + category: "code", + filePath: reference.filePath, + line: reference.line, + column: reference.column, + message, + rule, +}); + +export const evaluateForNodeBuiltins = ( + reference: ImportReference +): ValidationIssue[] => { + if (reference.isTypeOnly) { + return []; + } + const classifiedImport = classifyImport(reference.moduleSpecifier); + return classifiedImport.kind === "node" + ? [ + createIssue( + reference, + "imports/node-builtin", + `Node built-in module '${reference.moduleSpecifier}' cannot run in Section Library browser code.` + ), + ] + : []; +}; + +export const evaluateDeniedPackages = ( + reference: ImportReference +): ValidationIssue[] => { + if (reference.isTypeOnly) { + return []; + } + + const classifiedImport = classifyImport(reference.moduleSpecifier); + + if ( + classifiedImport.kind !== "package" || + !deniedPackages.has(classifiedImport.packageName) + ) { + return []; + } + + return [ + createIssue( + reference, + "imports/denied-package", + `Package '${classifiedImport.packageName}' is not permitted in Section Library code.` + ), + ]; +}; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts new file mode 100644 index 000000000..728af5209 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts @@ -0,0 +1,178 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { Project } from "ts-morph"; +import { createTempRoot } from "../../testUtils.ts"; +import { + evaluateDeniedPackages, + evaluateForNodeBuiltins, +} from "./importRules.ts"; +import { extractImportReferences, validateCode } from "./validateCode.ts"; + +describe("extractImportReferences", () => { + it("extracts supported import syntax", () => { + const sourceFile = createSourceFile([ + 'import value from "koa";', + 'export { readFile } from "node:fs/promises";', + 'require("express/router");', + 'import("playwright-core/lib");', + ]); + + expect( + extractImportReferences(sourceFile, "source.ts").map( + ({ moduleSpecifier, syntaxKind }) => [moduleSpecifier, syntaxKind] + ) + ).toEqual([ + ["koa", "import"], + ["node:fs/promises", "export"], + ["express/router", "require"], + ["playwright-core/lib", "dynamic-import"], + ]); + }); + + it("marks type-only imports and exports as isTypeOnly", () => { + const sourceFile = createSourceFile([ + 'import type { Stats } from "node:fs";', + 'export type { Stats } from "node:fs";', + ]); + + expect( + extractImportReferences(sourceFile, "source.ts").map( + ({ isTypeOnly }) => isTypeOnly + ) + ).toEqual([true, true]); + }); + + it("marks mixed imports as runtime imports", () => { + const sourceFile = createSourceFile([ + 'import { type PathLike, readFile } from "fs";', + ]); + + expect(extractImportReferences(sourceFile, "source.ts")[0]).toMatchObject({ + isTypeOnly: false, + line: 1, + column: 41, + }); + }); +}); + +describe("evaluateForNodeBuiltins", () => { + it("rejects a runtime Node built-in", () => { + expect(evaluateForNodeBuiltins(reference("node:path"))).toMatchObject([ + { rule: "imports/node-builtin" }, + ]); + }); + + it("allows a type-only Node built-in", () => { + expect( + evaluateForNodeBuiltins({ ...reference("node:path"), isTypeOnly: true }) + ).toEqual([]); + }); +}); + +describe("evaluateDeniedPackages", () => { + it("rejects a denied package subpath", () => { + expect( + evaluateDeniedPackages(reference("@nestjs/core/testing")) + ).toMatchObject([{ rule: "imports/denied-package" }]); + }); + + it("allows a package with a similar name", () => { + expect(evaluateDeniedPackages(reference("expressive/subpath"))).toEqual([]); + }); + + it("allows a type-only denied package", () => { + expect( + evaluateDeniedPackages({ ...reference("express"), isTypeOnly: true }) + ).toEqual([]); + }); +}); + +describe("validateCode", () => { + it("returns no issues when the library directory is missing", () => { + expect(validateCode(createTempRoot())).toEqual([]); + }); + + it("ignores non-source files", () => { + const rootDir = createTempRoot(); + writeSourceFile(rootDir, "Unsafe.txt", "eval(code);"); + + expect(validateCode(rootDir)).toEqual([]); + }); + + it("excludes every .generated directory", () => { + const rootDir = createTempRoot(); + writeSourceFile(rootDir, ".generated/Unsafe.ts", "eval(code);"); + writeSourceFile(rootDir, "nested/.generated/Unsafe.ts", "eval(code);"); + + expect(validateCode(rootDir)).toEqual([]); + }); + + it.each([ + ["innerHTML assignment", "node.innerHTML = html;", "xss/html-assignment"], + [ + "insertAdjacentHTML", + "node.insertAdjacentHTML('beforeend', html);", + "xss/insert-adjacent-html", + ], + ["document.write", "document.write(html);", "xss/document-write"], + ["eval", "eval(code);", "xss/eval"], + ["Function constructor", "new Function(code);", "xss/function-constructor"], + ["string timer", "setTimeout('run()', 1);", "xss/string-timer"], + [ + "dangerouslySetInnerHTML", + "export const View = () =>
;", + "xss/dangerously-set-inner-html", + ], + [ + "javascript URL", + 'export const View = () => ;', + "xss/javascript-url", + ], + ])("reports exactly one issue for %s", (_name, source, rule) => { + const rootDir = createTempRoot(); + writeSourceFile(rootDir, "Unsafe.tsx", source); + + const issues = validateCode(rootDir); + + expect(issues).toHaveLength(1); + expect(issues[0]).toMatchObject({ rule }); + }); + + it("ignores representative safe XSS lookalikes", () => { + const rootDir = createTempRoot(); + writeSourceFile( + rootDir, + "Safe.tsx", + [ + "const safeInnerHTML = 'ok';", + "setTimeout(() => run(), 1);", + 'export const View = () => ;', + ].join("\n") + ); + + expect(validateCode(rootDir)).toEqual([]); + }); +}); + +const createSourceFile = (lines: string[]) => { + const project = new Project({ useInMemoryFileSystem: true }); + return project.createSourceFile("/source.ts", lines.join("\n")); +}; + +const reference = (moduleSpecifier: string) => ({ + moduleSpecifier, + syntaxKind: "import" as const, + isTypeOnly: false, + filePath: "source.ts", + line: 1, + column: 1, +}); + +const writeSourceFile = ( + rootDir: string, + relativePath: string, + source: string +): void => { + fs.outputFileSync(path.join(rootDir, "src", "library", relativePath), source); +}; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts new file mode 100644 index 000000000..564781d7c --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts @@ -0,0 +1,184 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { Node, Project, type SourceFile, SyntaxKind } from "ts-morph"; +import { + evaluateDeniedPackages, + evaluateForNodeBuiltins, + type ImportReference, + type ImportSyntaxKind, +} from "./importRules.ts"; +import { xssRules } from "./xssRules.ts"; +import type { ValidationIssue } from "../../types.ts"; + +/** + * validateCode validates the section library's source code for obvious bad patterns. + * The goal is a fast-running, best-effort security check rather than a comprehensive scan. + * YextCI will perform the authoritative code scanning after upload. + */ +export const validateCode = (rootDir: string): ValidationIssue[] => { + const project = new Project({ + compilerOptions: { allowJs: true, jsx: 4 }, + skipAddingFilesFromTsConfig: true, + }); + + const sourcePaths = discoverSourceFiles(path.join(rootDir, "src", "library")); + + const sourceFiles = sourcePaths.map((sourcePath) => + project.createSourceFile(sourcePath, fs.readFileSync(sourcePath, "utf8"), { + overwrite: true, + }) + ); + + return sourceFiles.flatMap((sourceFile) => { + const filePath = path.relative(rootDir, sourceFile.getFilePath()); + const references = extractImportReferences(sourceFile, filePath); + return [ + ...references.flatMap((reference) => [ + ...evaluateForNodeBuiltins(reference), + ...evaluateDeniedPackages(reference), + ]), + ...findXssIssues(sourceFile, filePath), + ]; + }); +}; + +/** discoverSourceFiles gathers all js/jsx/ts/tsx file under the give directory. */ +const discoverSourceFiles = (directory: string): string[] => { + if (!fs.existsSync(directory)) { + return []; + } + return fs + .readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => left.name.localeCompare(right.name)) + .flatMap((entry): string[] => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return entry.name === ".generated" + ? [] + : discoverSourceFiles(entryPath); + } + return entry.isFile() && /\.(?:js|jsx|ts|tsx)$/.test(entry.name) + ? [entryPath] + : []; + }); +}; + +export const extractImportReferences = ( + sourceFile: SourceFile, + filePath: string +): ImportReference[] => { + const references: ImportReference[] = []; + + const addReference = ( + moduleSpecifier: Node, + syntaxKind: ImportSyntaxKind, + isTypeOnly: boolean + ): void => { + const value = getLiteralString(moduleSpecifier); + if (value === undefined) { + return; + } + const position = sourceFile.getLineAndColumnAtPos( + moduleSpecifier.getStart() + ); + references.push({ + moduleSpecifier: value, + syntaxKind, + isTypeOnly, + filePath, + line: position.line, + column: position.column, + }); + }; + + sourceFile.forEachDescendant((node) => { + // Extract static imports, including side-effect and type-only imports. + if (Node.isImportDeclaration(node)) { + const clause = node.getImportClause(); + const namedImports = clause?.getNamedImports() ?? []; + const runtime = + !clause || + (!clause.isTypeOnly() && + (!!clause.getDefaultImport() || + !!clause.getNamespaceImport() || + namedImports.length === 0 || + namedImports.some((specifier) => !specifier.isTypeOnly()))); + + addReference(node.getModuleSpecifier(), "import", !runtime); + return; + } + + // Extract module references from re-export declarations. + if (Node.isExportDeclaration(node) && node.getModuleSpecifier()) { + const runtime = + !node.isTypeOnly() && + (node.getNamedExports().length === 0 || + node.getNamedExports().some((specifier) => !specifier.isTypeOnly())); + + addReference(node.getModuleSpecifier()!, "export", !runtime); + return; + } + + // Extract CommonJS require calls and dynamic imports. + if (Node.isCallExpression(node)) { + const expression = node.getExpression(); + const argument = node.getArguments()[0]; + + if ( + argument && + Node.isIdentifier(expression) && + expression.getText() === "require" + ) { + addReference(argument, "require", false); + } else if ( + argument && + expression.getKind() === SyntaxKind.ImportKeyword + ) { + addReference(argument, "dynamic-import", false); + } + } + }); + + return references; +}; + +const findXssIssues = ( + sourceFile: SourceFile, + filePath: string +): ValidationIssue[] => { + const issues: ValidationIssue[] = []; + const addIssue = (node: Node, rule: string, message: string): void => { + const position = sourceFile.getLineAndColumnAtPos(node.getStart()); + issues.push({ + category: "code", + filePath, + line: position.line, + column: position.column, + message, + rule, + }); + }; + + sourceFile.forEachDescendant((node) => { + for (const rule of xssRules) { + const message = rule.evaluate(node); + if (message) { + addIssue(node, rule.name, message); + } + } + }); + + return issues; +}; + +const getLiteralString = (node: Node | undefined): string | undefined => { + if (!node) { + return; + } + if ( + Node.isStringLiteral(node) || + Node.isNoSubstitutionTemplateLiteral(node) + ) { + return node.getLiteralValue(); + } +}; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts new file mode 100644 index 000000000..96ef09d60 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts @@ -0,0 +1,140 @@ +import { Node, SyntaxKind } from "ts-morph"; + +type XssRule = { + name: string; + evaluate(node: Node): string | undefined; +}; + +const assignmentOperators = new Set([ + SyntaxKind.EqualsToken, + SyntaxKind.PlusEqualsToken, + SyntaxKind.MinusEqualsToken, + SyntaxKind.AsteriskEqualsToken, + SyntaxKind.AsteriskAsteriskEqualsToken, + SyntaxKind.SlashEqualsToken, + SyntaxKind.PercentEqualsToken, + SyntaxKind.LessThanLessThanEqualsToken, + SyntaxKind.GreaterThanGreaterThanEqualsToken, + SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, + SyntaxKind.AmpersandEqualsToken, + SyntaxKind.BarEqualsToken, + SyntaxKind.CaretEqualsToken, + SyntaxKind.BarBarEqualsToken, + SyntaxKind.AmpersandAmpersandEqualsToken, + SyntaxKind.QuestionQuestionEqualsToken, +]); + +export const xssRules: XssRule[] = [ + { + name: "xss/dangerously-set-inner-html", + evaluate: (node) => + Node.isJsxAttribute(node) && + node.getNameNode().getText() === "dangerouslySetInnerHTML" + ? "dangerouslySetInnerHTML is not permitted in Section Library code." + : undefined, + }, + { + name: "xss/html-assignment", + evaluate: (node) => { + if (!Node.isBinaryExpression(node)) { + return; + } + const left = node.getLeft(); + return assignmentOperators.has(node.getOperatorToken().getKind()) && + Node.isPropertyAccessExpression(left) && + ["innerHTML", "outerHTML"].includes(left.getName()) + ? `Assignment to ${left.getName()} is not permitted.` + : undefined; + }, + }, + { + name: "xss/insert-adjacent-html", + evaluate: (node) => { + if (!Node.isCallExpression(node)) { + return; + } + const expression = node.getExpression(); + return Node.isPropertyAccessExpression(expression) && + expression.getName() === "insertAdjacentHTML" + ? "insertAdjacentHTML() is not permitted." + : undefined; + }, + }, + { + name: "xss/document-write", + evaluate: (node) => { + if (!Node.isCallExpression(node)) { + return; + } + const expression = node.getExpression(); + return Node.isPropertyAccessExpression(expression) && + Node.isIdentifier(expression.getExpression()) && + expression.getExpression().getText() === "document" && + ["write", "writeln"].includes(expression.getName()) + ? `document.${expression.getName()}() is not permitted.` + : undefined; + }, + }, + { + name: "xss/eval", + evaluate: (node) => { + if (!Node.isCallExpression(node)) { + return; + } + const expression = node.getExpression(); + return Node.isIdentifier(expression) && expression.getText() === "eval" + ? "Direct eval() is not permitted." + : undefined; + }, + }, + { + name: "xss/function-constructor", + evaluate: (node) => + Node.isNewExpression(node) && + Node.isIdentifier(node.getExpression()) && + node.getExpression().getText() === "Function" + ? "new Function() is not permitted." + : undefined, + }, + { + name: "xss/string-timer", + evaluate: (node) => { + if (!Node.isCallExpression(node)) { + return; + } + const expression = node.getExpression(); + return Node.isIdentifier(expression) && + ["setTimeout", "setInterval"].includes(expression.getText()) && + getLiteralString(node.getArguments()[0]) !== undefined + ? `${expression.getText()}() must not receive a string argument.` + : undefined; + }, + }, + { + name: "xss/javascript-url", + evaluate: (node) => { + if (!Node.isJsxAttribute(node)) { + return; + } + const initializer = node.getInitializer(); + const expression = Node.isJsxExpression(initializer) + ? initializer.getExpression() + : initializer; + return getLiteralString(expression) + ?.trim() + .toLowerCase() + .startsWith("javascript:") + ? "Literal javascript: URLs are not permitted in JSX attributes." + : undefined; + }, + }, +]; + +const getLiteralString = (node: Node | undefined): string | undefined => { + if ( + Node.isStringLiteral(node) || + Node.isNoSubstitutionTemplateLiteral(node) + ) { + return node.getLiteralValue(); + } +}; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.test.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.test.ts new file mode 100644 index 000000000..2bd3f96b0 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.test.ts @@ -0,0 +1,125 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { createTempRoot } from "../../testUtils.ts"; +import { validateLibraryMetadata } from "./libraryMetadata.ts"; + +describe("validateLibraryMetadata", () => { + it("reports a missing library.json", () => { + expect(validateLibraryMetadata(createTempRoot()).issues).toMatchObject([ + { rule: "file/missing" }, + ]); + }); + + it("reports malformed JSON", () => { + const rootDir = createTempRoot(); + writeLibraryJsonSource(rootDir, "{"); + + expect(validateLibraryMetadata(rootDir).issues).toMatchObject([ + { rule: "json/invalid" }, + ]); + }); + + it.each([null, [], "library"])( + "reports a non-object JSON root for %j", + (value) => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, value); + + expect(validateLibraryMetadata(rootDir).issues).toMatchObject([ + { rule: "json/object" }, + ]); + } + ); + + it("reports an unsupported schema version", () => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, validMetadata({ schemaVersion: 2 })); + + expect(validateLibraryMetadata(rootDir).issues).toContainEqual( + expect.objectContaining({ rule: "schema/version" }) + ); + }); + + it.each(["id", "displayName", "description"] as const)( + "reports a non-string %s", + (field) => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, validMetadata({ [field]: 42 })); + + expect(validateLibraryMetadata(rootDir).issues).toContainEqual( + expect.objectContaining({ rule: `field/${field}/type` }) + ); + } + ); + + it.each(["id", "displayName"] as const)("reports an empty %s", (field) => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, validMetadata({ [field]: " " })); + + expect(validateLibraryMetadata(rootDir).issues).toContainEqual( + expect.objectContaining({ rule: `field/${field}/empty` }) + ); + }); + + it("reports a description longer than 1,024 characters", () => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, validMetadata({ description: "a".repeat(1025) })); + + expect(validateLibraryMetadata(rootDir).issues).toContainEqual( + expect.objectContaining({ rule: "field/description/length" }) + ); + }); + + it("reports an unsafe id", () => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, validMetadata({ id: "unsafe id" })); + + expect(validateLibraryMetadata(rootDir).issues).toContainEqual( + expect.objectContaining({ rule: "field/id/safe" }) + ); + }); + + it("aggregates independent field errors", () => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, { + schemaVersion: 1, + id: 42, + displayName: "", + description: null, + }); + + expect(validateLibraryMetadata(rootDir).issues).toHaveLength(3); + }); + + it("returns valid metadata", () => { + const rootDir = createTempRoot(); + writeLibraryJson(rootDir, validMetadata({ futureField: true })); + + expect(validateLibraryMetadata(rootDir)).toEqual({ + issues: [], + metadata: validMetadata(), + }); + }); +}); + +const validMetadata = ( + overrides: Record = {} +): Record => ({ + schemaVersion: 1, + id: "safe-id", + displayName: "Library", + description: "Description", + ...overrides, +}); + +const writeLibraryJson = (rootDir: string, value: unknown): void => { + fs.outputJsonSync(libraryJsonPath(rootDir), value); +}; + +const writeLibraryJsonSource = (rootDir: string, value: string): void => { + fs.outputFileSync(libraryJsonPath(rootDir), value); +}; + +const libraryJsonPath = (rootDir: string): string => + path.join(rootDir, "src", "library", "library.json"); diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.ts new file mode 100644 index 000000000..7e90e7ce1 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/metadata/libraryMetadata.ts @@ -0,0 +1,102 @@ +import path from "node:path"; +import fs from "fs-extra"; +import type { LibraryMetadata } from "../../../../types/sectionLibrary.ts"; +import type { ValidationIssue } from "../../types.ts"; + +const safeIdPattern = /^[A-Za-z0-9_-]{1,64}$/; +const descriptionMaxLength = 1024; + +/** validateLibraryMetadata validates that the library.json has the required fields. */ +export const validateLibraryMetadata = ( + rootDir: string +): { issues: ValidationIssue[]; metadata?: LibraryMetadata } => { + const filePath = path.join(rootDir, "src", "library", "library.json"); + const relativePath = path.relative(rootDir, filePath); + + const issues: ValidationIssue[] = []; + const addIssue = (rule: string, message: string): void => { + issues.push({ category: "api", filePath: relativePath, message, rule }); + }; + + if (!fs.existsSync(filePath)) { + addIssue("file/missing", "Library metadata file does not exist."); + return { issues }; + } + + let metadataFile: unknown; + try { + metadataFile = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch (error) { + addIssue( + "json/invalid", + `Library metadata is not valid JSON: ${error instanceof Error ? error.message : String(error)}` + ); + return { issues }; + } + + if ( + !metadataFile || + typeof metadataFile !== "object" || + Array.isArray(metadataFile) + ) { + addIssue("json/object", "Library metadata must be a JSON object."); + return { issues }; + } + + const metadataJson = metadataFile as Record; + // If we mutate the metadata schema in the future, we will introduce a new version + // so that existing files continue to be validated against the version 1 schema. + if (metadataJson.schemaVersion !== 1) { + addIssue("schema/version", "schemaVersion must equal 1."); + } + + const libraryMetadataValues = new Map(); + // Confirm presence of required fields + for (const field of ["id", "displayName"] as const) { + const fieldValue = metadataJson[field]; + if (typeof fieldValue !== "string") { + addIssue(`field/${field}/type`, `${field} must be a string.`); + } else if (!fieldValue.trim()) { + addIssue(`field/${field}/empty`, `${field} must not be empty.`); + } else { + libraryMetadataValues.set(field, fieldValue); + } + } + + const description = metadataJson.description; + if (description === undefined) { + libraryMetadataValues.set("description", ""); + } else if (typeof description !== "string") { + addIssue("field/description/type", "description must be a string."); + } else if (Array.from(description).length > descriptionMaxLength) { + addIssue( + "field/description/length", + `description must be at most ${descriptionMaxLength} characters.` + ); + } else { + libraryMetadataValues.set("description", description); + } + + // Validate id + const id = libraryMetadataValues.get("id"); + if (id && !safeIdPattern.test(id)) { + addIssue( + "field/id/safe", + "id must be at most 64 characters and may contain only letters, numbers, underscores, and hyphens." + ); + } + + if (issues.length > 0) { + return { issues }; + } + + return { + issues, + metadata: { + schemaVersion: 1, + id: libraryMetadataValues.get("id")!, + displayName: libraryMetadataValues.get("displayName")!, + description: libraryMetadataValues.get("description")!, + }, + }; +}; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.test.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.test.ts new file mode 100644 index 000000000..2eb1b23d0 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.test.ts @@ -0,0 +1,422 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { createTempRoot } from "../../testUtils.ts"; +import { validateSectionLibraryStructure } from "./structure.ts"; + +describe("validateSectionLibraryStructure", () => { + it("reports a section directory", () => { + const rootDir = createValidLibrary(); + fs.ensureDirSync(sectionPath(rootDir, "Nested")); + + expectRules(rootDir, "sections/directory"); + }); + + it("reports an unsupported section extension", () => { + const rootDir = createValidLibrary(); + fs.outputFileSync(sectionPath(rootDir, "Notes.txt"), "notes"); + + expectRules(rootDir, "sections/extension"); + }); + + it("reports an invalid section component name", () => { + const rootDir = createValidLibrary(); + fs.outputFileSync(sectionPath(rootDir, "Bad name.tsx"), validSectionSource); + + expectRules(rootDir, "sections/component-name"); + }); + + it("reports an invalid section id", () => { + const rootDir = createValidLibrary(); + writeSection( + rootDir, + validSectionSource.replace('id: "hero"', 'id: "bad id"') + ); + + expectRules(rootDir, "sections/id"); + }); + + it("reports an invalid section id before invalid page set types", () => { + const rootDir = createValidLibrary(); + writeSection( + rootDir, + validSectionSource + .replace('id: "hero"', 'id: "bad id"') + .replace('pageSetTypes: ["ENTITY"]', 'pageSetTypes: ["DOODLE"]') + ); + + const issues = validateSectionLibraryStructure(rootDir).issues; + expect(issues[0]).toMatchObject({ + rule: "sections/id", + message: expect.stringContaining("config must define a valid id"), + }); + expect(issues).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: expect.stringContaining( + "config.pageSetTypes must contain only" + ), + }), + ]) + ); + }); + + it("reports invalid section frontmatter", () => { + const rootDir = createValidLibrary(); + writeSection(rootDir, "export const Hero = () => null;"); + + expectRules(rootDir, "sections/frontmatter"); + }); + + it("ignores .gitkeep", () => { + const rootDir = createValidLibrary(); + fs.outputFileSync(sectionPath(rootDir, ".gitkeep"), ""); + + expect(validateSectionLibraryStructure(rootDir).issues).toEqual([]); + }); + it("reports a missing layouts directory", () => { + const rootDir = createValidLibrary(); + fs.removeSync(layoutsPath(rootDir)); + + expectRules(rootDir, "layouts/missing"); + }); + + it("reports invalid layout cardinality", () => { + const rootDir = createValidLibrary(); + fs.removeSync(layoutPath(rootDir, "locator-layout")); + + expectRules(rootDir, "layouts/cardinality"); + }); + + it("reports a missing layout JSON file", () => { + const rootDir = createValidLibrary(); + fs.removeSync(layoutFilePath(rootDir, "entity-layout", "metadata.json")); + + expectRules(rootDir, "json/missing"); + }); + + it("reports malformed layout JSON", () => { + const rootDir = createValidLibrary(); + fs.outputFileSync( + layoutFilePath(rootDir, "entity-layout", "defaultLayout.json"), + "{" + ); + + expectRules(rootDir, "json/invalid"); + }); + + it("reports a non-object layout JSON value", () => { + const rootDir = createValidLibrary(); + fs.outputJsonSync( + layoutFilePath(rootDir, "entity-layout", "defaultLayout.json"), + [] + ); + + expectRules(rootDir, "json/invalid"); + }); + + it("reports an unsupported page set type", () => { + const rootDir = createValidLibrary(); + writeLayoutMetadata(rootDir, "entity-layout", { + id: "entity-layout", + displayName: "Entity", + pageSetType: "UNKNOWN", + }); + + expectRules(rootDir, "layouts/metadata"); + }); + + it("reports a missing required metadata string", () => { + const rootDir = createValidLibrary(); + writeLayoutMetadata(rootDir, "directory-layout", { + id: "directory-layout", + pageSetType: "DIRECTORY", + }); + + expectRules(rootDir, "layouts/metadata"); + }); + + it("reports an invalid optional metadata list", () => { + const rootDir = createValidLibrary(); + writeLayoutMetadata(rootDir, "entity-layout", { + ...entityMetadata, + vertical: ["UNKNOWN"], + }); + + expectRules(rootDir, "layouts/metadata"); + }); + + it("reports an invalid layout id", () => { + const rootDir = createValidLibrary(); + writeLayoutMetadata(rootDir, "directory-layout", { + id: "bad id", + displayName: "Directory", + pageSetType: "DIRECTORY", + }); + + expectRules(rootDir, "layouts/metadata"); + }); + + it("reports a reserved layout id", () => { + const rootDir = createValidLibrary(); + writeLayoutMetadata(rootDir, "directory-layout", { + id: "directory", + displayName: "Directory", + pageSetType: "DIRECTORY", + }); + + expectRules(rootDir, "layouts/metadata"); + }); + + it("reports an invalid shared component registry", () => { + const rootDir = createValidLibrary(); + fs.outputFileSync(registryPath(rootDir), "export const nope = [];"); + + expectRules(rootDir, "shared/invalid"); + }); + + it("requires a shared registry for non-entity layouts", () => { + const rootDir = createValidLibrary(); + fs.removeSync(registryPath(rootDir)); + + expectRules(rootDir, "shared/missing"); + }); + + it("reports an invalid shared component id", () => { + const rootDir = createValidLibrary(); + writeRegistry(rootDir, [{ id: "bad id", pageSetTypes: ["DIRECTORY"] }]); + + expectRules(rootDir, "components/id"); + }); + + it("reports a duplicate component id", () => { + const rootDir = createValidLibrary(); + writeRegistry(rootDir, [{ id: "hero", pageSetTypes: ["DIRECTORY"] }]); + + expectRules(rootDir, "components/duplicate"); + }); + + it("reports a missing component instance id", () => { + const rootDir = createValidLibrary(); + writeDefaultLayout(rootDir, "entity-layout", { + content: [{ type: "hero", props: {} }], + zones: {}, + }); + + expectRules(rootDir, "layouts/instance-id"); + }); + + it("reports a duplicate component instance id", () => { + const rootDir = createValidLibrary(); + writeDefaultLayout(rootDir, "entity-layout", { + content: [ + { type: "hero", props: { id: "duplicate" } }, + { type: "hero", props: { id: "duplicate" } }, + ], + zones: {}, + }); + + expectRules(rootDir, "layouts/duplicate-instance-id"); + }); + + it("reports a missing component reference", () => { + const rootDir = createValidLibrary(); + writeDefaultLayout(rootDir, "entity-layout", { + content: [{ type: "missing", props: { id: "missing-1" } }], + zones: {}, + }); + + expectRules(rootDir, "layouts/reference"); + }); + + it("reports a page-set-incompatible component reference", () => { + const rootDir = createValidLibrary(); + writeDefaultLayout(rootDir, "directory-layout", { + content: [{ type: "hero", props: { id: "hero-1" } }], + zones: {}, + }); + + expectRules(rootDir, "layouts/reference"); + }); + + it.each([ + [ + "zones", + { + content: [], + zones: { root: [{ type: "missing", props: { id: "zone-1" } }] }, + }, + ], + [ + "slots", + { + content: [ + { + type: "MainContent", + props: { + id: "main", + slots: { + header: [{ type: "missing", props: { id: "slot-1" } }], + }, + }, + }, + ], + zones: {}, + }, + ], + [ + "MainContent", + { + content: [ + { + type: "MainContent", + props: { + id: "main", + content: [{ type: "missing", props: { id: "content-1" } }], + }, + }, + ], + zones: {}, + }, + ], + ])("validates components nested in %s", (_location, layout) => { + const rootDir = createValidLibrary(); + writeDefaultLayout(rootDir, "entity-layout", layout); + + expectRules(rootDir, "layouts/reference"); + }); + + it("returns the resolved structure for a valid library", () => { + const result = validateSectionLibraryStructure(createValidLibrary()); + + expect(result.issues).toEqual([]); + expect(result.structure).toMatchObject({ + sections: [{ id: "hero", componentName: "Hero" }], + sharedComponents: [{ id: "directory-header" }], + sharedRootPageSetTypes: ["DIRECTORY", "LOCATOR"], + layouts: [ + { metadata: { pageSetType: "DIRECTORY" } }, + { metadata: { pageSetType: "ENTITY" } }, + { metadata: { pageSetType: "LOCATOR" } }, + ], + }); + }); +}); + +const validSectionSource = [ + "export const Hero = () => null;", + 'export const config: SectionConfig = { id: "hero", displayName: "Hero", description: "Hero section", pageSetTypes: ["ENTITY"] };', +].join("\n"); + +const entityMetadata = { + id: "entity-layout", + displayName: "Entity", + previewImageUrl: "https://example.com/entity.png", + vertical: ["RETAIL"], + purpose: ["LOCATION"], + pageSetType: "ENTITY", +}; + +const createValidLibrary = (): string => { + const rootDir = createTempRoot(); + writeSection(rootDir, validSectionSource); + writeLayout(rootDir, "entity-layout", entityMetadata); + writeLayout(rootDir, "directory-layout", { + id: "directory-layout", + displayName: "Directory", + pageSetType: "DIRECTORY", + }); + writeLayout(rootDir, "locator-layout", { + id: "locator-layout", + displayName: "Locator", + pageSetType: "LOCATOR", + }); + writeRegistry(rootDir, [ + { id: "directory-header", pageSetTypes: ["DIRECTORY"] }, + ]); + return rootDir; +}; + +const writeSection = (rootDir: string, source: string): void => { + fs.outputFileSync(sectionPath(rootDir, "Hero.tsx"), source); +}; + +const writeLayout = ( + rootDir: string, + directoryName: string, + metadata: Record +): void => { + writeLayoutMetadata(rootDir, directoryName, metadata); + writeDefaultLayout(rootDir, directoryName, { content: [], zones: {} }); +}; + +const writeLayoutMetadata = ( + rootDir: string, + directoryName: string, + metadata: Record +): void => { + fs.outputJsonSync( + layoutFilePath(rootDir, directoryName, "metadata.json"), + metadata + ); +}; + +const writeDefaultLayout = ( + rootDir: string, + directoryName: string, + layout: unknown +): void => { + fs.outputJsonSync( + layoutFilePath(rootDir, directoryName, "defaultLayout.json"), + layout + ); +}; + +const writeRegistry = ( + rootDir: string, + components: { id: string; pageSetTypes: string[] }[] +): void => { + const metadata = components + .map( + ({ id, pageSetTypes }) => + `{ id: ${JSON.stringify(id)}, pageSetTypes: ${JSON.stringify(pageSetTypes)} }` + ) + .join(",\n"); + fs.outputFileSync( + registryPath(rootDir), + [ + `export const sharedComponentMetadata = [${metadata}];`, + "export const sharedComponentConfigs = {};", + "export const sharedRootConfigs = {};", + "export const sharedRootAllowedComponentIds = {};", + 'export const sharedRootPageSetTypes = ["DIRECTORY", "LOCATOR"];', + ].join("\n") + ); +}; + +const expectRules = (rootDir: string, ...rules: string[]): void => { + const actualRules = validateSectionLibraryStructure(rootDir).issues.map( + (issue) => issue.rule + ); + for (const rule of rules) { + expect(actualRules).toContain(rule); + } +}; + +const sectionPath = (rootDir: string, name: string): string => + path.join(rootDir, "src", "library", "sections", name); + +const layoutsPath = (rootDir: string): string => + path.join(rootDir, "src", "library", "layouts"); + +const layoutPath = (rootDir: string, name: string): string => + path.join(layoutsPath(rootDir), name); + +const layoutFilePath = ( + rootDir: string, + directoryName: string, + fileName: string +): string => path.join(layoutPath(rootDir, directoryName), fileName); + +const registryPath = (rootDir: string): string => + path.join(rootDir, "src", "library", "shared", "componentRegistry.ts"); diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts new file mode 100644 index 000000000..c3e79d060 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts @@ -0,0 +1,503 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { + purposes, + verticals, + type LayoutMetadata, + type PageSetType, + type SectionLibraryLayout, + type SharedHiddenPuckComponent, +} from "../../../../types/sectionLibrary.ts"; +import { extractSectionConfigFrontmatter } from "../../../../vite-plugin/section-library/sectionFrontmatter.ts"; +import { readSharedComponentRegistry } from "../../../../vite-plugin/section-library/sharedComponentRegistry.ts"; +import type { + ResolvedSection, + ResolvedSectionLibraryStructure, + ValidationIssue, +} from "../../types.ts"; + +const reservedLayoutIds = new Set([ + "main", // reserved for backwards compatibility + "directory", // must be the name of the directory layout + "locator", // must be the name of the locator layout + "edit", // reserved for the editor static page +]); +// TODO: Match API-specific resource-ID and length behavior when it is finalized. +const safeIdPattern = /^[A-Za-z0-9_-]+$/; +type ParsedLayout = SectionLibraryLayout & { defaultLayoutPath: string }; + +/** validateSectionLibraryStructure validates that repo structure is correct for Section Libraries. */ +export const validateSectionLibraryStructure = ( + rootDir: string +): { + issues: ValidationIssue[]; + structure?: ResolvedSectionLibraryStructure; +} => { + const libraryDirectory = path.join(rootDir, "src", "library"); + + const issues: ValidationIssue[] = []; + const addIssue = (filePath: string, rule: string, message: string): void => { + issues.push({ + category: "structure", + filePath: path.relative(rootDir, filePath) || ".", + message: cleanMessage(message, rootDir), + rule, + }); + }; + + const sections = readSections(rootDir, libraryDirectory, addIssue); + const registryPath = path.join( + libraryDirectory, + "shared", + "componentRegistry.ts" + ); + let sharedRegistry: ReturnType; + try { + sharedRegistry = readSharedComponentRegistry(registryPath); + } catch (error) { + addIssue(registryPath, "shared/invalid", errorMessage(error)); + } + + const sharedComponents = sharedRegistry?.components ?? []; + const sharedRootPageSetTypes = sharedRegistry?.rootPageSetTypes ?? []; + const parsedLayouts = readLayouts(libraryDirectory, addIssue); + const layouts = parsedLayouts.map( + ({ defaultLayoutPath: _, ...layout }) => layout + ); + + if ( + !sharedRegistry && + !fs.existsSync(registryPath) && + parsedLayouts.some((layout) => layout.metadata.pageSetType !== "ENTITY") + ) { + addIssue( + registryPath, + "shared/missing", + "A shared component registry is required for DIRECTORY and LOCATOR layouts." + ); + } + validateComponentIds(sections, sharedComponents, libraryDirectory, addIssue); + for (const layout of parsedLayouts) { + validateLayoutReferences(layout, sections, sharedComponents, addIssue); + } + + if (issues.length > 0) { + return { issues }; + } + return { + issues, + structure: { + sections, + sharedComponents, + sharedRootPageSetTypes, + layouts, + }, + }; +}; + +const readSections = ( + rootDir: string, + libraryDirectory: string, + addIssue: AddIssue +): ResolvedSection[] => { + const sectionsDirectory = path.join(libraryDirectory, "sections"); + if (!fs.existsSync(sectionsDirectory)) { + return []; + } + + const sections: ResolvedSection[] = []; + for (const entry of fs + .readdirSync(sectionsDirectory, { withFileTypes: true }) + .filter((entry) => entry.name !== ".gitkeep") + .sort((left, right) => left.name.localeCompare(right.name))) { + const sourcePath = path.join(sectionsDirectory, entry.name); + + if (entry.isDirectory()) { + addIssue( + sourcePath, + "sections/directory", + "Section directories are not supported." + ); + continue; + } + + const extension = path.extname(entry.name); + if (!entry.isFile() || ![".tsx", ".jsx"].includes(extension)) { + addIssue( + sourcePath, + "sections/extension", + "Sections must be .tsx or .jsx files." + ); + continue; + } + + const componentName = path.basename(entry.name, extension); + if (!safeIdPattern.test(componentName)) { + addIssue( + sourcePath, + "sections/component-name", + `Section component name is not valid: ${componentName}` + ); + continue; + } + + try { + const section: ResolvedSection = { + ...extractSectionConfigFrontmatter(sourcePath, componentName), + componentName, + sourcePath: path.relative(rootDir, sourcePath), + }; + + if (!safeIdPattern.test(section.id)) { + addIssue(sourcePath, "sections/id", "config must define a valid id"); + } else { + sections.push(section); + } + } catch (error) { + const message = errorMessage(error); + const invalidId = message.includes("config must define a valid id"); + addIssue( + sourcePath, + invalidId ? "sections/id" : "sections/frontmatter", + sourcePath.endsWith(".tsx") && message.includes("config") + ? `${message}. Accepted form: export const config: SectionConfig = { ... };` + : message + ); + } + } + return sections; +}; + +const readLayouts = ( + libraryDirectory: string, + addIssue: AddIssue +): ParsedLayout[] => { + const layoutsDirectory = path.join(libraryDirectory, "layouts"); + + if (!fs.existsSync(layoutsDirectory)) { + addIssue(layoutsDirectory, "layouts/missing", "Missing layouts directory."); + return []; + } + + const layoutDirectories = fs + .readdirSync(layoutsDirectory, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)); + const layouts = layoutDirectories.flatMap((entry) => { + const layout = readLayout( + path.join(layoutsDirectory, entry.name), + addIssue + ); + return layout ? [layout] : []; + }); + const pageSetTypes = new Set( + layouts.map((layout) => layout.metadata.pageSetType) + ); + + if ( + layoutDirectories.length !== 3 || + (layouts.length === layoutDirectories.length && + (pageSetTypes.size !== 3 || + !["ENTITY", "DIRECTORY", "LOCATOR"].every((type) => + pageSetTypes.has(type as PageSetType) + ))) + ) { + addIssue( + layoutsDirectory, + "layouts/cardinality", + "Section Library build mode requires one ENTITY, one DIRECTORY, and one LOCATOR layout." + ); + } + return layouts; +}; + +const readLayout = ( + layoutDirectory: string, + addIssue: AddIssue +): ParsedLayout | undefined => { + const metadataPath = path.join(layoutDirectory, "metadata.json"); + const defaultLayoutPath = path.join(layoutDirectory, "defaultLayout.json"); + const metadataValue = readJsonObject( + metadataPath, + "layout metadata", + addIssue + ); + const defaultLayout = readJsonObject( + defaultLayoutPath, + "default layout", + addIssue + ); + + if (!metadataValue || !defaultLayout) { + return; + } + + try { + const pageSetType = metadataValue.pageSetType; + let metadata: LayoutMetadata; + if (pageSetType === "ENTITY") { + const vertical = getOptionalStringListProperty( + metadataValue, + "vertical", + verticals, + metadataPath + ); + const purpose = getOptionalStringListProperty( + metadataValue, + "purpose", + purposes, + metadataPath + ); + + metadata = { + id: requireString(metadataValue.id, metadataPath, "id"), + displayName: requireString( + metadataValue.displayName, + metadataPath, + "displayName" + ), + previewImageUrl: requireString( + metadataValue.previewImageUrl, + metadataPath, + "previewImageUrl" + ), + ...(vertical === undefined ? {} : { vertical }), + ...(purpose === undefined ? {} : { purpose }), + pageSetType, + }; + } else if (pageSetType === "DIRECTORY" || pageSetType === "LOCATOR") { + metadata = { + id: requireString(metadataValue.id, metadataPath, "id"), + displayName: requireString( + metadataValue.displayName, + metadataPath, + "displayName" + ), + pageSetType, + }; + } else { + throw new Error("must set a supported pageSetType"); + } + if (!safeIdPattern.test(metadata.id)) { + throw new Error(`Layout ID is not valid: ${metadata.id}`); + } + + if (reservedLayoutIds.has(metadata.id)) { + throw new Error( + `cannot use ${metadata.id} because it is reserved for a generated template` + ); + } + + return { metadata, defaultLayout, defaultLayoutPath }; + } catch (error) { + addIssue(metadataPath, "layouts/metadata", errorMessage(error)); + } +}; + +const validateComponentIds = ( + sections: ResolvedSection[], + sharedComponents: SharedHiddenPuckComponent[], + libraryDirectory: string, + addIssue: AddIssue +): void => { + const componentIds = new Set(); + for (const [componentTypeName, components] of [ + ["Section", sections], + ["Component", sharedComponents], + ] as const) { + for (const component of components) { + const filePath = + componentTypeName === "Section" && "sourcePath" in component + ? path.join( + libraryDirectory, + "sections", + component.sourcePath.split(path.sep).at(-1) ?? "" + ) + : path.join(libraryDirectory, "shared", "componentRegistry.ts"); + + if (!safeIdPattern.test(component.id)) { + addIssue( + filePath, + "components/id", + `${componentTypeName} ID is not valid: ${component.id}` + ); + } + + if (componentIds.has(component.id)) { + addIssue( + filePath, + "components/duplicate", + `${componentTypeName} ID is not unique: ${component.id}` + ); + } + + componentIds.add(component.id); + } + } +}; + +// Validates the props.id and type fields of the default layout +const validateLayoutReferences = ( + layout: ParsedLayout, + sections: ResolvedSection[], + sharedComponents: SharedHiddenPuckComponent[], + addIssue: AddIssue +): void => { + const componentIds = new Set( + [...sections, ...sharedComponents] + .filter((component) => + component.pageSetTypes.includes(layout.metadata.pageSetType) + ) + .map((component) => component.id) + ); + const instanceIds = new Set(); + const filePath = layout.defaultLayoutPath; + + for (const component of collectLayoutComponents(layout.defaultLayout)) { + const instanceId = component.props.id; + + if (typeof instanceId !== "string" || !instanceId) { + addIssue( + filePath, + "layouts/instance-id", + `Layout ${layout.metadata.id} component ${component.type} must contain props.id` + ); + continue; + } + + if (instanceIds.has(instanceId)) { + addIssue( + filePath, + "layouts/duplicate-instance-id", + `Layout ${layout.metadata.id} props.id is not unique: ${instanceId}` + ); + } + + instanceIds.add(instanceId); + + if (component.type !== "MainContent" && !componentIds.has(component.type)) { + addIssue( + filePath, + "layouts/reference", + `Layout ${layout.metadata.id} references missing or incompatible section ${component.type}` + ); + } + } +}; + +const collectLayoutComponents = ( + value: unknown +): { type: string; props: Record }[] => { + if (!value || typeof value !== "object") { + return []; + } + + const layout = value as Record; + + return [ + ...collectComponentList(layout.content), + ...Object.values(layout.zones ?? {}).flatMap(collectComponentList), + ]; +}; + +const collectComponentList = ( + value: unknown +): { type: string; props: Record }[] => { + if (!Array.isArray(value)) { + return []; + } + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") { + return []; + } + + const component = entry as Record; + if (typeof component.type !== "string" || !component.props) { + return []; + } + + const props = component.props as Record; + const slots = + props.slots && typeof props.slots === "object" + ? Object.values(props.slots).flatMap(collectComponentList) + : []; + const mainContent = + component.type === "MainContent" + ? collectComponentList(props.content) + : []; + + return [{ type: component.type, props }, ...slots, ...mainContent]; + }); +}; + +const readJsonObject = ( + filePath: string, + description: string, + addIssue: AddIssue +): Record | undefined => { + if (!fs.existsSync(filePath)) { + addIssue(filePath, "json/missing", `Missing ${description}.`); + return; + } + + try { + const value: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); + + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("must be a JSON object"); + } + + return value as Record; + } catch (error) { + addIssue( + filePath, + "json/invalid", + `Could not parse ${description}: ${errorMessage(error)}` + ); + } +}; + +const requireString = ( + value: unknown, + filePath: string, + field: string +): string => { + if (typeof value !== "string") { + throw new Error(`${filePath} must define a string ${field}`); + } + + return value; +}; + +const getOptionalStringListProperty = ( + metadata: Record, + name: string, + allowedValues: readonly T[], + filePath: string +): T[] | undefined => { + const value = metadata[name]; + + if (value === undefined) { + return; + } + + if ( + !Array.isArray(value) || + value.some( + (item) => typeof item !== "string" || !allowedValues.includes(item as T) + ) + ) { + throw new Error( + `${filePath} must define ${name} as a list of supported values` + ); + } + return value as T[]; +}; + +const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +const cleanMessage = (message: string, rootDir: string): string => + message.split(`${rootDir}${path.sep}`).join(""); + +type AddIssue = (filePath: string, rule: string, message: string) => void; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/testUtils.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/testUtils.ts new file mode 100644 index 000000000..43806ef52 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/testUtils.ts @@ -0,0 +1,18 @@ +import os from "node:os"; +import path from "node:path"; +import fs from "fs-extra"; +import { afterEach } from "vitest"; + +const tempRoots: string[] = []; + +afterEach(() => { + for (const rootDir of tempRoots.splice(0)) { + fs.removeSync(rootDir); + } +}); + +export const createTempRoot = (prefix = "yextve-test-"): string => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempRoots.push(rootDir); + return rootDir; +}; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/types.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/types.ts new file mode 100644 index 000000000..61e40fea4 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/types.ts @@ -0,0 +1,49 @@ +import type { + LibraryMetadata, + PageSetType, + SectionConfig, + SectionLibraryLayout, + SharedHiddenPuckComponent, +} from "../../types/sectionLibrary.ts"; + +/** The stages of validation. Each can be run or skipped independently. */ +export type ValidationStage = "api" | "structure" | "code"; + +/** A problem found while validating a Section Library. */ +export type ValidationIssue = { + category: ValidationStage; + filePath: string; + line?: number; + column?: number; + message: string; + rule: string; +}; + +/** The configuration for a Section Library validation run. */ +export type ValidationContext = { + rootDir: string; + yextCI: boolean; + skippedStages: ReadonlySet; +}; + +/** A section config augmented with its resolved component name and source path. */ +export type ResolvedSection = SectionConfig & { + componentName: string; + sourcePath: string; +}; + +/** The resolved sections, shared components, and layouts in a Section Library. */ +export type ResolvedSectionLibraryStructure = { + sections: ResolvedSection[]; + sharedComponents: SharedHiddenPuckComponent[]; + sharedRootPageSetTypes: PageSetType[]; + layouts: SectionLibraryLayout[]; +}; + +/** The result of a Section Library validation run. */ +export type ValidationResult = { + context: ValidationContext; + issues: ValidationIssue[]; + metadata?: LibraryMetadata; + structure?: ResolvedSectionLibraryStructure; +}; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.test.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.test.ts new file mode 100644 index 000000000..9b48c1d57 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.test.ts @@ -0,0 +1,148 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { describe, expect, it } from "vitest"; +import { createTempRoot } from "./testUtils.ts"; +import { + createValidationContext, + sortValidationIssues, + validateSectionLibrary, +} from "./validateSectionLibrary.ts"; + +describe("validateSectionLibrary", () => { + it("combines issues from every stage", () => { + const rootDir = createTempRoot(); + fs.outputFileSync( + path.join(rootDir, "src", "library", "Unsafe.ts"), + "eval(code);" + ); + + const result = validateSectionLibrary(createValidationContext(rootDir)); + + expect(new Set(result.issues.map((issue) => issue.category))).toEqual( + new Set(["api", "structure", "code"]) + ); + }); + + it("does not run explicitly skipped stages", () => { + const context = createValidationContext(createTempRoot(), { + skippedStages: ["api", "structure", "code"], + }); + + expect(validateSectionLibrary(context)).toMatchObject({ + issues: [], + metadata: undefined, + structure: undefined, + }); + }); + + it("skips API validation in Yext CI", () => { + const context = createValidationContext(createTempRoot(), { + yextCI: true, + skippedStages: ["structure", "code"], + }); + + expect(validateSectionLibrary(context).issues).toEqual([]); + }); +}); + +describe("sortValidationIssues", () => { + it("sorts findings by stage", () => { + const issue = createIssue(); + + expect( + sortValidationIssues([ + { ...issue, category: "code" }, + { ...issue, category: "structure" }, + { ...issue, category: "api" }, + ]).map((value) => value.category) + ).toEqual(["api", "structure", "code"]); + }); + + it("sorts findings by file path", () => { + const issue = createIssue(); + + expect( + sortValidationIssues([ + { ...issue, filePath: "b.ts" }, + { ...issue, filePath: "a.ts" }, + ]).map((value) => value.filePath) + ).toEqual(["a.ts", "b.ts"]); + }); + + it("sorts findings by source position", () => { + const issue = createIssue(); + + expect( + sortValidationIssues([ + { ...issue, line: 2, column: 1 }, + { ...issue, line: 1, column: 2 }, + { ...issue, line: 1, column: 1 }, + ]).map(({ line, column }) => [line, column]) + ).toEqual([ + [1, 1], + [1, 2], + [2, 1], + ]); + }); + + it("sorts equivalent findings by rule", () => { + const issue = createIssue(); + + expect( + sortValidationIssues([ + { ...issue, rule: "rule/b" }, + { ...issue, rule: "rule/a" }, + ]).map((value) => value.rule) + ).toEqual(["rule/a", "rule/b"]); + }); + + it("does not mutate the input", () => { + const issue = createIssue(); + const issues = [ + { ...issue, filePath: "b.ts" }, + { ...issue, filePath: "a.ts" }, + ]; + + sortValidationIssues(issues); + + expect(issues.map((value) => value.filePath)).toEqual(["b.ts", "a.ts"]); + }); +}); + +describe("createValidationContext", () => { + it("uses default options", () => { + const rootDir = createTempRoot(); + + expect(createValidationContext(rootDir)).toEqual({ + rootDir, + yextCI: false, + skippedStages: new Set(), + }); + }); + + it("preserves explicitly skipped stages", () => { + const context = createValidationContext(createTempRoot(), { + skippedStages: ["structure", "code"], + }); + + expect([...context.skippedStages]).toEqual(["structure", "code"]); + }); + + it("adds API validation to skipped stages in Yext CI", () => { + const context = createValidationContext(createTempRoot(), { + yextCI: true, + skippedStages: ["structure"], + }); + + expect([...context.skippedStages]).toEqual(["structure", "api"]); + }); +}); + +const createIssue = () => ({ + category: "structure" as const, + filePath: "source.ts", + line: 1, + column: 1, + message: "message", + rule: "rule", +}); diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.ts new file mode 100644 index 000000000..2cf4c182c --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/validateSectionLibrary.ts @@ -0,0 +1,75 @@ +import { validateCode } from "./stages/code/validateCode.ts"; +import { validateLibraryMetadata } from "./stages/metadata/libraryMetadata.ts"; +import { validateSectionLibraryStructure } from "./stages/structure/structure.ts"; +import type { + ValidationContext, + ValidationIssue, + ValidationResult, + ValidationStage, +} from "./types.ts"; + +const stagesSkippedInYextCI: Set = new Set(["api"]); +const categoryOrder: ValidationStage[] = ["api", "structure", "code"]; + +export const createValidationContext = ( + rootDir: string, + options: { + yextCI?: boolean; + skippedStages?: Iterable; + } = {} +): ValidationContext => { + const yextCI = options.yextCI ?? false; + const skippedStages = new Set(options.skippedStages ?? []); + if (yextCI) { + for (const stage of stagesSkippedInYextCI) { + skippedStages.add(stage); + } + } + return { rootDir, yextCI, skippedStages }; +}; + +export const validateSectionLibrary = ( + context: ValidationContext +): ValidationResult => { + const metadataResult = context.skippedStages.has("api") + ? { issues: [], metadata: undefined } + : validateLibraryMetadata(context.rootDir); + + const structureResult = context.skippedStages.has("structure") + ? { issues: [], structure: undefined } + : validateSectionLibraryStructure(context.rootDir); + + const codeIssues = context.skippedStages.has("code") + ? [] + : validateCode(context.rootDir); + + const issues = sortValidationIssues([ + ...metadataResult.issues, + ...structureResult.issues, + ...codeIssues, + ]); + + return { + context, + issues, + metadata: metadataResult.metadata, + structure: structureResult.structure, + }; +}; + +export const sortValidationIssues = ( + issues: ValidationIssue[] +): ValidationIssue[] => + [...issues].sort((left, right) => { + return ( + categoryOrder.indexOf(left.category) - + categoryOrder.indexOf(right.category) || + compareStrings(left.filePath, right.filePath) || + (left.line ?? 0) - (right.line ?? 0) || + (left.column ?? 0) - (right.column ?? 0) || + compareStrings(left.rule, right.rule) + ); + }); + +const compareStrings = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/validationError.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/validationError.ts new file mode 100644 index 000000000..3280d8dc4 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/validationError.ts @@ -0,0 +1,17 @@ +import type { ValidationIssue } from "./types.ts"; + +export const formatValidationIssue = (issue: ValidationIssue): string => { + const position = issue.line + ? `:${issue.line}${issue.column ? `:${issue.column}` : ""}` + : ""; + return `${issue.filePath}${position}: ${issue.message}`; +}; + +export class SectionLibraryValidationError extends Error { + constructor(public readonly issues: ValidationIssue[]) { + super( + `Section Library validation failed with ${issues.length} ${issues.length === 1 ? "error" : "errors"}:\n${issues.map(formatValidationIssue).join("\n")}` + ); + this.name = "SectionLibraryValidationError"; + } +} diff --git a/packages/visual-editor/src/sectionLibrary.ts b/packages/visual-editor/src/sectionLibrary.ts deleted file mode 100644 index 9c81d902f..000000000 --- a/packages/visual-editor/src/sectionLibrary.ts +++ /dev/null @@ -1,68 +0,0 @@ -export const supportedPageSetTypes = [ - "ENTITY", - "DIRECTORY", - "LOCATOR", -] as const; - -export type PageSetType = (typeof supportedPageSetTypes)[number]; - -export type SectionConfig = { - id: string; - displayName: string; - description: string; - pageSetTypes: PageSetType[]; - category?: string; -}; - -export type LibraryMetadata = { - schemaVersion: 1; - id: string; - displayName: string; - description: string; -}; - -export const verticals = [ - "HEALTHCARE", - "FINANCIAL_SERVICES", - "FOOD_AND_DINING", - "RETAIL", - "HOSPITALITY", - "PROFESSIONAL_SERVICES", -] as const; - -export type Vertical = (typeof verticals)[number]; - -export const purposes = ["LOCATION", "CITATION"] as const; - -export type Purpose = (typeof purposes)[number]; - -export type EntityLayoutMetadata = { - id: string; - displayName: string; - previewImageUrl: string; - vertical?: Vertical[]; - purpose?: Purpose[]; - pageSetType: "ENTITY"; -}; - -export type DirectoryLayoutMetadata = { - id: string; - displayName: string; - pageSetType: "DIRECTORY"; -}; - -export type LocatorLayoutMetadata = { - id: string; - displayName: string; - pageSetType: "LOCATOR"; -}; - -export type LayoutMetadata = - | EntityLayoutMetadata - | DirectoryLayoutMetadata - | LocatorLayoutMetadata; - -export type SectionLibraryLayout = { - metadata: LayoutMetadata; - defaultLayout: Record; -}; diff --git a/packages/visual-editor/src/types/sectionLibrary.ts b/packages/visual-editor/src/types/sectionLibrary.ts new file mode 100644 index 000000000..7ba8795e2 --- /dev/null +++ b/packages/visual-editor/src/types/sectionLibrary.ts @@ -0,0 +1,126 @@ +export const supportedPageSetTypes = [ + "ENTITY", + "DIRECTORY", + "LOCATOR", +] as const; + +export type PageSetType = (typeof supportedPageSetTypes)[number]; + +/** A hidden internal Puck component that can appear in saved slot layout data. */ +export type SharedHiddenPuckComponent = { + /** Stable Puck component ID stored in the layout data. */ + id: string; + + /** Page-set types that can render this hidden internal component. */ + pageSetTypes: PageSetType[]; +}; + +/** + * Static metadata from a Section Library shared registry. + * + * The generator uses this metadata to validate saved component IDs, register + * their Puck configs, and omit them from editor add-component menus. + */ +export type SharedHiddenPuckComponentRegistry = { + /** Hidden internal components that can appear in saved layout data. */ + components: SharedHiddenPuckComponent[]; + + /** Page-set types that need the shared root config, even without components. */ + rootPageSetTypes: PageSetType[]; +}; + +/** + * Metadata describing a Section. + * A section is a horizontal slice of a page that is + * available for configuration in the Visual Layout Editor. + */ +export type SectionConfig = { + /** The internal id of the section. */ + id: string; + /** The user-facing display name of the section. */ + displayName: string; + /** The description of the section's purpose. */ + description: string; + /** The page set types in which the section can be used. */ + pageSetTypes: PageSetType[]; + /** The category in the left sidebar in which the section will be listed. */ + category?: string; +}; + +/** + * Metadata describing a Library. + * A library is a collection of Sections and Layouts. + */ +export type LibraryMetadata = { + /** The version of the library.json metadata schema used in this repo. */ + schemaVersion: 1; + /** The internal id of the library. */ + id: string; + /** The user-facing display name of the library. */ + displayName: string; + /** The user-facing description of the library. */ + description: string; +}; + +export const verticals = [ + "HEALTHCARE", + "FINANCIAL_SERVICES", + "FOOD_AND_DINING", + "RETAIL", + "HOSPITALITY", + "PROFESSIONAL_SERVICES", +] as const; + +export type Vertical = (typeof verticals)[number]; + +export const purposes = ["LOCATION", "CITATION"] as const; + +export type Purpose = (typeof purposes)[number]; + +/** A layout for use with entity page sets. */ +export type EntityLayoutMetadata = { + /** The internal id of the entity layout. */ + id: string; + /** The user-facing display name of the layout. */ + displayName: string; + /** The URL of an image to display in the Section Library Gallery. */ + previewImageUrl: string; + /** The entity categories/industries recommended for use with this layout. */ + vertical?: Vertical[]; + /** Whether this layout is intended as the primary location landing page, as an alternate citation page, or both. */ + purpose?: Purpose[]; + pageSetType: "ENTITY"; +}; + +/** A layout for use with directory page sets. */ +export type DirectoryLayoutMetadata = { + /** The internal id of the directory layout. */ + id: string; + /** The user-facing display name of the layout. */ + displayName: string; + pageSetType: "DIRECTORY"; +}; + +/** A layout for use with locator page sets. */ +export type LocatorLayoutMetadata = { + /** The internal id of the locator layout. */ + id: string; + /** The user-facing display name of the layout. */ + displayName: string; + pageSetType: "LOCATOR"; +}; + +export type LayoutMetadata = + | EntityLayoutMetadata + | DirectoryLayoutMetadata + | LocatorLayoutMetadata; + +/** + * A Section Library Layout is a starting point for a page set. + * It appears in the Section Library Gallery and sets the page set's initial layout configuration. + */ +export type SectionLibraryLayout = { + metadata: LayoutMetadata; + /** Puck Layout JSON */ + defaultLayout: Record; +}; diff --git a/packages/visual-editor/src/vite-plugin/local-editor/artifacts.test.ts b/packages/visual-editor/src/vite-plugin/local-editor/artifacts.test.ts index 4c76d4565..0cf52439f 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/artifacts.test.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/artifacts.test.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import fs from "fs-extra"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { SectionLibraryLayout } from "../../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../../types/sectionLibrary.ts"; import { createLocalEditorArtifactsManager } from "./artifacts.ts"; const rootDirs: string[] = []; diff --git a/packages/visual-editor/src/vite-plugin/local-editor/artifacts.ts b/packages/visual-editor/src/vite-plugin/local-editor/artifacts.ts index fd29af8bd..964d66a2b 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/artifacts.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/artifacts.ts @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "fs-extra"; -import type { SectionLibraryLayout } from "../../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../../types/sectionLibrary.ts"; import { buildLocalEditorDataTemplatePath, buildLocalEditorDataTemplateSource, diff --git a/packages/visual-editor/src/vite-plugin/local-editor/config.test.ts b/packages/visual-editor/src/vite-plugin/local-editor/config.test.ts index 8840323cf..98e55d484 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/config.test.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/config.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import type { SectionLibraryLayout } from "../../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../../types/sectionLibrary.ts"; import { resolveLocalEditorConfigs } from "./config.ts"; const layout = ( diff --git a/packages/visual-editor/src/vite-plugin/local-editor/config.ts b/packages/visual-editor/src/vite-plugin/local-editor/config.ts index c4becc3fd..a2c77d590 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/config.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/config.ts @@ -2,7 +2,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import fs from "fs-extra"; import { tsImport } from "tsx/esm/api"; -import type { SectionLibraryLayout } from "../../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../../types/sectionLibrary.ts"; import { buildLocalEditorDataTemplateName } from "./generatedFiles.ts"; import type { LocalEditorConfig, ResolvedLocalEditorConfig } from "./types.ts"; import { toErrorMessage } from "./utils.ts"; diff --git a/packages/visual-editor/src/vite-plugin/local-editor/data.test.ts b/packages/visual-editor/src/vite-plugin/local-editor/data.test.ts index e03a6afff..a6f2fc59d 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/data.test.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/data.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import fs from "fs-extra"; import { afterEach, describe, expect, it } from "vitest"; -import type { SectionLibraryLayout } from "../../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../../types/sectionLibrary.ts"; import { getLocalEditorDocument, getLocalEditorManifest } from "./data.ts"; const rootDirs: string[] = []; diff --git a/packages/visual-editor/src/vite-plugin/local-editor/data.ts b/packages/visual-editor/src/vite-plugin/local-editor/data.ts index bba683894..720c17ac4 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/data.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/data.ts @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "fs-extra"; -import type { SectionLibraryLayout } from "../../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../../types/sectionLibrary.ts"; import { readResolvedLayoutConfigs } from "./config.ts"; import { DEFAULT_LOCAL_EDITOR_STREAM_CONFIG_PATH } from "./generatedFiles.ts"; import { inferEntityFields } from "./entityFields.ts"; diff --git a/packages/visual-editor/src/vite-plugin/local-editor/generatedFiles.ts b/packages/visual-editor/src/vite-plugin/local-editor/generatedFiles.ts index ad248153c..ecd81e814 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/generatedFiles.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/generatedFiles.ts @@ -1,6 +1,6 @@ import path from "node:path"; import fs from "fs-extra"; -import type { PageSetType } from "../../sectionLibrary.ts"; +import type { PageSetType } from "../../types/sectionLibrary.ts"; export const DEFAULT_LOCAL_EDITOR_ROUTE = "/local-editor"; export const DEFAULT_LOCAL_EDITOR_STREAM_CONFIG_PATH = "stream.config.ts"; diff --git a/packages/visual-editor/src/vite-plugin/local-editor/server.ts b/packages/visual-editor/src/vite-plugin/local-editor/server.ts index 4e350b488..d51878b0d 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/server.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/server.ts @@ -1,7 +1,7 @@ import { getLocalEditorDocument, getLocalEditorManifest } from "./data.ts"; import { MAX_LOCAL_EDITOR_DIRECTORY_CHILD_COUNT } from "./fixtureData.ts"; import { LOCAL_EDITOR_API_BASE_PATH } from "./generatedFiles.ts"; -import type { SectionLibraryLayout } from "../../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../../types/sectionLibrary.ts"; import type { LocalEditorDocumentResponse, LocalEditorManifestResponse, diff --git a/packages/visual-editor/src/vite-plugin/local-editor/types.ts b/packages/visual-editor/src/vite-plugin/local-editor/types.ts index 5cbb7a0a6..fbd838160 100644 --- a/packages/visual-editor/src/vite-plugin/local-editor/types.ts +++ b/packages/visual-editor/src/vite-plugin/local-editor/types.ts @@ -1,5 +1,5 @@ import type { YextSchemaField } from "../../types/entityFields.ts"; -import type { PageSetType } from "../../sectionLibrary.ts"; +import type { PageSetType } from "../../types/sectionLibrary.ts"; export type LocalEditorOptions = { enabled?: boolean; diff --git a/packages/visual-editor/src/vite-plugin/plugin.ts b/packages/visual-editor/src/vite-plugin/plugin.ts index 585b996e5..0904a095d 100644 --- a/packages/visual-editor/src/vite-plugin/plugin.ts +++ b/packages/visual-editor/src/vite-plugin/plugin.ts @@ -21,7 +21,7 @@ import { sendJsonResponse, } from "./local-editor/server.ts"; import type { LocalEditorOptions } from "./local-editor/types.ts"; -import type { SectionLibraryLayout } from "../sectionLibrary.ts"; +import type { SectionLibraryLayout } from "../types/sectionLibrary.ts"; export type VisualEditorPluginOptions = { sectionLibrary?: boolean; diff --git a/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts b/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts index e3fd08992..51ed9afcf 100644 --- a/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts +++ b/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts @@ -4,9 +4,10 @@ import { supportedPageSetTypes, type PageSetType, type SectionConfig, -} from "../../sectionLibrary.ts"; +} from "../../types/sectionLibrary.ts"; const project = new Project({ compilerOptions: { allowJs: true } }); +const safeIdPattern = /^[A-Za-z0-9_-]+$/; /** Reads the static SectionConfig metadata from one section source file. */ export const extractSectionConfigFrontmatter = ( @@ -58,6 +59,12 @@ export const extractSectionConfigFrontmatter = ( } return value.asKind(SyntaxKind.StringLiteral)?.getLiteralValue() ?? null; }; + + const id = getString("id"); + if (!id || !safeIdPattern.test(id)) { + throw new Error(`${sourcePath} config must define a valid id`); + } + const pageSetTypesProperty = object.getProperty("pageSetTypes"); const pageSetTypes = pageSetTypesProperty ? pageSetTypesProperty @@ -91,10 +98,6 @@ export const extractSectionConfigFrontmatter = ( ); } - const id = getString("id"); - if (!id) { - throw new Error(`${sourcePath} config must define a valid id`); - } const displayName = getString("displayName"); if (!displayName) { throw new Error( diff --git a/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts b/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts index b1dbcecce..7904d6712 100644 --- a/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts +++ b/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts @@ -585,7 +585,7 @@ describe("generateSectionLibraryFiles", () => { description: "A test library.", }); }, - error: /must set schemaVersion to 1/, + error: /schemaVersion must equal 1/, }, { name: "unsupported layout vertical", @@ -689,7 +689,7 @@ describe("generateSectionLibraryFiles", () => { } ); }, - error: /component hero must define an ID/, + error: /component hero must contain props\.id/, }, { name: "duplicate layout component ID", @@ -713,7 +713,7 @@ describe("generateSectionLibraryFiles", () => { } ); }, - error: /component ID is not unique: hero-default/, + error: /props\.id is not unique: hero-default/, }, ])("rejects $name", ({ update, error }) => { const rootDir = createLibrary(); @@ -727,6 +727,29 @@ describe("generateSectionLibraryFiles", () => { ).toThrow(error); }); + it("aggregates validation findings before generating any files", () => { + const rootDir = createLibrary(); + const sectionsDirectory = path.join(rootDir, "src", "library", "sections"); + fs.writeFileSync( + path.join(sectionsDirectory, "Hero.tsx"), + "export const Hero = {};" + ); + fs.writeFileSync( + path.join(sectionsDirectory, "Locator.tsx"), + "export const Locator = {};" + ); + + expect(() => + generateSectionLibraryFiles( + rootDir, + "123e4567-e89b-12d3-a456-426614174000" + ) + ).toThrowError(/Hero\.tsx[\s\S]*Locator\.tsx/); + expect( + fs.existsSync(path.join(rootDir, "src", "library", ".generated")) + ).toBe(false); + }); + it("requires a Section Library revision ID", () => { expect(() => generateSectionLibraryFiles(createLibrary())).toThrow( "Section Library builds require SECTION_LIBRARY_REVISION_ID" diff --git a/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.ts b/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.ts index 57bbc4d98..6594e36ba 100644 --- a/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.ts +++ b/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.ts @@ -4,45 +4,23 @@ import sectionLibraryConfigTemplate from "../templates/section-library-config.ts import sectionLibraryEditorTemplate from "../templates/section-library-editor.tsx?raw"; import sectionLibraryRenderTemplate from "../templates/section-library-render.tsx?raw"; import { - purposes, - verticals, - type LayoutMetadata, - type LibraryMetadata, type PageSetType, type SectionLibraryLayout, - type SectionConfig, -} from "../../sectionLibrary.ts"; -import { extractSectionConfigFrontmatter } from "./sectionFrontmatter.ts"; + type SharedHiddenPuckComponent, +} from "../../types/sectionLibrary.ts"; import { - readSharedComponentRegistry, - type SharedComponent, -} from "./sharedComponentRegistry.ts"; + createValidationContext, + validateSectionLibrary, +} from "../../internal/sectionLibraryValidation/validateSectionLibrary.ts"; +import { SectionLibraryValidationError } from "../../internal/sectionLibraryValidation/validationError.ts"; +import type { ResolvedSection } from "../../internal/sectionLibraryValidation/types.ts"; const GENERATED_FILE_PREFIX = "/** THIS FILE IS GENERATED BY THE SECTION LIBRARY PLUGIN */"; -const reservedLayoutIds = new Set(["main", "directory", "locator", "edit"]); - -/** A visible Section Library section and its static config metadata. */ -type Section = SectionConfig & { - /** The source filename without its extension. */ - componentName: string; - /** The relative source path for generated imports. */ - sourcePath: string; -}; - /** A Section Library layout and its metadata. */ type Layout = SectionLibraryLayout; -/** The complete validated Section Library source. */ -type SectionLibrary = { - metadata: LibraryMetadata; - sections: Section[]; - sharedComponents: SharedComponent[]; - sharedRootPageSetTypes: PageSetType[]; - layouts: Layout[]; -}; - /** Section Library files and metadata generated for a build. */ type GeneratedSectionLibrary = { generatedFiles: string[]; @@ -69,13 +47,24 @@ export const generateSectionLibraryFiles = ( ); } - const library = readSectionLibrary(rootDir); - if (!library) { + if (!fs.existsSync(path.join(rootDir, "src", "library", "library.json"))) { return { generatedFiles: [], layouts: [] }; } + const result = validateSectionLibrary( + createValidationContext(rootDir, { skippedStages: ["code"] }) + ); + if (result.issues.length > 0) { + throw new SectionLibraryValidationError(result.issues); + } + if (!result.metadata || !result.structure) { + throw new Error( + "Section Library metadata and structure validation did not run." + ); + } + const { metadata, structure } = result; const generatedDirectory = path.join(rootDir, "src", "library", ".generated"); - const generatedFiles = library.layouts.flatMap((layout) => { + const generatedFiles = structure.layouts.flatMap((layout) => { const configPath = path.join( generatedDirectory, `libraryConfig-${layout.metadata.id}.tsx` @@ -85,9 +74,9 @@ export const generateSectionLibraryFiles = ( buildConfigSource( rootDir, layout, - library.sections, - library.sharedComponents, - library.sharedRootPageSetTypes + structure.sections, + structure.sharedComponents, + structure.sharedRootPageSetTypes ) ) ? [configPath] @@ -95,7 +84,7 @@ export const generateSectionLibraryFiles = ( }); const layoutsByPageSetType = new Map( - library.layouts.map((layout) => [layout.metadata.pageSetType, layout]) + structure.layouts.map((layout) => [layout.metadata.pageSetType, layout]) ); const entityLayout = layoutsByPageSetType.get("ENTITY")!; const directoryLayout = layoutsByPageSetType.get("DIRECTORY")!; @@ -125,7 +114,7 @@ export const generateSectionLibraryFiles = ( }) ); generatedFiles.push( - ...library.layouts.flatMap((layout) => { + ...structure.layouts.flatMap((layout) => { const filePath = path.join( rootDir, "src", @@ -153,12 +142,12 @@ export const generateSectionLibraryFiles = ( return { generatedFiles, - layouts: library.layouts, + layouts: structure.layouts, manifestSource: `${JSON.stringify( { schemaVersion: 1, - library: library.metadata, - layouts: library.layouts.map((layout) => ({ + library: metadata, + layouts: structure.layouts.map((layout) => ({ ...layout.metadata, templateId: layout.metadata.pageSetType === "ENTITY" @@ -174,43 +163,6 @@ export const generateSectionLibraryFiles = ( }; }; -/** Reads and validates the Section Library source. */ -const readSectionLibrary = (rootDir: string): SectionLibrary | undefined => { - const libraryDirectory = path.join(rootDir, "src", "library"); - const libraryJsonPath = path.join(libraryDirectory, "library.json"); - if (!fs.existsSync(libraryJsonPath)) { - return undefined; - } - - const metadata = readLibraryMetadata(libraryJsonPath); - const sections = readSections(rootDir, libraryDirectory); - const sharedRegistry = readSharedComponentRegistry( - path.join(libraryDirectory, "shared", "componentRegistry.ts") - ); - const sharedComponents = sharedRegistry?.components ?? []; - const sharedRootPageSetTypes = sharedRegistry?.rootPageSetTypes ?? []; - const layouts = readLayouts(libraryDirectory); - if ( - !sharedRegistry && - layouts.some((layout) => layout.metadata.pageSetType !== "ENTITY") - ) { - throw new Error( - `Missing shared component registry at ${path.join(libraryDirectory, "shared", "componentRegistry.ts")}` - ); - } - validateComponentIds(sections, sharedComponents); - for (const layout of layouts) { - validateLayoutReferences(layout, sections, sharedComponents); - } - return { - metadata, - sections, - sharedComponents, - sharedRootPageSetTypes, - layouts, - }; -}; - export const cleanupGeneratedSectionLibraryFiles = ( generatedFiles: string[] ): void => { @@ -222,250 +174,6 @@ export const cleanupGeneratedSectionLibraryFiles = ( } }; -const readLibraryMetadata = (filePath: string): LibraryMetadata => { - const value = readJson(filePath, "library metadata"); - if (value.schemaVersion !== 1) { - throw new Error(`${filePath} must set schemaVersion to 1`); - } - return { - schemaVersion: 1, - id: requireString(value.id, filePath, "id"), - displayName: requireString(value.displayName, filePath, "displayName"), - description: requireString(value.description, filePath, "description"), - }; -}; - -const readSections = (rootDir: string, libraryDirectory: string): Section[] => { - const sectionsDirectory = path.join(libraryDirectory, "sections"); - if (!fs.existsSync(sectionsDirectory)) { - return []; - } - - return fs - .readdirSync(sectionsDirectory, { withFileTypes: true }) - .filter((entry) => entry.name !== ".gitkeep") - .sort((left, right) => left.name.localeCompare(right.name)) - .map((entry) => { - const sourcePath = path.join(sectionsDirectory, entry.name); - if (entry.isDirectory()) { - throw new Error(`Section directories are not supported: ${sourcePath}`); - } - const extension = path.extname(entry.name); - if (!entry.isFile() || ![".tsx", ".jsx"].includes(extension)) { - throw new Error(`Sections must be .tsx or .jsx files: ${sourcePath}`); - } - - const componentName = path.basename(entry.name, extension); - if (!isSafeId(componentName)) { - throw new Error( - `Section component name is not valid: ${componentName}` - ); - } - const section: Section = { - ...extractSectionConfigFrontmatter(sourcePath, componentName), - componentName, - sourcePath: path.relative(rootDir, sourcePath), - }; - if (!isSafeId(section.id)) { - throw new Error(`${sourcePath} config must define a valid id`); - } - return section; - }); -}; - -const readLayouts = (libraryDirectory: string): Layout[] => { - const layoutsDirectory = path.join(libraryDirectory, "layouts"); - if (!fs.existsSync(layoutsDirectory)) { - throw new Error(`Missing layouts directory at ${layoutsDirectory}`); - } - const layouts = fs - .readdirSync(layoutsDirectory, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => readLayout(path.join(layoutsDirectory, entry.name))); - if (layouts.length !== 3) { - throw new Error( - "Section Library build mode requires one ENTITY, one DIRECTORY, and one LOCATOR layout" - ); - } - const pageSetTypes = new Set( - layouts.map((layout) => layout.metadata.pageSetType) - ); - if ( - pageSetTypes.size !== 3 || - !["ENTITY", "DIRECTORY", "LOCATOR"].every((type) => - pageSetTypes.has(type as PageSetType) - ) - ) { - throw new Error( - "Section Library build mode requires one ENTITY, one DIRECTORY, and one LOCATOR layout" - ); - } - return layouts; -}; - -const readLayout = (layoutDirectory: string): Layout => { - const metadataPath = path.join(layoutDirectory, "metadata.json"); - const metadataValue = readJson(metadataPath, "layout metadata"); - const pageSetType = metadataValue.pageSetType; - let metadata: LayoutMetadata; - if (pageSetType === "ENTITY") { - const vertical = getOptionalStringListProperty( - metadataValue, - "vertical", - verticals, - metadataPath - ); - const purpose = getOptionalStringListProperty( - metadataValue, - "purpose", - purposes, - metadataPath - ); - metadata = { - id: requireString(metadataValue.id, metadataPath, "id"), - displayName: requireString( - metadataValue.displayName, - metadataPath, - "displayName" - ), - previewImageUrl: requireString( - metadataValue.previewImageUrl, - metadataPath, - "previewImageUrl" - ), - ...(vertical === undefined ? {} : { vertical }), - ...(purpose === undefined ? {} : { purpose }), - pageSetType, - }; - } else if (pageSetType === "DIRECTORY" || pageSetType === "LOCATOR") { - metadata = { - id: requireString(metadataValue.id, metadataPath, "id"), - displayName: requireString( - metadataValue.displayName, - metadataPath, - "displayName" - ), - pageSetType, - }; - } else { - throw new Error(`${metadataPath} must set a supported pageSetType`); - } - if (!isSafeId(metadata.id)) { - throw new Error(`Layout ID is not valid: ${metadata.id}`); - } - if (reservedLayoutIds.has(metadata.id)) { - throw new Error( - `${metadataPath} cannot use ${metadata.id} because it is reserved for a generated template` - ); - } - return { - metadata, - defaultLayout: readJson( - path.join(layoutDirectory, "defaultLayout.json"), - `default layout for ${metadata.id}` - ), - }; -}; - -const validateComponentIds = ( - sections: Section[], - sharedComponents: SharedComponent[] -): void => { - const componentIds = new Set(); - for (const [componentTypeName, components] of [ - ["Section", sections], - ["Component", sharedComponents], - ] as const) { - for (const component of components) { - if (!isSafeId(component.id)) { - throw new Error( - `${componentTypeName} ID is not valid: ${component.id}` - ); - } - if (componentIds.has(component.id)) { - throw new Error( - `${componentTypeName} ID is not unique: ${component.id}` - ); - } - componentIds.add(component.id); - } - } -}; - -const validateLayoutReferences = ( - layout: Layout, - sections: Section[], - sharedComponents: SharedComponent[] -): void => { - const componentIds = new Set( - [...sections, ...sharedComponents] - .filter((component) => - component.pageSetTypes.includes(layout.metadata.pageSetType) - ) - .map((component) => component.id) - ); - const instanceIds = new Set(); - for (const component of collectLayoutComponents(layout.defaultLayout)) { - const instanceId = component.props.id; - if (typeof instanceId !== "string" || !instanceId) { - throw new Error( - `Layout ${layout.metadata.id} component ${component.type} must define an ID` - ); - } - if (instanceIds.has(instanceId)) { - throw new Error( - `Layout ${layout.metadata.id} component ID is not unique: ${instanceId}` - ); - } - instanceIds.add(instanceId); - if (component.type !== "MainContent" && !componentIds.has(component.type)) { - throw new Error( - `Layout ${layout.metadata.id} references missing or incompatible section ${component.type}` - ); - } - } -}; - -const collectLayoutComponents = ( - value: unknown -): { type: string; props: Record }[] => { - if (!value || typeof value !== "object") { - return []; - } - const layout = value as Record; - return [ - ...collectComponentList(layout.content), - ...Object.values(layout.zones ?? {}).flatMap(collectComponentList), - ]; -}; - -const collectComponentList = ( - value: unknown -): { type: string; props: Record }[] => { - if (!Array.isArray(value)) { - return []; - } - return value.flatMap((value) => { - if (!value || typeof value !== "object") { - return []; - } - const component = value as Record; - if (typeof component.type !== "string" || !component.props) { - return []; - } - const props = component.props as Record; - const slots = - props.slots && typeof props.slots === "object" - ? Object.values(props.slots).flatMap(collectComponentList) - : []; - const mainContent = - component.type === "MainContent" - ? collectComponentList(props.content) - : []; - return [{ type: component.type, props }, ...slots, ...mainContent]; - }); -}; - /** * Writes the temporary Platform compatibility entries. Remove these entries * when Platform reads section-library-manifest.json for page set creation. @@ -519,8 +227,8 @@ const writeLegacyTemplateManifest = ( const buildConfigSource = ( rootDir: string, layout: Layout, - sections: Section[], - sharedComponents: SharedComponent[], + sections: ResolvedSection[], + sharedComponents: SharedHiddenPuckComponent[], sharedRootPageSetTypes: PageSetType[] ): string => { const layoutId = layout.metadata.id; @@ -629,60 +337,3 @@ const writeGeneratedFile = (filePath: string, source: string): boolean => { fs.writeFileSync(filePath, source); return true; }; - -const readJson = ( - filePath: string, - description: string -): Record => { - if (!fs.existsSync(filePath)) { - throw new Error(`Missing ${description} at ${filePath}`); - } - try { - const value: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error("must be a JSON object"); - } - return value as Record; - } catch (error) { - throw new Error( - `Could not parse ${description} at ${filePath}: ${error instanceof Error ? error.message : String(error)}` - ); - } -}; - -const requireString = ( - value: unknown, - filePath: string, - field: string -): string => { - if (typeof value !== "string") { - throw new Error(`${filePath} must define a string ${field}`); - } - return value; -}; - -const getOptionalStringListProperty = ( - metadata: Record, - name: string, - allowedValues: readonly T[], - filePath: string -): T[] | undefined => { - const value = metadata[name]; - if (value === undefined) { - return; - } - if ( - !Array.isArray(value) || - value.some( - (item) => typeof item !== "string" || !allowedValues.includes(item as T) - ) - ) { - throw new Error( - `${filePath} must define ${name} as a list of supported values` - ); - } - return value as T[]; -}; - -/** Returns whether an identifier is safe for generated filenames and imports. */ -const isSafeId = (value: string): boolean => /^[A-Za-z0-9_-]+$/.test(value); diff --git a/packages/visual-editor/src/vite-plugin/section-library/sharedComponentRegistry.ts b/packages/visual-editor/src/vite-plugin/section-library/sharedComponentRegistry.ts index 07e5f6c50..2f885069a 100644 --- a/packages/visual-editor/src/vite-plugin/section-library/sharedComponentRegistry.ts +++ b/packages/visual-editor/src/vite-plugin/section-library/sharedComponentRegistry.ts @@ -9,33 +9,11 @@ import { import { supportedPageSetTypes, type PageSetType, -} from "../../sectionLibrary.ts"; + type SharedHiddenPuckComponentRegistry, +} from "../../types/sectionLibrary.ts"; const project = new Project({ compilerOptions: { allowJs: true } }); -/** A hidden internal Puck component that can appear in saved slot layout data. */ -export type SharedComponent = { - /** Stable Puck component ID stored in the layout data. */ - id: string; - - /** Page-set types that can render this hidden internal component. */ - pageSetTypes: PageSetType[]; -}; - -/** - * Static metadata from a Section Library shared registry. - * - * The generator uses this metadata to validate saved component IDs, register - * their Puck configs, and omit them from editor add-component menus. - */ -export type SharedComponentRegistry = { - /** Hidden internal components that can appear in saved layout data. */ - components: SharedComponent[]; - - /** Page-set types that need the shared root config, even without components. */ - rootPageSetTypes: PageSetType[]; -}; - /** * Reads the optional `shared/componentRegistry.ts` Section Library contract. * @@ -52,7 +30,7 @@ export type SharedComponentRegistry = { */ export const readSharedComponentRegistry = ( sourcePath: string -): SharedComponentRegistry | undefined => { +): SharedHiddenPuckComponentRegistry | undefined => { if (!fs.existsSync(sourcePath)) { return undefined; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9283f2ea3..965813c44 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: next-themes: specifier: ^0.3.0 version: 0.3.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + picocolors: + specifier: ^1.1.1 + version: 1.1.1 pure-react-carousel: specifier: ^1.32.0 version: 1.32.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) From 24fefe54061f1c8b92737222bf660983354fc1a2 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:43:59 +0000 Subject: [PATCH 2/7] Automated update to THIRD-PARTY-NOTICES from github action's 3rd party notices check --- packages/visual-editor/THIRD-PARTY-NOTICES | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/visual-editor/THIRD-PARTY-NOTICES b/packages/visual-editor/THIRD-PARTY-NOTICES index bbedcb05a..8335f0a64 100644 --- a/packages/visual-editor/THIRD-PARTY-NOTICES +++ b/packages/visual-editor/THIRD-PARTY-NOTICES @@ -1017,6 +1017,30 @@ Exhibit B - “Incompatible With Secondary Licenses” Notice ----------- +The following npm package may be included in this product: + + - picocolors@1.1.1 + +This package contains the following license: + +ISC License + +Copyright (c) 2021-2024 Oleksii Raspopov, Kostiantyn Denysov, Anton Verinov + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +----------- + The following npm package may be included in this product: - lucide-react@0.414.0 From e908d628576aee1617a1793fd670bc5237726d4d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Aug 2026 18:50:01 +0000 Subject: [PATCH 3/7] Update component screenshots for visual-editor auto-screenshot-update: true --- ...p] version 36 with no nearby locations.png | Bin 13973 -> 4491 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[desktop] version 36 with no nearby locations.png b/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[desktop] version 36 with no nearby locations.png index 56304b9018dade4a51965bf641e082d9c54d247e..31ac40a7ab1f1d24b5369890a4954ccce3b2f74c 100644 GIT binary patch literal 4491 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 UFbw?9?8+4-dG+TgS+jTl4-(A)3jhEB From 6f9cc673e917e26b6618a1ff0b8320184d45bb19 Mon Sep 17 00:00:00 2001 From: Ben Life Date: Wed, 26 Aug 2026 15:29:38 -0400 Subject: [PATCH 4/7] rabbit --- .../stages/code/importRules.ts | 30 +++++++++++++++---- .../stages/code/validateCode.test.ts | 30 +++++++++++++++++++ .../stages/code/validateCode.ts | 2 ++ .../stages/code/xssRules.ts | 27 +++++++++++------ .../stages/structure/structure.ts | 11 ++++--- .../section-library/sectionFrontmatter.ts | 4 +-- 6 files changed, 82 insertions(+), 22 deletions(-) diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts index 04faccd2d..72a8097be 100644 --- a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts @@ -48,6 +48,7 @@ export type ImportReference = { type ClassifiedImport = | { kind: "local" } + | { kind: "unsupported-package-import" } | { kind: "node"; moduleName: string } | { kind: "package"; packageName: string }; @@ -58,13 +59,12 @@ const nodeBuiltins = new Set( ); const classifyImport = (moduleSpecifier: string): ClassifiedImport => { - if ( - moduleSpecifier.startsWith(".") || - moduleSpecifier.startsWith("/") || - moduleSpecifier.startsWith("#") - ) { + if (moduleSpecifier.startsWith(".") || moduleSpecifier.startsWith("/")) { return { kind: "local" }; } + if (moduleSpecifier.startsWith("#")) { + return { kind: "unsupported-package-import" }; + } const withoutNodePrefix = moduleSpecifier.startsWith("node:") ? moduleSpecifier.slice(5) : moduleSpecifier; @@ -115,6 +115,26 @@ export const evaluateForNodeBuiltins = ( : []; }; +export const evaluateUnsupportedPackageImports = ( + reference: ImportReference +): ValidationIssue[] => { + if ( + reference.isTypeOnly || + classifyImport(reference.moduleSpecifier).kind !== + "unsupported-package-import" + ) { + return []; + } + + return [ + createIssue( + reference, + "imports/unsupported-package-import", + `Package import alias '${reference.moduleSpecifier}' cannot be resolved by Section Library validation.` + ), + ]; +}; + export const evaluateDeniedPackages = ( reference: ImportReference ): ValidationIssue[] => { diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts index 728af5209..ee91b0e54 100644 --- a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts @@ -6,6 +6,7 @@ import { createTempRoot } from "../../testUtils.ts"; import { evaluateDeniedPackages, evaluateForNodeBuiltins, + evaluateUnsupportedPackageImports, } from "./importRules.ts"; import { extractImportReferences, validateCode } from "./validateCode.ts"; @@ -88,6 +89,23 @@ describe("evaluateDeniedPackages", () => { }); }); +describe("evaluateUnsupportedPackageImports", () => { + it("rejects a runtime package import alias", () => { + expect( + evaluateUnsupportedPackageImports(reference("#server")) + ).toMatchObject([{ rule: "imports/unsupported-package-import" }]); + }); + + it.each(["./local", "/absolute"])( + "preserves local classification for %s", + (moduleSpecifier) => { + expect( + evaluateUnsupportedPackageImports(reference(moduleSpecifier)) + ).toEqual([]); + } + ); +}); + describe("validateCode", () => { it("returns no issues when the library directory is missing", () => { expect(validateCode(createTempRoot())).toEqual([]); @@ -110,6 +128,17 @@ describe("validateCode", () => { it.each([ ["innerHTML assignment", "node.innerHTML = html;", "xss/html-assignment"], + ["outerHTML assignment", "node.outerHTML = html;", "xss/html-assignment"], + [ + "innerHTML element assignment", + "node['innerHTML'] = html;", + "xss/html-assignment", + ], + [ + "outerHTML element assignment", + "node[`outerHTML`] = html;", + "xss/html-assignment", + ], [ "insertAdjacentHTML", "node.insertAdjacentHTML('beforeend', html);", @@ -118,6 +147,7 @@ describe("validateCode", () => { ["document.write", "document.write(html);", "xss/document-write"], ["eval", "eval(code);", "xss/eval"], ["Function constructor", "new Function(code);", "xss/function-constructor"], + ["Function call", "Function(code);", "xss/function-constructor"], ["string timer", "setTimeout('run()', 1);", "xss/string-timer"], [ "dangerouslySetInnerHTML", diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts index 564781d7c..c38bd7f6c 100644 --- a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts @@ -4,6 +4,7 @@ import { Node, Project, type SourceFile, SyntaxKind } from "ts-morph"; import { evaluateDeniedPackages, evaluateForNodeBuiltins, + evaluateUnsupportedPackageImports, type ImportReference, type ImportSyntaxKind, } from "./importRules.ts"; @@ -34,6 +35,7 @@ export const validateCode = (rootDir: string): ValidationIssue[] => { const references = extractImportReferences(sourceFile, filePath); return [ ...references.flatMap((reference) => [ + ...evaluateUnsupportedPackageImports(reference), ...evaluateForNodeBuiltins(reference), ...evaluateDeniedPackages(reference), ]), diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts index 96ef09d60..983efeb02 100644 --- a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts @@ -40,10 +40,15 @@ export const xssRules: XssRule[] = [ return; } const left = node.getLeft(); + const propertyName = Node.isPropertyAccessExpression(left) + ? left.getName() + : Node.isElementAccessExpression(left) + ? getLiteralString(left.getArgumentExpression()) + : undefined; return assignmentOperators.has(node.getOperatorToken().getKind()) && - Node.isPropertyAccessExpression(left) && - ["innerHTML", "outerHTML"].includes(left.getName()) - ? `Assignment to ${left.getName()} is not permitted.` + propertyName !== undefined && + ["innerHTML", "outerHTML"].includes(propertyName) + ? `Assignment to ${propertyName} is not permitted.` : undefined; }, }, @@ -89,12 +94,16 @@ export const xssRules: XssRule[] = [ }, { name: "xss/function-constructor", - evaluate: (node) => - Node.isNewExpression(node) && - Node.isIdentifier(node.getExpression()) && - node.getExpression().getText() === "Function" - ? "new Function() is not permitted." - : undefined, + evaluate: (node) => { + if (!Node.isNewExpression(node) && !Node.isCallExpression(node)) { + return; + } + const expression = node.getExpression(); + return Node.isIdentifier(expression) && + expression.getText() === "Function" + ? "Function constructor calls are not permitted." + : undefined; + }, }, { name: "xss/string-timer", diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts index c3e79d060..6ed7210d2 100644 --- a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts @@ -22,8 +22,7 @@ const reservedLayoutIds = new Set([ "locator", // must be the name of the locator layout "edit", // reserved for the editor static page ]); -// TODO: Match API-specific resource-ID and length behavior when it is finalized. -const safeIdPattern = /^[A-Za-z0-9_-]+$/; +export const safeSectionLibraryIdPattern = /^[A-Za-z0-9_-]+$/; type ParsedLayout = SectionLibraryLayout & { defaultLayoutPath: string }; /** validateSectionLibraryStructure validates that repo structure is correct for Section Libraries. */ @@ -132,7 +131,7 @@ const readSections = ( } const componentName = path.basename(entry.name, extension); - if (!safeIdPattern.test(componentName)) { + if (!safeSectionLibraryIdPattern.test(componentName)) { addIssue( sourcePath, "sections/component-name", @@ -148,7 +147,7 @@ const readSections = ( sourcePath: path.relative(rootDir, sourcePath), }; - if (!safeIdPattern.test(section.id)) { + if (!safeSectionLibraryIdPattern.test(section.id)) { addIssue(sourcePath, "sections/id", "config must define a valid id"); } else { sections.push(section); @@ -278,7 +277,7 @@ const readLayout = ( } else { throw new Error("must set a supported pageSetType"); } - if (!safeIdPattern.test(metadata.id)) { + if (!safeSectionLibraryIdPattern.test(metadata.id)) { throw new Error(`Layout ID is not valid: ${metadata.id}`); } @@ -315,7 +314,7 @@ const validateComponentIds = ( ) : path.join(libraryDirectory, "shared", "componentRegistry.ts"); - if (!safeIdPattern.test(component.id)) { + if (!safeSectionLibraryIdPattern.test(component.id)) { addIssue( filePath, "components/id", diff --git a/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts b/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts index 51ed9afcf..3d85f5f7e 100644 --- a/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts +++ b/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts @@ -5,9 +5,9 @@ import { type PageSetType, type SectionConfig, } from "../../types/sectionLibrary.ts"; +import { safeSectionLibraryIdPattern } from "../../internal/sectionLibraryValidation/stages/structure/structure.ts"; const project = new Project({ compilerOptions: { allowJs: true } }); -const safeIdPattern = /^[A-Za-z0-9_-]+$/; /** Reads the static SectionConfig metadata from one section source file. */ export const extractSectionConfigFrontmatter = ( @@ -61,7 +61,7 @@ export const extractSectionConfigFrontmatter = ( }; const id = getString("id"); - if (!id || !safeIdPattern.test(id)) { + if (!id || !safeSectionLibraryIdPattern.test(id)) { throw new Error(`${sourcePath} config must define a valid id`); } From 2fba838e823fe611c51a63e687599166717a7f6b Mon Sep 17 00:00:00 2001 From: Ben Life Date: Wed, 26 Aug 2026 16:23:17 -0400 Subject: [PATCH 5/7] increase test timeout for main section library plugin --- .../vite-plugin/section-library/sectionLibraryGenerator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts b/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts index 7904d6712..29c94d07d 100644 --- a/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts +++ b/packages/visual-editor/src/vite-plugin/section-library/sectionLibraryGenerator.test.ts @@ -192,7 +192,7 @@ describe("generateSectionLibraryFiles", () => { expect(result.manifestSource).toContain( '"purpose": [\n "LOCATION"\n ]' ); - }); + }, 10_000); it("replaces a compatibility manifest from an earlier library structure", () => { const rootDir = createLibrary(); From df8b6e793fc5359e20273e5eb4147816f0b77549 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 26 Aug 2026 20:38:49 +0000 Subject: [PATCH 6/7] Update component screenshots for visual-editor auto-screenshot-update: true --- ...default props with no nearby locations.png | Bin 8597 -> 2510 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[tablet] default props with no nearby locations.png b/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[tablet] default props with no nearby locations.png index 0d0c9fa3a3fe61a0702b73e65c982333280f99cf..0aa8c0781da3ab64feedc44a78c359f15843481d 100644 GIT binary patch literal 2510 zcmeAS@N?(olHy`uVBq!ia0y~yU_8XYz;uCw4Jg9=uE2wVfpe;-i(^OyKY!JJU}ro-LI^X%VJQZQYYYcAuraimG8n93Y)Ir~;K^o4h!~X`4UN&XF`6Al q3y0D2aI|(9tq@0>2UKklRlohrIY;i@Um;*Sjlt8^&t;ucLK6VaRg1j< literal 8597 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 HN8*12^&W&< From 3964bb5208e65f8126da325b85ea93b6629373df Mon Sep 17 00:00:00 2001 From: Ben Life Date: Thu, 27 Aug 2026 11:14:12 -0400 Subject: [PATCH 7/7] remove todo --- .../internal/sectionLibraryValidation/stages/code/importRules.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts index 72a8097be..ce840e49c 100644 --- a/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts @@ -1,7 +1,6 @@ import { builtinModules } from "node:module"; import type { ValidationIssue } from "../../types.ts"; -// TODO: Review this deny list const deniedPackages = new Set([ "child-process-promise", "cross-spawn",