diff --git a/README.md b/README.md index 1d11b3fcb..7cb6e2da7 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ PagesJS is a collection of tools that make it easy to develop on [Yext Pages](https://www.yext.com/platform/pages). It provides 2 main tools: 1. A default development server, backed by [Vite](https://vitejs.dev/), that makes local development fast and easy. -1. A Vite plugin used to bundle your assets and templates for Yext Pages. +2. A Vite plugin used to bundle your assets and templates for Yext Pages. ## Packages @@ -17,11 +17,11 @@ PagesJS is a collection of tools that make it easy to develop on [Yext Pages](ht ## Utility Functions | Function | -| -------------------------------------------------------------------------------------------------------- | --- | +| -------------------------------------------------------------------------------------------------------- | | [fetch()](https://github.com/yext/pages/blob/main/packages/pages/src/util/README.md#fetch) | | [getRuntime()](https://github.com/yext/pages/blob/main/packages/pages/src/util/README.md#getRuntime) | | [isProduction()](https://github.com/yext/pages/blob/main/packages/pages/src/util/README.md#isProduction) | -| [useDocument()](https://github.com/yext/pages/blob/main/packages/pages/src/util/README.md#useDocument) | | +| [useDocument()](https://github.com/yext/pages/blob/main/packages/pages/src/util/README.md#useDocument) | ## Development diff --git a/packages/pages/CHANGELOG.md b/packages/pages/CHANGELOG.md index f4c0fa7c3..807ef3f0f 100644 --- a/packages/pages/CHANGELOG.md +++ b/packages/pages/CHANGELOG.md @@ -1,3 +1,29 @@ +#### 1.2.0-beta.0 (2024-08-01) + +##### New Features + +* **dev:** + * override document visual configuration (#534) (f7b7e7e9) + * add pageSets to global variable in header in dev mode (#531) (b239cbb5) +* **scaffold:** + * create dynamic and static templates on scaffold (#533) (0008e583) + * create ve template on scaffold (#532) (4039f5bd) + * add prompts for scaffold template (#529) (529ee240) +* **util:** add useDocument hook (#528) (f27199ab) + +##### Bug Fixes + +* pnpm actions (083a7a54) + +##### Other Changes + +* pages@1.2.0-beta.0 (2953ca7a) +* pages@1.2.0-beta.0 (dfb04527) + +##### Refactors + +* change the type of additionalProperties in TemplateConfig to 'Record' (#527) (8fd95b19) + #### 1.1.1 (2024-06-13) ##### Bug Fixes diff --git a/packages/pages/package.json b/packages/pages/package.json index 759958ead..e1f00b9b3 100644 --- a/packages/pages/package.json +++ b/packages/pages/package.json @@ -1,6 +1,6 @@ { "name": "@yext/pages", - "version": "1.1.1", + "version": "1.2.0-beta.0", "description": "The default React development toolchain provided by Yext", "author": "sumo@yext.com", "license": "BSD-3-Clause", diff --git a/packages/pages/src/bin/loader.js b/packages/pages/src/bin/loader.js deleted file mode 100644 index 9a3dabc44..000000000 --- a/packages/pages/src/bin/loader.js +++ /dev/null @@ -1,20 +0,0 @@ -// A custom loader to append .js to react-dom/server -// -// In my quest to support both React 17 and 18 I ran into a very annoying blocker related to -// not being able to use the .js extension when importing ReactDOMServer from “react-dom/server”. -// For React 17 using Node 17, you need to use the .js extension OR you can set -// --experimental-specifier-resolution=node which we had in PagesJS awhile back. -// Unfortunately for React 18, you cannot use the .js extension at all because the file is not exported. -// It results in this error: -// Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './server.js' is not defined by "exports" in -// /path/to/node_modules/react-dom/package.json -// -// React should really export this file as it's the correct solution to use with Node without -// experimental flags. Once that happens the .js can be added back to the react-dom/server usages. -// https://github.com/facebook/react/issues/26170 -export function resolve(specifier, context, nextResolve) { - if (specifier === "react-dom/server") { - return nextResolve(specifier + ".node"); - } - return nextResolve(specifier); -} diff --git a/packages/pages/src/bin/spawn.ts b/packages/pages/src/bin/spawn.ts index 49b40c839..8227c58b9 100644 --- a/packages/pages/src/bin/spawn.ts +++ b/packages/pages/src/bin/spawn.ts @@ -6,27 +6,11 @@ import path from "path"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const filePrefix = path.sep === path.win32.sep ? "file:\\\\" : ""; const pathToPagesScript = path.resolve(__dirname, "./pages.js"); -const pathToLoader = filePrefix + path.resolve(__dirname, "./loader.js"); - -const nodeVersion = Number( - spawnSync("node", ["-v"], { encoding: "utf-8" }) - .stdout.substring(1) - .split(".")[0] -); - -const experimentalFlags = ["--experimental-vm-modules"]; -if (nodeVersion === 18) { - experimentalFlags.push("--experimental-specifier-resolution=node"); -} else { - experimentalFlags.push("--experimental-loader"); - experimentalFlags.push(pathToLoader); -} const results = spawnSync( "node", - [...experimentalFlags, pathToPagesScript, ...process.argv.slice(2)], + [pathToPagesScript, ...process.argv.slice(2)], { stdio: "inherit", } diff --git a/packages/pages/src/common/src/parsers/puckConfigParser.test.ts b/packages/pages/src/common/src/parsers/puckConfigParser.test.ts new file mode 100644 index 000000000..42c536223 --- /dev/null +++ b/packages/pages/src/common/src/parsers/puckConfigParser.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import { addDataToPuckConfig } from "./puckConfigParser.js"; + +describe("addDataToPuckConfig", () => { + it("should throw an error if the filepath is invalid", () => { + expect(() => addDataToPuckConfig("fileName", "invalid/filepath")).toThrow( + 'Filepath "invalid/filepath" is invalid.' + ); + }); + + it("correctly adds new config to the puck config file", () => { + try { + fs.writeFileSync( + "test.tsx", + `export const puckConfigs = new Map>([ + ["location", locationConfig], + ]);` + ); + addDataToPuckConfig("foo", "test.tsx"); + const modifiedContent = fs.readFileSync("test.tsx", "utf-8"); + expect(modifiedContent).toContain('["foo", fooConfig]'); + expect(modifiedContent).toContain( + `export const fooConfig: Config` + ); + } finally { + if (fs.existsSync("test.tsx")) { + fs.unlinkSync("test.tsx"); + } + } + }); +}); diff --git a/packages/pages/src/common/src/parsers/puckConfigParser.ts b/packages/pages/src/common/src/parsers/puckConfigParser.ts new file mode 100644 index 000000000..de83285a1 --- /dev/null +++ b/packages/pages/src/common/src/parsers/puckConfigParser.ts @@ -0,0 +1,43 @@ +import fs from "node:fs"; +import SourceFileParser, { createTsMorphProject } from "./sourceFileParser.js"; +import { newConfig } from "../../../scaffold/template/sampleTemplates.js"; +import { SyntaxKind } from "ts-morph"; + +/** + * Adds variables to the puck config file and adds the new config to + * the exported map. + * @param fileName template name with invalid chars and spaces removed + * @param filepath /src/puck/ve.config.ts + */ +export function addDataToPuckConfig(fileName: string, filepath: string) { + if (!fs.existsSync(filepath)) { + throw new Error(`Filepath "${filepath}" is invalid.`); + } + const parser = new SourceFileParser(filepath, createTsMorphProject()); + + const puckConfigsStatement = parser.getVariableStatement("puckConfigs"); + + const formattedTemplateName = + fileName.charAt(0).toUpperCase() + fileName.slice(1); + + const puckConfigsStartLocation = puckConfigsStatement.getStart(); + parser.insertStatement( + newConfig(formattedTemplateName, fileName), + puckConfigsStartLocation + ); + + const puckConfigsDeclaration = parser.getVariableDeclaration("puckConfigs"); + const puckConfigsInitializer = puckConfigsDeclaration.getInitializer(); + if ( + puckConfigsInitializer && + puckConfigsInitializer.getKind() === SyntaxKind.NewExpression + ) { + const newExpression = puckConfigsInitializer; + const puckConfigsArray = newExpression.getFirstChildByKindOrThrow( + SyntaxKind.ArrayLiteralExpression + ); + puckConfigsArray.addElement(`["${fileName}", ${fileName}Config]`); + } + parser.format(); + parser.save(); +} diff --git a/packages/pages/src/common/src/parsers/sourceFileParser.ts b/packages/pages/src/common/src/parsers/sourceFileParser.ts index 054f6e2be..9a25b97a4 100644 --- a/packages/pages/src/common/src/parsers/sourceFileParser.ts +++ b/packages/pages/src/common/src/parsers/sourceFileParser.ts @@ -277,4 +277,16 @@ export default class SourceFileParser { removeUnusedImports() { this.sourceFile.fixUnusedIdentifiers(); } + + format() { + this.sourceFile.formatText(); + } + + getVariableStatement(variableName: string) { + return this.sourceFile.getVariableStatementOrThrow(variableName); + } + + getVariableDeclaration(variableName: string) { + return this.sourceFile.getVariableDeclarationOrThrow(variableName); + } } diff --git a/packages/pages/src/common/src/template/hydration.ts b/packages/pages/src/common/src/template/hydration.ts index 55b567f12..fe9515ba9 100644 --- a/packages/pages/src/common/src/template/hydration.ts +++ b/packages/pages/src/common/src/template/hydration.ts @@ -1,6 +1,7 @@ import { HeadConfig, renderHeadConfigToString } from "./head.js"; import { convertToPosixPath } from "./paths.js"; import { TemplateRenderProps } from "./types.js"; +import { FeaturesConfig } from "../feature/features.js"; /** * Imports the custom hydration template and entrypoint template as modules and calls @@ -134,6 +135,7 @@ const getCommonInjectedIndexHtml = ( * @param clientHydrationString * @param indexHtml * @param appLanguage + * @param templatesConfig * @param headConfig * @returns the server template to render in the Vite dev environment */ @@ -141,14 +143,21 @@ export const getIndexTemplateDev = ( clientHydrationString: string | undefined, indexHtml: string, appLanguage: string, + templatesConfig: FeaturesConfig, headConfig?: HeadConfig ): string => { - return getCommonInjectedIndexHtml( + let commonIndex = getCommonInjectedIndexHtml( clientHydrationString, indexHtml, appLanguage, headConfig ); + commonIndex = injectIntoEndOfHead( + commonIndex, + `` + ); + + return commonIndex; }; /** diff --git a/packages/pages/src/dev/server/middleware/sendAppHTML.ts b/packages/pages/src/dev/server/middleware/sendAppHTML.ts index ec9bf8e9c..7b94bba27 100644 --- a/packages/pages/src/dev/server/middleware/sendAppHTML.ts +++ b/packages/pages/src/dev/server/middleware/sendAppHTML.ts @@ -13,6 +13,11 @@ import { getHydrationTemplateDev, getIndexTemplateDev, } from "../../../common/src/template/hydration.js"; +import { + getTemplateModules, + getTemplatesConfig, +} from "../../../generate/templates/createTemplatesJson.js"; +import { FeaturesConfig } from "../../../common/src/feature/features.js"; /** * Renders the HTML for a given {@link TemplateModuleInternal} @@ -53,6 +58,13 @@ export default async function sendAppHTML( clientServerRenderTemplates.serverRenderTemplatePath )) as ServerRenderTemplate; + const { templateModules, redirectModules } = + await getTemplateModules(projectStructure); + const templatesConfig: FeaturesConfig = getTemplatesConfig( + templateModules, + redirectModules + ); + const clientInjectedIndexHtml = getIndexTemplateDev( clientHydrationString, serverRenderTemplateModule.getIndexHtml @@ -62,6 +74,7 @@ export default async function sendAppHTML( }) : serverRenderTemplateModule.indexHtml, getLang(headConfig, props), + templatesConfig, headConfig ); diff --git a/packages/pages/src/dev/server/middleware/serverRenderRoute.ts b/packages/pages/src/dev/server/middleware/serverRenderRoute.ts index bcb96eafe..cad32efc5 100644 --- a/packages/pages/src/dev/server/middleware/serverRenderRoute.ts +++ b/packages/pages/src/dev/server/middleware/serverRenderRoute.ts @@ -1,5 +1,6 @@ import { RequestHandler } from "express-serve-static-core"; import { ViteDevServer } from "vite"; +import merge from "lodash/merge.js"; import { propsLoader } from "../ssr/propsLoader.js"; import { parseAsStaticUrl, @@ -88,6 +89,9 @@ export const serverRenderRoute = return; } + const overrides = JSON.parse(req?.body?.overrides ?? "{}"); + merge(document, overrides); + const props = await propsLoader({ templateModuleInternal, document, diff --git a/packages/pages/src/dev/server/server.ts b/packages/pages/src/dev/server/server.ts index 5432046d0..8f74a3e4e 100644 --- a/packages/pages/src/dev/server/server.ts +++ b/packages/pages/src/dev/server/server.ts @@ -191,9 +191,18 @@ export const createServer = async ( } }); + app.post( + /^\/(.+)/, + serverRenderRoute({ + vite, + dynamicGenerateData, + projectStructure, + }) + ); + // When a page is requested that is anything except the root, call our // serverRenderRoute middleware. - app.use( + app.get( /^\/(.+)/, useProdURLs ? serverRenderSlugRoute({ diff --git a/packages/pages/src/generate/features/features.ts b/packages/pages/src/generate/features/features.ts index 36d903d5b..e78384992 100644 --- a/packages/pages/src/generate/features/features.ts +++ b/packages/pages/src/generate/features/features.ts @@ -1,26 +1,13 @@ import { ProjectStructure } from "../../common/src/project/structure.js"; -import { getTemplateFilepaths } from "../../common/src/template/internal/getTemplateFilepaths.js"; import { Command } from "commander"; import { createTemplatesJson } from "../templates/createTemplatesJson.js"; import { logErrorAndExit } from "../../util/logError.js"; -import { getRedirectFilePaths } from "../../common/src/redirect/internal/getRedirectFilepaths.js"; const handler = async ({ scope }: { scope: string }): Promise => { const projectStructure = await ProjectStructure.init({ scope }); - const templateFilepaths = getTemplateFilepaths( - projectStructure.getTemplatePaths() - ); - const redirectFilepaths = getRedirectFilePaths( - projectStructure.getRedirectPaths() - ); try { - await createTemplatesJson( - templateFilepaths, - redirectFilepaths, - projectStructure, - "FEATURES" - ); + await createTemplatesJson(projectStructure, "FEATURES"); } catch (error) { logErrorAndExit(error); } diff --git a/packages/pages/src/generate/templates/createTemplatesJson.ts b/packages/pages/src/generate/templates/createTemplatesJson.ts index be8becc8f..eb60a39ff 100644 --- a/packages/pages/src/generate/templates/createTemplatesJson.ts +++ b/packages/pages/src/generate/templates/createTemplatesJson.ts @@ -21,30 +21,19 @@ import { loadRedirectModules, RedirectModuleCollection, } from "../../common/src/redirect/loader/loader.js"; +import { getTemplateFilepaths } from "../../common/src/template/internal/getTemplateFilepaths.js"; +import { getRedirectFilePaths } from "../../common/src/redirect/internal/getRedirectFilepaths.js"; /** * Loads the templates as modules and generates a templates.json or * features.json from the templates. */ export const createTemplatesJson = async ( - templateFilepaths: string[], - redirectFilepaths: string[], projectStructure: ProjectStructure, type: "FEATURES" | "TEMPLATES" ): Promise => { - const templateModules = await loadTemplateModules( - templateFilepaths, - true, - false, - projectStructure - ); - - const redirectModules = await loadRedirectModules( - redirectFilepaths, - true, - false, - projectStructure - ); + const { templateModules, redirectModules } = + await getTemplateModules(projectStructure); return createTemplatesJsonFromModule( templateModules, @@ -114,6 +103,35 @@ export const createTemplatesJsonFromModule = async ( ); }; +/** + * Helper to get the template modules from the project structure + * @param projectStructure + */ +export const getTemplateModules = async ( + projectStructure: ProjectStructure +) => { + const templateFilepaths = getTemplateFilepaths( + projectStructure.getTemplatePaths() + ); + const redirectFilepaths = getRedirectFilePaths( + projectStructure.getRedirectPaths() + ); + const templateModules = await loadTemplateModules( + templateFilepaths, + true, + false, + projectStructure + ); + + const redirectModules = await loadRedirectModules( + redirectFilepaths, + true, + false, + projectStructure + ); + return { templateModules, redirectModules }; +}; + export const getTemplatesConfig = ( templateModules: TemplateModuleCollection, redirectModules?: RedirectModuleCollection diff --git a/packages/pages/src/generate/templates/templates.ts b/packages/pages/src/generate/templates/templates.ts index d7efa2bc3..752724be1 100644 --- a/packages/pages/src/generate/templates/templates.ts +++ b/packages/pages/src/generate/templates/templates.ts @@ -1,8 +1,6 @@ import { ProjectStructure } from "../../common/src/project/structure.js"; -import { getTemplateFilepaths } from "../../common/src/template/internal/getTemplateFilepaths.js"; import { createTemplatesJson } from "./createTemplatesJson.js"; import { Command } from "commander"; -import { getRedirectFilePaths } from "../../common/src/redirect/internal/getRedirectFilepaths.js"; export const templatesHandler = async ({ scope, @@ -10,19 +8,8 @@ export const templatesHandler = async ({ scope: string; }): Promise => { const projectStructure = await ProjectStructure.init({ scope }); - const templateFilepaths = getTemplateFilepaths( - projectStructure.getTemplatePaths() - ); - const redirectFilepaths = getRedirectFilePaths( - projectStructure.getRedirectPaths() - ); - await createTemplatesJson( - templateFilepaths, - redirectFilepaths, - projectStructure, - "TEMPLATES" - ); + await createTemplatesJson(projectStructure, "TEMPLATES"); }; export const templatesCommand = (program: Command) => { diff --git a/packages/pages/src/scaffold/scaffold.ts b/packages/pages/src/scaffold/scaffold.ts index 1156918e4..c2bbbae40 100644 --- a/packages/pages/src/scaffold/scaffold.ts +++ b/packages/pages/src/scaffold/scaffold.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { modulesCommand } from "./modules/modules.js"; +import { templateCommand } from "./template/template.js"; export const scaffoldCommand = (program: Command) => { const scaffold = program @@ -16,4 +17,5 @@ export const scaffoldCommand = (program: Command) => { console.log('Must provide a subcommand of "scaffold".'); }); modulesCommand(scaffold); + templateCommand(scaffold); }; diff --git a/packages/pages/src/scaffold/template/generate.ts b/packages/pages/src/scaffold/template/generate.ts new file mode 100644 index 000000000..dd7923f0a --- /dev/null +++ b/packages/pages/src/scaffold/template/generate.ts @@ -0,0 +1,205 @@ +import prompts, { PromptObject } from "prompts"; +import { ProjectStructure } from "../../common/src/project/structure.js"; +import path from "node:path"; +import fs from "node:fs"; +import { + dynamicTemplate, + newConfigFile, + staticTemplate, + visualEditorTemplateCode, +} from "./sampleTemplates.js"; +import { addDataToPuckConfig } from "../../common/src/parsers/puckConfigParser.js"; +import { + installDependencies, + updatePackageDependency, +} from "../../upgrade/pagesUpdater.js"; +import { logErrorAndExit } from "../../util/logError.js"; + +export const generateTemplate = async ( + projectStructure: ProjectStructure +): Promise => { + const questions: PromptObject[] = [ + { + type: "text", + name: "templateName", + message: "What would you like to name your Template?", + validate: (templateName) => + validateTemplateName(templateName, projectStructure) || + "Please ensure the name provided isn't already used and is valid.", + }, + { + type: "confirm", + name: "isVisualEditor", + message: "Is this a Visual Editor template?", + initial: true, + }, + { + type: (prev) => (prev ? null : "toggle"), + name: "isDynamic", + message: "Is this a static or dynamic template?", + initial: true, + active: "Dynamic", + inactive: "Static", + }, + { + type: (prev) => (prev ? "select" : null), + name: "entityScope", + message: + "How would you like you to define the entity scope for your template?", + choices: [ + { title: "Entity Type", value: "entityTypes" }, + { title: "Saved Filter", value: "savedFilterIds" }, + { title: "Entity Id", value: "entityIds" }, + ], + }, + { + type: (prev, values) => + values.entityScope === "entityTypes" ? "list" : null, + name: "filter", + message: "Enter the entity type(s) as a comma-separated list:", + initial: "", + separator: ",", + }, + { + type: (prev, values) => + values.entityScope === "savedFilterIds" ? "list" : null, + name: "filter", + message: "Enter the saved filter ID(s) as a comma-separated list:", + initial: "", + separator: ",", + }, + { + type: (prev, values) => + values.entityScope === "entityIds" ? "list" : null, + name: "filter", + message: "Enter the entity ID(s) as a comma-separated list:", + initial: "", + separator: ",", + }, + ]; + + const response = await prompts(questions); + + if (response.isVisualEditor) { + await generateVETemplate(response, projectStructure); + } else { + response.isDynamic + ? await generateDynamicTemplate(response, projectStructure) + : await generateStaticTemplate(response.templateName, projectStructure); + } +}; + +// Returns true if templateName can be formatted into valid filename and that filename isn't being used. +const validateTemplateName = ( + templateName: string, + projectStructure: ProjectStructure +): boolean => { + const formattedFileName = formatFileName(templateName); + + // Must start with an alphabetic char + if (/^[^a-zA-Z]/.test(formattedFileName)) { + return false; + } + + const templatePath = path.join( + projectStructure.getTemplatePaths()[0].path, + formattedFileName + ); + if (fs.existsSync(templatePath)) { + return false; + } + + return true; +}; + +const formatFileName = (templateName: string): string => { + const specialCharsRemoved = templateName.replace(/[^a-zA-Z0-9\s]+/g, ""); + + const words = specialCharsRemoved.split(" "); + if (words.length === 0) { + return ""; + } + + let fileName = words[0].toLowerCase(); + for (let i = 1; i < words.length; i++) { + fileName += + words[i].charAt(0).toUpperCase() + words[i].slice(1).toLowerCase(); + } + + return fileName; +}; + +// Creates a src/templates/ file with a basic template based on provided user responses +// and adds the new VE template and config to src/ve.config.ts +const generateVETemplate = async ( + response: any, + projectStructure: ProjectStructure +) => { + const templatePath = projectStructure.getTemplatePaths()[0].path; + const templateFileName = formatFileName(response.templateName); + + fs.writeFileSync( + path.join(templatePath, `${templateFileName}.tsx`), + visualEditorTemplateCode( + templateFileName, + response.entityScope, + response.filter + ) + ); + addVETemplateToConfig(templateFileName, projectStructure); + + try { + await addVEDependencies(); + } catch (error) { + logErrorAndExit(error); + } +}; + +const addVETemplateToConfig = ( + fileName: string, + projectStructure: ProjectStructure +) => { + const configPath = path.join( + projectStructure.config.rootFolders.source, + "ve.config.ts" + ); + if (fs.existsSync(configPath)) { + addDataToPuckConfig(fileName, configPath); + } else { + fs.writeFileSync(configPath, newConfigFile(fileName)); + } +}; + +const addVEDependencies = async () => { + await updatePackageDependency("@yext/visual-editor", null, true); + await updatePackageDependency("@measured/puck", null, true); + await installDependencies(); +}; + +// Creates a file with a basic dynamic template based on provided user responses +const generateDynamicTemplate = async ( + response: any, + projectStructure: ProjectStructure +) => { + const templatePath = projectStructure.getTemplatePaths()[0].path; + const templateFileName = formatFileName(response.templateName); + + fs.writeFileSync( + path.join(templatePath, `${templateFileName}.tsx`), + dynamicTemplate(templateFileName, response.entityScope, response.filter) + ); +}; + +// Creates a file with a basic static template based templateName provided by user +const generateStaticTemplate = async ( + templateName: string, + projectStructure: ProjectStructure +) => { + const templatePath = projectStructure.getTemplatePaths()[0].path; + const templateFileName = formatFileName(templateName); + + fs.writeFileSync( + path.join(templatePath, `${templateFileName}.tsx`), + staticTemplate(templateFileName) + ); +}; diff --git a/packages/pages/src/scaffold/template/sampleTemplates.test.ts b/packages/pages/src/scaffold/template/sampleTemplates.test.ts new file mode 100644 index 000000000..1b83d6d3e --- /dev/null +++ b/packages/pages/src/scaffold/template/sampleTemplates.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + dynamicTemplate, + newConfigFile, + staticTemplate, + visualEditorTemplateCode, +} from "./sampleTemplates.js"; +import fs from "node:fs"; +import { Project } from "ts-morph"; + +describe("newConfigFile", () => { + it("confirm returned code has no warnings", () => { + const fileContent = newConfigFile("testTemplate"); + const filePath = "test.tsx"; + + try { + fs.writeFileSync(filePath, fileContent); + const project = new Project(); + project.addSourceFileAtPath(filePath); + const diagnostics = project + .getPreEmitDiagnostics() + .filter( + (d) => + !d + .getMessageText() + .toString() + .includes("Cannot find module '@measured/puck'") + ); + expect(diagnostics.length).toBe(0); + } finally { + if (fs.existsSync("test.tsx")) { + fs.unlinkSync("test.tsx"); + } + } + }); +}); + +describe("visualEditorTemplateCode", () => { + it("confirm returned code has no warnings", () => { + const fileContent = visualEditorTemplateCode( + "testTemplate", + "entityTypes", + ["location"] + ); + const filePath = "test.tsx"; + + try { + fs.writeFileSync(filePath, fileContent); + const project = new Project(); + project.addSourceFileAtPath(filePath); + const diagnostics = project + .getPreEmitDiagnostics() + .filter( + (d) => + !d.getMessageText().toString().includes("Cannot find module") && + !d.getMessageText().toString().includes("Cannot use JSX") + ); + expect(diagnostics.length).toBe(0); + } finally { + if (fs.existsSync("test.tsx")) { + fs.unlinkSync("test.tsx"); + } + } + }); +}); + +describe("staticTemplate", () => { + it("confirm returned code has no warnings", () => { + const fileContent = staticTemplate("testTemplate"); + const filePath = "test.tsx"; + + try { + fs.writeFileSync(filePath, fileContent); + const project = new Project(); + project.addSourceFileAtPath(filePath); + const diagnostics = project + .getPreEmitDiagnostics() + .filter( + (d) => + !d.getMessageText().toString().includes("Cannot find module") && + !d.getMessageText().toString().includes("Cannot use JSX") + ); + expect(diagnostics.length).toBe(0); + } finally { + if (fs.existsSync("test.tsx")) { + fs.unlinkSync("test.tsx"); + } + } + }); +}); + +describe("dynamicTemplate", () => { + it("confirm returned code has no warnings", () => { + const fileContent = dynamicTemplate("testTemplate", "entityTypes", [ + "location", + ]); + const filePath = "test.tsx"; + + try { + fs.writeFileSync(filePath, fileContent); + const project = new Project(); + project.addSourceFileAtPath(filePath); + const diagnostics = project + .getPreEmitDiagnostics() + .filter( + (d) => + !d.getMessageText().toString().includes("Cannot find module") && + !d.getMessageText().toString().includes("Cannot use JSX") + ); + expect(diagnostics.length).toBe(0); + } finally { + if (fs.existsSync("test.tsx")) { + fs.unlinkSync("test.tsx"); + } + } + }); +}); diff --git a/packages/pages/src/scaffold/template/sampleTemplates.ts b/packages/pages/src/scaffold/template/sampleTemplates.ts new file mode 100644 index 000000000..3ade4a35c --- /dev/null +++ b/packages/pages/src/scaffold/template/sampleTemplates.ts @@ -0,0 +1,209 @@ +export const visualEditorTemplateCode = ( + templateName: string, + entityScope: string, + filter: string[] +): string => { + const formattedTemplateName = + templateName.charAt(0).toUpperCase() + templateName.slice(1); + const filterCode = `${entityScope}: ${JSON.stringify(filter)},`; + const config = `${templateName}Config`; + + return `import { + Template, + GetPath, + TemplateConfig, + TemplateProps, + TemplateRenderProps, + GetHeadConfig, + HeadConfig, +} from "@yext/pages"; +import { Config, Render } from "@measured/puck"; +import { ${config} } from "../ve.config"; +import { DocumentProvider } from "../hooks/useDocument"; +import { resolveVisualEditorData } from "@yext/visual-editor"; + +export const config: TemplateConfig = { + name: "${templateName}", + stream: { + $id: "${templateName}-stream", + filter: { + ${filterCode} + }, + fields: [ + "id", + "name", + "slug", + "c_visualConfigurations", + "c_pages_layouts.c_visualConfiguration", + ], + localization: { + locales: ["en"], + }, + }, + additionalProperties: { + isVETemplate: true, + isDraft: true, + } +}; + +export const transformProps = async (data) => { + const { document } = data; + const entityConfigurations = document.c_visualConfigurations ?? []; + const entityLayoutConfigurations = document.c_pages_layouts ?? []; + const siteLayoutConfigurations = document._site?.c_visualLayouts; + const visualTemplate = resolveVisualEditorData(entityConfigurations, entityLayoutConfigurations, siteLayoutConfigurations, ${formattedTemplateName}); + return { + ...data, + document: { + ...document, + visualTemplate, + }, + }; +}; + +export const getHeadConfig: GetHeadConfig = ({ + document, +}): HeadConfig => { + return { + title: document.name, + charset: "UTF-8", + viewport: "width=device-width, initial-scale=1" + }; +}; + +export const getPath: GetPath = ({ document }) => { + return document.slug ? document.slug : "${templateName}/" + document.id; +}; + +const ${formattedTemplateName}: Template = ({ document }) => { + const { visualTemplate } = document; + return ( + + + + ); +}; + +export default ${formattedTemplateName}; +`; +}; + +export const newConfigFile = (templateName: string) => { + const formattedTemplateName = + templateName.charAt(0).toUpperCase() + templateName.slice(1); + + return `import type { Config } from "@measured/puck"; +${newConfig(formattedTemplateName, templateName)} +export const puckConfigs = new Map>([ + ["${templateName}", ${templateName}Config], +]); +`; +}; + +export const newConfig = (formattedTemplateName: string, fileName: string) => { + return ` +// eslint-disable-next-line @typescript-eslint/ban-types +type ${formattedTemplateName}Props = { +}; + +export const ${fileName}Config: Config<${formattedTemplateName}Props> = { + components: { }, + root: { }, +}; + +`; +}; + +export const dynamicTemplate = ( + templateName: string, + entityScope: string, + filter: string[] +) => { + const formattedTemplateName = + templateName.charAt(0).toUpperCase() + templateName.slice(1); + const filterCode = `${entityScope}: ${JSON.stringify(filter)},`; + + return `import { + Template, + GetPath, + TemplateConfig, + TemplateProps, + TemplateRenderProps, + GetHeadConfig, + HeadConfig, +} from "@yext/pages"; + +export const config: TemplateConfig = { + name: "${templateName}", + stream: { + $id: "${templateName}-stream", + filter: { + ${filterCode} + }, + fields: [ + "id", + "name", + "slug", + ], + localization: { + locales: ["en"], + }, + }, +}; + +export const getHeadConfig: GetHeadConfig = ({ + document, +}): HeadConfig => { + return { + title: document.name, + charset: "UTF-8", + viewport: "width=device-width, initial-scale=1" + }; +}; + +export const getPath: GetPath = ({ document }) => { + return document.slug ? document.slug : "${templateName}/" + document.id; +}; + +const ${formattedTemplateName}: Template = ({ document }) => { + return ( +
${formattedTemplateName} page
+ ); +}; + +export default ${formattedTemplateName}; +`; +}; + +export const staticTemplate = (templateName: string) => { + const formattedTemplateName = + templateName.charAt(0).toUpperCase() + templateName.slice(1); + + return `import { + GetPath, + TemplateProps, + TemplateRenderProps, + GetHeadConfig, +} from "@yext/pages"; + +export const getPath: GetPath = () => { + return "${templateName}"; +}; + +export const getHeadConfig: GetHeadConfig = () => { + return { + title: "${templateName}", + charset: "UTF-8", + viewport: "width=device-width, initial-scale=1", + }; +}; + +const ${formattedTemplateName} = (data: TemplateRenderProps) => { + return ( +
${templateName} page
+ ); +}; + +export default ${formattedTemplateName}; +`; +}; diff --git a/packages/pages/src/scaffold/template/template.ts b/packages/pages/src/scaffold/template/template.ts new file mode 100644 index 000000000..70a76adcd --- /dev/null +++ b/packages/pages/src/scaffold/template/template.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import { logErrorAndExit } from "../../util/logError.js"; +import { ProjectStructure } from "../../common/src/project/structure.js"; +import { generateTemplate } from "./generate.js"; + +const handler = async () => { + const scope = process.env.YEXT_PAGES_SCOPE; + const projectStructure = await ProjectStructure.init({ scope }); + try { + await generateTemplate(projectStructure); + } catch (error) { + logErrorAndExit(error); + } + process.exit(0); +}; + +export const templateCommand = (program: Command) => { + program + .command("template") + .description( + "Adds the required files and folder structure for a new Pages template." + ) + .action(handler); +}; diff --git a/playground/locations-site/sites-config/features.json b/playground/locations-site/sites-config/features.json index ce40d806a..03cf6e032 100644 --- a/playground/locations-site/sites-config/features.json +++ b/playground/locations-site/sites-config/features.json @@ -15,21 +15,56 @@ "streamId": "location-stream", "templateType": "JS", "entityPageSet": {} + }, + { + "name": "closed-locations", + "streamId": "closed-location-redirects", + "templateType": "JS", + "entityPageSet": {} } ], "streams": [ { "$id": "location-stream", "filter": { - "entityTypes": ["location"] + "entityTypes": [ + "location" + ] + }, + "fields": [ + "id", + "uid", + "meta", + "address", + "slug" + ], + "localization": { + "locales": [ + "en" + ], + "primary": false + }, + "source": "knowledgeGraph", + "destination": "pages" + }, + { + "$id": "closed-location-redirects", + "fields": [ + "slug" + ], + "filter": { + "entityTypes": [ + "location" + ] }, - "fields": ["id", "uid", "meta", "address", "slug"], "localization": { - "locales": ["en"], + "locales": [ + "en" + ], "primary": false }, "source": "knowledgeGraph", "destination": "pages" } ] -} +} \ No newline at end of file diff --git a/playground/multibrand-site/sites-config/sunglasses.oakley.com/features.json b/playground/multibrand-site/sites-config/sunglasses.oakley.com/features.json index aa60a939f..727c031ca 100644 --- a/playground/multibrand-site/sites-config/sunglasses.oakley.com/features.json +++ b/playground/multibrand-site/sites-config/sunglasses.oakley.com/features.json @@ -15,21 +15,54 @@ "name": "robots", "templateType": "JS", "staticPage": {} + }, + { + "name": "closed-locations", + "streamId": "closed-oakley-redirects", + "templateType": "JS", + "entityPageSet": {} } ], "streams": [ { "$id": "oakley-stream", - "fields": ["id", "name", "slug"], + "fields": [ + "id", + "name", + "slug" + ], + "filter": { + "savedFilterIds": [ + "1241548641" + ] + }, + "localization": { + "locales": [ + "en" + ], + "primary": false + }, + "source": "knowledgeGraph", + "destination": "pages" + }, + { + "$id": "closed-oakley-redirects", + "fields": [ + "slug" + ], "filter": { - "savedFilterIds": ["1241548641"] + "savedFilterIds": [ + "1241548641" + ] }, "localization": { - "locales": ["en"], + "locales": [ + "en" + ], "primary": false }, "source": "knowledgeGraph", "destination": "pages" } ] -} +} \ No newline at end of file