diff --git a/packages/visual-editor/THIRD-PARTY-NOTICES b/packages/visual-editor/THIRD-PARTY-NOTICES index bbedcb05a8..8335f0a647 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 diff --git a/packages/visual-editor/package.json b/packages/visual-editor/package.json index 45548d9898..ed103a43c6 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 d0d0ecf7df..01a249e75b 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 f6de3f0c9f..7f8188a2e2 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 0000000000..4e478fe225 --- /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 0000000000..0429e38f7c --- /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 0000000000..43eac7568b --- /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 0000000000..8f739ce281 --- /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/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 56304b9018..31ac40a7ab 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[desktop] version 36 with no nearby locations.png and b/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[desktop] version 36 with no nearby locations.png differ 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 0d0c9fa3a3..0aa8c0781d 100644 Binary files a/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[tablet] default props with no nearby locations.png and b/packages/visual-editor/src/components/testing/screenshots/NearbyLocationsSection/[tablet] default props with no nearby locations.png differ diff --git a/packages/visual-editor/src/index.ts b/packages/visual-editor/src/index.ts index e1c4076670..1655dc14f1 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 0000000000..ce840e49c2 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/importRules.ts @@ -0,0 +1,160 @@ +import { builtinModules } from "node:module"; +import type { ValidationIssue } from "../../types.ts"; + +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: "unsupported-package-import" } + | { 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("/")) { + return { kind: "local" }; + } + if (moduleSpecifier.startsWith("#")) { + return { kind: "unsupported-package-import" }; + } + 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 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[] => { + 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 0000000000..ee91b0e54a --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.test.ts @@ -0,0 +1,208 @@ +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, + evaluateUnsupportedPackageImports, +} 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("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([]); + }); + + 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"], + ["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);", + "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"], + ["Function call", "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 0000000000..c38bd7f6c6 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/validateCode.ts @@ -0,0 +1,186 @@ +import path from "node:path"; +import fs from "fs-extra"; +import { Node, Project, type SourceFile, SyntaxKind } from "ts-morph"; +import { + evaluateDeniedPackages, + evaluateForNodeBuiltins, + evaluateUnsupportedPackageImports, + 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) => [ + ...evaluateUnsupportedPackageImports(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 0000000000..983efeb028 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/code/xssRules.ts @@ -0,0 +1,149 @@ +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(); + const propertyName = Node.isPropertyAccessExpression(left) + ? left.getName() + : Node.isElementAccessExpression(left) + ? getLiteralString(left.getArgumentExpression()) + : undefined; + return assignmentOperators.has(node.getOperatorToken().getKind()) && + propertyName !== undefined && + ["innerHTML", "outerHTML"].includes(propertyName) + ? `Assignment to ${propertyName} 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) => { + 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", + 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 0000000000..2bd3f96b04 --- /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 0000000000..7e90e7ce11 --- /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 0000000000..2eb1b23d01 --- /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 0000000000..6ed7210d23 --- /dev/null +++ b/packages/visual-editor/src/internal/sectionLibraryValidation/stages/structure/structure.ts @@ -0,0 +1,502 @@ +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 +]); +export const safeSectionLibraryIdPattern = /^[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 (!safeSectionLibraryIdPattern.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 (!safeSectionLibraryIdPattern.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 (!safeSectionLibraryIdPattern.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 (!safeSectionLibraryIdPattern.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 0000000000..43806ef52b --- /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 0000000000..61e40fea4c --- /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 0000000000..9b48c1d57a --- /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 0000000000..2cf4c182cd --- /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 0000000000..3280d8dc4b --- /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 9c81d902fb..0000000000 --- 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 0000000000..7ba8795e26 --- /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 4c76d45652..0cf52439f9 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 fd29af8bdc..964d66a2bd 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 8840323cf5..98e55d4842 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 c4becc3fd3..a2c77d590a 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 e03a6afffc..a6f2fc59d3 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 bba6838947..720c17ac44 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 ad248153c0..ecd81e8148 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 4e350b4889..d51878b0d9 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 5cbb7a0a6a..fbd8381607 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 585b996e56..0904a095de 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 e3fd089921..3d85f5f7e0 100644 --- a/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts +++ b/packages/visual-editor/src/vite-plugin/section-library/sectionFrontmatter.ts @@ -4,7 +4,8 @@ import { supportedPageSetTypes, type PageSetType, type SectionConfig, -} from "../../sectionLibrary.ts"; +} from "../../types/sectionLibrary.ts"; +import { safeSectionLibraryIdPattern } from "../../internal/sectionLibraryValidation/stages/structure/structure.ts"; const project = new Project({ compilerOptions: { allowJs: true } }); @@ -58,6 +59,12 @@ export const extractSectionConfigFrontmatter = ( } return value.asKind(SyntaxKind.StringLiteral)?.getLiteralValue() ?? null; }; + + const id = getString("id"); + if (!id || !safeSectionLibraryIdPattern.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 b1dbcecce8..29c94d07de 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(); @@ -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 57bbc4d98d..6594e36ba4 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 07e5f6c500..2f885069a8 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 9283f2ea3c..965813c442 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)