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/etc/pages.api.md b/packages/pages/etc/pages.api.md index 61b7a2f3b..a3f49330a 100644 --- a/packages/pages/etc/pages.api.md +++ b/packages/pages/etc/pages.api.md @@ -343,6 +343,17 @@ export interface TemplateConfig { streamId?: string; } +// @internal +export interface TemplateManifest { + templates: { + name: string; + description: string; + exampleSiteUrl: string; + layoutRequired: boolean; + defaultLayoutData: string; + }[]; +} + // @public export interface TemplateModule< T extends TemplateProps, diff --git a/packages/pages/src/common/src/feature/stream.test.ts b/packages/pages/src/common/src/feature/stream.test.ts index 7e0d03d85..ac12220f6 100644 --- a/packages/pages/src/common/src/feature/stream.test.ts +++ b/packages/pages/src/common/src/feature/stream.test.ts @@ -1,6 +1,13 @@ -import { describe, it, expect } from "vitest"; -import { convertTemplateConfigToStreamConfig, StreamConfig } from "./stream.js"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + convertTemplateConfigToStreamConfig, + StreamConfig, + formatSiteStream, + readSiteStream, +} from "./stream.js"; import { TemplateConfigInternal } from "../template/internal/types.js"; +import fs from "fs"; +import { ProjectStructure } from "../project/structure.js"; describe("stream", () => { it("returns void if no stream", async () => { @@ -45,3 +52,116 @@ describe("stream", () => { expect(streamConfig).toEqual(expectedStreamConfig); }); }); + +const siteStreamPath = "foo/bar"; + +describe("formatSiteStream", () => { + it("errors and exits when there are multiple entityIds", () => { + const testJson = { + $id: "site-stream", + filter: { entityIds: ["1234", "123"] }, + localization: { locales: ["en"] }, + fields: [], + }; + const mockExit = vi + .spyOn(process, "exit") + .mockImplementation(() => undefined as never); + formatSiteStream(testJson, siteStreamPath); + expect(mockExit).toHaveBeenCalledWith(1); + }); + + it("returns expected entityId", () => { + const testJson = { + $id: "my-site-stream", + filter: { entityIds: ["1234"] }, + localization: { locales: ["en"] }, + fields: [], + }; + const expectedJson = { + id: "my-site-stream", + entityId: "1234", + localization: { locales: ["en"] }, + fields: [], + }; + expect(formatSiteStream(testJson, siteStreamPath)).toEqual(expectedJson); + }); + + it("returns expected full config", () => { + const testJson = { + $id: "site-stream-123", + fields: ["meta", "name"], + filter: { entityIds: ["1234"] }, + source: "foo", + localization: { locales: ["en"] }, + }; + const expectedJson = { + id: "site-stream-123", + entityId: "1234", + fields: ["meta", "name"], + localization: { locales: ["en"] }, + }; + expect(formatSiteStream(testJson, siteStreamPath)).toEqual(expectedJson); + }); +}); + +describe("readSiteStream", () => { + afterEach(() => { + if (fs.existsSync("config.yaml")) { + fs.rmSync("config.yaml"); + } + if (fs.existsSync("sites-config/site-stream.json")) { + fs.rmSync("sites-config", { recursive: true, force: true }); + } + }); + + const projectStructure = new ProjectStructure({}); + + it("reads siteStream from config.yaml", () => { + const path = "config.yaml"; + fs.writeFileSync( + path, + `siteStream: + id: site-stream + entityId: site-stream-from-yaml + fields: + - c_visualLayouts.c_visualConfiguration + localization: + locales: + - en + ` + ); + const siteStream = readSiteStream(projectStructure); + expect(siteStream).toEqual({ + id: "site-stream", + entityId: "site-stream-from-yaml", + fields: ["c_visualLayouts.c_visualConfiguration"], + localization: { locales: ["en"] }, + }); + }); + + it("reads siteStream from sites-config/sites-stream.json", () => { + projectStructure.getSitesConfigPath; + const path = "sites-config/site-stream.json"; + fs.mkdirSync("sites-config"); + fs.writeFileSync( + path, + ` + { + "$id": "site-stream", + "filter": { + "entityIds": ["site-stream-from-json"] + }, + "fields": ["c_visualLayouts.c_visualConfiguration"], + "localization": {"locales": ["en"]} + } + ` + ); + const siteStream = readSiteStream(projectStructure); + expect(siteStream).toEqual({ + id: "site-stream", + entityId: "site-stream-from-json", + fields: ["c_visualLayouts.c_visualConfiguration"], + localization: { locales: ["en"] }, + }); + }); +}); diff --git a/packages/pages/src/common/src/feature/stream.ts b/packages/pages/src/common/src/feature/stream.ts index 0a69e3e41..b2c8131c9 100644 --- a/packages/pages/src/common/src/feature/stream.ts +++ b/packages/pages/src/common/src/feature/stream.ts @@ -1,6 +1,13 @@ +import fs from "fs"; +import path from "path"; +import YAML from "yaml"; + +import { readJsonSync } from "../../../upgrade/migrateConfig.js"; import { TemplateConfigInternal } from "../template/internal/types.js"; import { RedirectConfigInternal } from "../redirect/internal/types.js"; +import { ProjectStructure } from "../project/structure.js"; import { logErrorAndExit } from "../../../util/logError.js"; +import { Stream } from "../template/types.js"; /** * The shape of data that represents a stream configuration. @@ -39,6 +46,38 @@ export interface StreamConfig { }; } +/** + * The shape of data that represents a site stream. + * Similar to {@link StreamConfig} but there can only be one entityId. + */ +export interface SiteStream { + /** Identifies the stream */ + id: string; + /** The entity id of the site stream */ + entityId: string; + /** The fields to apply to the stream */ + fields: string[]; + /** The localization used by the filter. Either set primary: true or specify a locales array. */ + localization: + | { + /** The entity profiles languages to apply to the stream. */ + locales: string[]; + primary?: never; + } + | { + /** Use the primary profile language. */ + primary: true; + locales?: never; + }; + /** The transformation to apply to the stream */ + transform?: { + /** The option fields to be expanded to include the display fields, numeric values, and selected boolean */ + expandOptionFields?: string[]; + /** The option fields to be replaced with display names */ + replaceOptionValuesWithDisplayNames?: string[]; + }; +} + /** * Converts a {@link TemplateConfig.config.stream} into a valid {@link StreamConfig}. */ @@ -85,3 +124,57 @@ export const convertRedirectConfigToStreamConfig = ( }; } }; + +/** + * Loads the site stream specified in config.yaml or site-stream.json into a {@link SiteStream}. + */ +export const readSiteStream = ( + projectStructure: ProjectStructure +): SiteStream | undefined => { + // read site stream from deprecated sites-config directory if it exists + const siteStreamJsonPath = path.resolve( + projectStructure.getSitesConfigPath().path, + projectStructure.config.sitesConfigFiles.siteStream + ); + if (fs.existsSync(siteStreamJsonPath)) { + const sitesJson = readJsonSync(siteStreamJsonPath); + return formatSiteStream(sitesJson, siteStreamJsonPath); + } + + // read site stream from config.yaml + const configYamlPath = projectStructure.getConfigYamlPath().getAbsolutePath(); + if (fs.existsSync(configYamlPath)) { + const yamlDoc = YAML.parse(fs.readFileSync(configYamlPath, "utf-8")); + if (yamlDoc.siteStream) { + yamlDoc.siteStream.entityId = yamlDoc.siteStream?.entityId?.toString(); + return yamlDoc.siteStream; + } + } + + return; +}; + +/** + * Converts the deprecated format of a siteStream specified in site-stream.json into + * the format of a siteStream specified in config.yaml + */ +export const formatSiteStream = ( + sitesJson: Stream, + siteStreamPath: string +): SiteStream => { + let entityId; + if (sitesJson.filter?.entityIds && sitesJson.filter?.entityIds.length === 1) { + entityId = sitesJson.filter.entityIds[0]; + } else if (sitesJson.filter?.entityIds) { + logErrorAndExit( + `Unable to migrate ${siteStreamPath} due to multiple entityIds` + ); + } + + return { + id: sitesJson.$id, // Replace $id with id and keeps id in the first position + entityId: entityId?.toString() || "", + localization: sitesJson.localization, + fields: sitesJson.fields, + }; +}; 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..1f928ac6b --- /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 componentRegistry = 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..2b0b71d14 --- /dev/null +++ b/packages/pages/src/common/src/parsers/puckConfigParser.ts @@ -0,0 +1,44 @@ +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/ve.config.tsx + */ +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("componentRegistry"); + + const formattedTemplateName = + fileName.charAt(0).toUpperCase() + fileName.slice(1); + + const puckConfigsStartLocation = puckConfigsStatement.getStart(); + parser.insertStatement( + newConfig(formattedTemplateName, fileName), + puckConfigsStartLocation + ); + + const puckConfigsDeclaration = + parser.getVariableDeclaration("componentRegistry"); + 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.test.ts b/packages/pages/src/common/src/parsers/sourceFileParser.test.ts index 4f4fc9608..99cfc10d5 100644 --- a/packages/pages/src/common/src/parsers/sourceFileParser.test.ts +++ b/packages/pages/src/common/src/parsers/sourceFileParser.test.ts @@ -8,13 +8,13 @@ describe("getDefaultExport", () => { const parser = createParser( `export const no = false; export default function test() {}` ); - const defaultExport = parser.getDefaultExport(); + const defaultExport = parser.getDefaultExportName(); expect(defaultExport).toBe("test"); }); it("correctly gets default export's name when variable", () => { const parser = createParser(`const test = 5; export default test`); - const defaultExport = parser.getDefaultExport(); + const defaultExport = parser.getDefaultExportName(); expect(defaultExport).toBe("test"); }); }); @@ -23,7 +23,7 @@ describe("addDefaultExport", () => { it("correctly adds default export to file", () => { const parser = createParser(``); parser.addDefaultExport("test"); - expect(parser.getDefaultExport()).toBe("test"); + expect(parser.getDefaultExportName()).toBe("test"); }); }); @@ -216,6 +216,24 @@ describe("removeUnusedImports", () => { }); }); +describe("ensureNamedImport", () => { + it("correctly adds a new named import and module", () => { + const parser = createParser(""); + parser.ensureNamedImport("@testModule", "testFunction"); + expect(parser.getAllText()).toContain( + 'import { testFunction } from "@testModule";' + ); + }); + + it("correctly adds a second named import to an existing module", () => { + const parser = createParser('import { testFunction } from "@testModule";'); + parser.ensureNamedImport("@testModule", "testFunction2"); + expect(parser.getAllText()).toContain( + 'import { testFunction, testFunction2 } from "@testModule";' + ); + }); +}); + function createParser(sourceCode: string) { const filepath = path.resolve(__dirname, "test.tsx"); const { project } = createTestSourceFile(sourceCode, filepath); diff --git a/packages/pages/src/common/src/parsers/sourceFileParser.ts b/packages/pages/src/common/src/parsers/sourceFileParser.ts index 054f6e2be..63442c25d 100644 --- a/packages/pages/src/common/src/parsers/sourceFileParser.ts +++ b/packages/pages/src/common/src/parsers/sourceFileParser.ts @@ -6,6 +6,7 @@ import { OptionalKind, ImportAttributeStructure, VariableDeclaration, + ts, } from "ts-morph"; import typescript from "typescript"; @@ -102,7 +103,7 @@ export default class SourceFileParser { * getDefaultExport parses the source file for a default export. * @returns the default export's name */ - getDefaultExport(): string { + getDefaultExportName(): string { const defaultExportSymbol = this.sourceFile.getDefaultExportSymbol(); if (!defaultExportSymbol) { return ""; @@ -132,6 +133,10 @@ export default class SourceFileParser { }); } + getSourceFile() { + return this.sourceFile; + } + /** * @returns all imports from source file */ @@ -277,4 +282,45 @@ 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); + } + + /** + * Checks if a named import exists in the source file and adds it if it doesn't. + */ + ensureNamedImport(moduleName: string, importName: string) { + const imports = this.sourceFile.getImportDeclarations(); + + // Check if there's already an import for the specified module + const targetImport = imports.find( + (imp) => imp.getModuleSpecifierValue() === moduleName + ); + + if (targetImport) { + // Check if the named import already exists + const namedImports = targetImport.getNamedImports(); + const hasImport = namedImports.some((ni) => ni.getName() === importName); + + if (!hasImport) { + // Add the named import if it doesn't exist + targetImport.addNamedImport(importName); + } + } else { + // Add a new import declaration if the module is not imported + this.sourceFile.addImportDeclaration({ + moduleSpecifier: moduleName, + namedImports: [importName], + }); + } + } } diff --git a/packages/pages/src/common/src/parsers/tailwindConfigParser.test.ts b/packages/pages/src/common/src/parsers/tailwindConfigParser.test.ts new file mode 100644 index 000000000..43cb460dd --- /dev/null +++ b/packages/pages/src/common/src/parsers/tailwindConfigParser.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import fs from "node:fs"; +import { + addThemeConfigToTailwind, + tailwindConfigFilename, +} from "./tailwindConfigParser.js"; + +describe("addDataToPuckConfig", () => { + it("should throw an error if the filepath is invalid", () => { + expect(() => addThemeConfigToTailwind("invalid/filepath")).toThrow( + 'Filepath "invalid/filepath" is invalid.' + ); + }); + + it("correctly adds the theme config to the tailwind config", () => { + try { + fs.writeFileSync( + tailwindConfigFilename, + `import type { Config } from "tailwindcss"; + +export default { + content: [ + "./src/**/*.{html,js,jsx,ts,tsx}", + "./node_modules/@yext/visual-editor/dist/**/*.js", + ], + plugins: [], +} satisfies Config; +` + ); + addThemeConfigToTailwind(tailwindConfigFilename); + const modifiedContent = fs.readFileSync(tailwindConfigFilename, "utf-8"); + expect(modifiedContent).toContain(`theme: { + extend: themeResolver({}, themeConfig) + }`); + expect(modifiedContent).toContain( + `import { themeConfig } from "./theme.config";` + ); + expect(modifiedContent).toContain( + `import { themeResolver } from "@yext/visual-editor";` + ); + } finally { + if (fs.existsSync(tailwindConfigFilename)) { + fs.unlinkSync(tailwindConfigFilename); + } + } + }); +}); diff --git a/packages/pages/src/common/src/parsers/tailwindConfigParser.ts b/packages/pages/src/common/src/parsers/tailwindConfigParser.ts new file mode 100644 index 000000000..d83537a9f --- /dev/null +++ b/packages/pages/src/common/src/parsers/tailwindConfigParser.ts @@ -0,0 +1,117 @@ +import fs from "node:fs"; +import SourceFileParser, { createTsMorphProject } from "./sourceFileParser.js"; +import { + ObjectLiteralExpression, + PropertyAssignment, + SyntaxKind, +} from "ts-morph"; + +export const tailwindConfigFilename = "tailwind.config.ts"; + +/** + * Adds the themeConfig to tailwind.config.ts if it's not there. + */ +export const addThemeConfigToTailwind = (tailwindConfigPath: string) => { + if (!fs.existsSync(tailwindConfigPath)) { + throw new Error(`Filepath "${tailwindConfigPath}" is invalid.`); + } + + const parser = new SourceFileParser( + tailwindConfigPath, + createTsMorphProject() + ); + + const defaultExport = parser + .getSourceFile() + .getFirstDescendantByKind(SyntaxKind.ExportAssignment); + + if (!defaultExport) { + throw new Error("Default export not found in the file."); + } + + // Get the initializer of the default export + const exportInitializer = defaultExport.getExpression(); + + if (!exportInitializer) { + throw new Error("No initializer found for the default export."); + } + + // If the initializer includes a `satisfies` clause, extract the object literal + let configObject: ObjectLiteralExpression | undefined; + if ( + exportInitializer.getKind() === SyntaxKind.AsExpression || + exportInitializer.getKind() === SyntaxKind.SatisfiesExpression + ) { + const innerExpression = exportInitializer.getChildAtIndex(0); // Extract left-hand side + if (innerExpression.getKind() === SyntaxKind.ObjectLiteralExpression) { + configObject = innerExpression.asKind(SyntaxKind.ObjectLiteralExpression); + } + } else if ( + exportInitializer.getKind() === SyntaxKind.ObjectLiteralExpression + ) { + configObject = exportInitializer.asKind(SyntaxKind.ObjectLiteralExpression); + } + + if (!configObject) { + throw new Error("Config object not found in the default export."); + } + + // Locate the "theme" property + let themeProperty = configObject.getProperty("theme") as PropertyAssignment; + + if ( + !themeProperty || + themeProperty.getKind() !== SyntaxKind.PropertyAssignment + ) { + // Add the "theme" property if it doesn't exist + themeProperty = configObject.addPropertyAssignment({ + name: "theme", + initializer: `{ + extend: themeResolver({}, themeConfig) + }`, + }); + } + + // Get the value of the theme property + const themeValue = (themeProperty as PropertyAssignment).getInitializer(); + + if (!themeValue) { + throw new Error("Unable to determine theme initializer."); + } + + // Check if the theme value is a function call or object literal + if (themeValue.getKind() === SyntaxKind.CallExpression) { + // The theme is resolved using a function call + (themeProperty as PropertyAssignment).setInitializer(`{ + extend: themeResolver({}, themeConfig) + }`); + } else if (themeValue.getKind() === SyntaxKind.ObjectLiteralExpression) { + // The theme is a regular object literal + const extendProperty = (themeValue as ObjectLiteralExpression).getProperty( + "extend" + ); + if ( + extendProperty && + extendProperty.getKind() === SyntaxKind.PropertyAssignment + ) { + // Modify or replace the "extend" property + (extendProperty as PropertyAssignment).setInitializer( + `themeResolver({}, themeConfig)` + ); + } else { + // Add "extend" if it doesn't exist + (themeValue as ObjectLiteralExpression).addPropertyAssignment({ + name: "extend", + initializer: `themeResolver({}, themeConfig)`, + }); + } + } else { + throw new Error("Unsupported initializer type for the theme property."); + } + + parser.ensureNamedImport("./theme.config", "themeConfig"); + parser.ensureNamedImport("@yext/visual-editor", "themeResolver"); + + parser.format(); + parser.save(); +}; diff --git a/packages/pages/src/common/src/parsers/templateParser.ts b/packages/pages/src/common/src/parsers/templateParser.ts index 8d8374d24..f1f29ae55 100644 --- a/packages/pages/src/common/src/parsers/templateParser.ts +++ b/packages/pages/src/common/src/parsers/templateParser.ts @@ -31,7 +31,7 @@ export default class TemplateParser { * @return output and newSfp. */ makeClientTemplateFromSfp(newSfp: SourceFileParser) { - const defaultExportName = this.originalSfp.getDefaultExport(); + const defaultExportName = this.originalSfp.getDefaultExportName(); const childExpressionNames: string[] = [defaultExportName]; this.originalSfp.getChildExpressions( defaultExportName, diff --git a/packages/pages/src/common/src/project/structure.ts b/packages/pages/src/common/src/project/structure.ts index 7b7ef13aa..41434dbe0 100644 --- a/packages/pages/src/common/src/project/structure.ts +++ b/packages/pages/src/common/src/project/structure.ts @@ -86,6 +86,8 @@ export interface DistConfigFiles { export interface RootFiles { /** The config.yaml file */ config: string; + /** The .template-manifest.json file for in-platform page sets */ + templateManifest: string; } /** @@ -180,6 +182,7 @@ const defaultProjectStructureConfig: ProjectStructureConfig = { }, rootFiles: { config: "config.yaml", + templateManifest: ".template-manifest.json", }, envVarConfig: { envVarDir: "", @@ -313,6 +316,18 @@ export class ProjectStructure { ); }; + /** + * @returns the {@link Path} to the .template-manifest.json file, taking scope into account. + */ + getTemplateManifestPath = () => { + return new Path( + pathLib.join( + this.config.scope ?? "", + this.config.rootFiles.templateManifest + ) + ); + }; + /** * @returns the {@link Path} to the modules folder, taking scope into account. * If moduleName is provided, returns the path to that modules folder. If a scope is diff --git a/packages/pages/src/common/src/template/hydration.ts b/packages/pages/src/common/src/template/hydration.ts index 55b567f12..48bf55871 100644 --- a/packages/pages/src/common/src/template/hydration.ts +++ b/packages/pages/src/common/src/template/hydration.ts @@ -1,6 +1,8 @@ import { HeadConfig, renderHeadConfigToString } from "./head.js"; import { convertToPosixPath } from "./paths.js"; import { TemplateRenderProps } from "./types.js"; +import { FeaturesConfig } from "../feature/features.js"; +import { SiteStream } from "../feature/stream.js"; /** * Imports the custom hydration template and entrypoint template as modules and calls @@ -134,6 +136,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 +144,22 @@ export const getIndexTemplateDev = ( clientHydrationString: string | undefined, indexHtml: string, appLanguage: string, + templatesConfig: FeaturesConfig, + siteStream: SiteStream | undefined, headConfig?: HeadConfig ): string => { - return getCommonInjectedIndexHtml( + let commonIndex = getCommonInjectedIndexHtml( clientHydrationString, indexHtml, appLanguage, headConfig ); + commonIndex = injectIntoEndOfHead( + commonIndex, + `` + ); + + return commonIndex; }; /** diff --git a/packages/pages/src/common/src/template/loader/loader.test.ts b/packages/pages/src/common/src/template/loader/loader.test.ts index 8cb6da54a..cc1016017 100644 --- a/packages/pages/src/common/src/template/loader/loader.test.ts +++ b/packages/pages/src/common/src/template/loader/loader.test.ts @@ -4,6 +4,7 @@ import path from "path"; import { loadTemplateModules } from "./loader.js"; import { convertToPosixPath } from "../paths.js"; import { ProjectStructure } from "../../project/structure.js"; +import fs from "node:fs"; describe("loadTemplateModules", () => { it("loads and transpiles raw templates", async () => { @@ -35,4 +36,48 @@ describe("loadTemplateModules", () => { expect(templateModules.get("template")?.config.name).toEqual("template"); }); + + it("ignores in-platform page set templates", async () => { + try { + const templateFiles = glob.sync([ + convertToPosixPath( + path.join(process.cwd(), "tests/fixtures/inPlatformTemplate.tsx") + ), + convertToPosixPath( + path.join(process.cwd(), "tests/fixtures/template.tsx") + ), + ]); + + const testTemplateManifest = { + templates: [ + { + name: "inPlatformTemplate", + description: "test", + exampleSiteUrl: "", + layoutRequired: true, + defaultLayoutData: '{"root":{}, "zones":{}, "content":[]}', + }, + ], + }; + + fs.writeFileSync( + ".template-manifest.json", + JSON.stringify(testTemplateManifest) + ); + + const templateModules = await loadTemplateModules( + templateFiles, + false, + false, + new ProjectStructure() + ); + + expect(templateModules.get("inPlatformTemplate")).toBeUndefined(); + expect(templateModules.get("template")?.config.name).toEqual("template"); + } finally { + if (fs.existsSync(".template-manifest.json")) { + fs.unlinkSync(".template-manifest.json"); + } + } + }); }); diff --git a/packages/pages/src/common/src/template/loader/loader.ts b/packages/pages/src/common/src/template/loader/loader.ts index 0454ab998..339c42de9 100644 --- a/packages/pages/src/common/src/template/loader/loader.ts +++ b/packages/pages/src/common/src/template/loader/loader.ts @@ -6,7 +6,8 @@ import { ProjectStructure } from "../../project/structure.js"; import { loadModules } from "../../loader/vite.js"; import { ViteDevServer } from "vite"; import { loadViteModule } from "../../../../dev/server/ssr/loadViteModule.js"; -import { TemplateModule } from "../types.js"; +import { TemplateManifest, TemplateModule } from "../types.js"; +import fs from "node:fs"; /** * Loads all templates in the project. @@ -29,6 +30,21 @@ export const loadTemplateModules = async ( projectStructure ); + const templateManifestPath = projectStructure + .getTemplateManifestPath() + .getAbsolutePath(); + + let inPlatformTemplateNames: string[] = []; + if (fs.existsSync(templateManifestPath)) { + const templateManifest = JSON.parse( + fs.readFileSync(templateManifestPath, "utf-8") + ) as TemplateManifest; + + inPlatformTemplateNames = templateManifest.templates.map( + (templateInfo) => templateInfo.name + ); + } + const importedTemplateModules = [] as TemplateModuleInternal[]; for (const importedModule of importedModules) { const templateModuleInternal = @@ -38,10 +54,15 @@ export const loadTemplateModules = async ( adjustForFingerprintedAsset ); - importedTemplateModules.push({ - ...templateModuleInternal, - path: importedModule.path, - }); + // ignore templates marked for in-platform page set use by .template-manifest.json + if ( + !inPlatformTemplateNames.includes(templateModuleInternal.templateName) + ) { + importedTemplateModules.push({ + ...templateModuleInternal, + path: importedModule.path, + }); + } } return importedTemplateModules.reduce((prev, module) => { diff --git a/packages/pages/src/common/src/template/types.ts b/packages/pages/src/common/src/template/types.ts index d98ecb19a..d1a538584 100644 --- a/packages/pages/src/common/src/template/types.ts +++ b/packages/pages/src/common/src/template/types.ts @@ -307,3 +307,25 @@ export interface PageContext> { /** The template to render */ Page: Template; } + +/** + * The type of the .template-manifest.json file, + * which is used to specify in-platform page sets. + * + * @internal + */ +export interface TemplateManifest { + /** The list of templates to use for in-platform page sets. */ + templates: { + /** The file name of the template, without the extension */ + name: string; + /** A description of the template. */ + description: string; + /** A URL of an example site that uses the template. */ + exampleSiteUrl: string; + /** Determines if a layout is automatically created during page set creation */ + layoutRequired: boolean; + /** Stringified Puck data to use when a new layout is created */ + defaultLayoutData: string; + }[]; +} diff --git a/packages/pages/src/dev/server/middleware/sendAppHTML.ts b/packages/pages/src/dev/server/middleware/sendAppHTML.ts index ec9bf8e9c..b33bfc029 100644 --- a/packages/pages/src/dev/server/middleware/sendAppHTML.ts +++ b/packages/pages/src/dev/server/middleware/sendAppHTML.ts @@ -13,7 +13,12 @@ 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"; +import { readSiteStream } from "../../../common/src/feature/stream.js"; /** * Renders the HTML for a given {@link TemplateModuleInternal} * and {@link TemplateRenderProps}, and sends it back to the Response. @@ -53,6 +58,15 @@ export default async function sendAppHTML( clientServerRenderTemplates.serverRenderTemplatePath )) as ServerRenderTemplate; + const { templateModules, redirectModules } = + await getTemplateModules(projectStructure); + const templatesConfig: FeaturesConfig = getTemplatesConfig( + templateModules, + redirectModules + ); + + const siteStream = readSiteStream(projectStructure); + const clientInjectedIndexHtml = getIndexTemplateDev( clientHydrationString, serverRenderTemplateModule.getIndexHtml @@ -62,6 +76,8 @@ export default async function sendAppHTML( }) : serverRenderTemplateModule.indexHtml, getLang(headConfig, props), + templatesConfig, + siteStream, 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 f5930fb49..7a25d32a3 100644 --- a/packages/pages/src/dev/server/server.ts +++ b/packages/pages/src/dev/server/server.ts @@ -192,9 +192,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/dev/server/ssr/generateTestData.ts b/packages/pages/src/dev/server/ssr/generateTestData.ts index 66a3dc783..e7ef50470 100644 --- a/packages/pages/src/dev/server/ssr/generateTestData.ts +++ b/packages/pages/src/dev/server/ssr/generateTestData.ts @@ -7,8 +7,6 @@ import { UPGRADE_MESSAGE_LINE_BEGIN, UPGRADE_INSTRUCTIONS_LINE_BEGIN, } from "./constants.js"; -import path from "path"; -import fs from "fs"; import { ProjectStructure } from "../../../common/src/project/structure.js"; import { getTemplateFilepathsFromProjectStructure } from "../../../common/src/template/internal/getTemplateFilepaths.js"; import { TemplateModuleInternal } from "../../../common/src/template/internal/types.js"; @@ -19,7 +17,7 @@ import { loadTemplateModuleCollectionUsingVite, } from "../../../common/src/template/loader/loader.js"; import runSubprocess from "../../../util/runSubprocess.js"; -import YAML from "yaml"; +import { readSiteStream } from "../../../common/src/feature/stream.js"; /** * generateTestData will run yext pages generate-test-data and return true in @@ -208,28 +206,6 @@ async function spawnTestDataCommand( }); } -const getSiteStream = (projectStructure: ProjectStructure) => { - const siteStreamPath = path.resolve( - projectStructure.getSitesConfigPath().path, - projectStructure.config.sitesConfigFiles.siteStream - ); - - if (fs.existsSync(siteStreamPath)) { - return prepareJsonForCmd( - JSON.parse(fs.readFileSync(siteStreamPath).toString()) - ); - } - - const configYamlPath = projectStructure.getConfigYamlPath().getAbsolutePath(); - if (fs.existsSync(configYamlPath)) { - const yamlDoc = YAML.parse(fs.readFileSync(configYamlPath, "utf-8")); - if (yamlDoc.siteStream) { - yamlDoc.siteStream.entityId = yamlDoc.siteStream?.entityId?.toString(); - return prepareJsonForCmd(yamlDoc.siteStream); - } - } -}; - const getCommonArgs = ( featuresConfig: FeaturesConfig, projectStructure: ProjectStructure @@ -238,7 +214,7 @@ const getCommonArgs = ( args.push("--featuresConfig", prepareJsonForCmd(featuresConfig)); - const siteStream = getSiteStream(projectStructure); + const siteStream = prepareJsonForCmd(readSiteStream(projectStructure)); if (siteStream) { args.push("--siteStreamConfig", siteStream); } 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..470218b1a --- /dev/null +++ b/packages/pages/src/scaffold/template/generate.ts @@ -0,0 +1,289 @@ +import prompts, { PromptObject } from "prompts"; +import { ProjectStructure } from "../../common/src/project/structure.js"; +import path from "node:path"; +import fs from "node:fs"; +import { + buildSchemaUtil, + defaultLayoutData, + dynamicTemplate, + newConfigFile, + staticTemplate, + tailwindConfig, + veThemeConfig, + 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"; +import { addThemeConfigToTailwind } from "../../common/src/parsers/tailwindConfigParser.js"; +import { TemplateManifest } from "../../common/src/template/types.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.", + }, + // TODO: Add this back when hybrid VE templates are supported + // { + // type: "confirm", + // name: "isVisualEditor", + // message: "Is this a Visual Editor template?", + // initial: true, + // }, + { + // type: (prev) => (prev ? null : "toggle"), + type: "toggle", + name: "isDynamic", + message: "Is this a static or dynamic template?", + initial: true, + active: "Dynamic", + inactive: "Static", + }, + ]; + + const entityScopeQuestions: PromptObject[] = [ + { + type: "select", + 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 { + if (response.isDynamic) { + const subsequentResponse = await prompts(entityScopeQuestions); + await generateDynamicTemplate( + { ...response, ...subsequentResponse }, + projectStructure + ); + } else { + 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.tsx +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) + ); + addVETemplateToConfig(templateFilename, projectStructure); + addVEThemeConfig(); + addTailwindConfig(); + addBuildSchemaUtil(projectStructure); + addTemplateManifest(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.tsx" + ); + if (fs.existsSync(configPath)) { + addDataToPuckConfig(fileName, configPath); + } else { + fs.writeFileSync(configPath, newConfigFile(fileName)); + } +}; + +const addVEThemeConfig = () => { + const themeConfigPath = path.join("ve.config.tsx"); + if (fs.existsSync(themeConfigPath)) { + return; + } + + fs.writeFileSync(themeConfigPath, veThemeConfig); +}; + +const addTailwindConfig = () => { + const tailwindConfigPath = path.join("tailwind.config.ts"); + if (fs.existsSync(tailwindConfigPath)) { + addThemeConfigToTailwind(tailwindConfigPath); + return; + } + + fs.writeFileSync(tailwindConfigPath, tailwindConfig); +}; + +const addBuildSchemaUtil = (projectStructure: ProjectStructure) => { + const buildSchemaUtilPath = path.join( + projectStructure.config.rootFolders.source, + "utils", + "buildSchema.ts" + ); + if (fs.existsSync(buildSchemaUtilPath)) { + return; + } + + fs.writeFileSync(buildSchemaUtilPath, buildSchemaUtil); +}; + +const addTemplateManifest = ( + templateName: string, + projectStructure: ProjectStructure +) => { + const templateManifestPath = projectStructure + .getTemplateManifestPath() + .getAbsolutePath(); + + let templateManifest: TemplateManifest; + if (fs.existsSync(templateManifestPath)) { + templateManifest = JSON.parse( + fs.readFileSync(templateManifestPath, "utf-8") + ) as TemplateManifest; + } else { + templateManifest = { templates: [] }; + } + + templateManifest.templates.push({ + name: templateName, + description: `Use this template to generate pages for each of your ${templateName}.`, + exampleSiteUrl: "", + layoutRequired: true, + defaultLayoutData: defaultLayoutData, + }); + + fs.writeFileSync( + templateManifestPath, + JSON.stringify(templateManifest, null, 2) + ); +}; + +const addVEDependencies = async () => { + await updatePackageDependency("@yext/visual-editor", null, true); + await updatePackageDependency( + "@measured/puck", + { specificVersion: "0.17.1" }, + 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..abd211be2 --- /dev/null +++ b/packages/pages/src/scaffold/template/sampleTemplates.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { + dynamicTemplate, + newConfigFile, + staticTemplate, + visualEditorTemplateCode, +} from "./sampleTemplates.js"; +import fs from "node:fs"; +import { Diagnostic, Project, ts } from "ts-morph"; + +const filterOutModuleErrors = (d: Diagnostic) => { + return ( + !d.getMessageText().toString().includes("Cannot find module") && + !d.getMessageText().toString().includes("Cannot use JSX") + ); +}; + +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(filterOutModuleErrors); + 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"); + const filePath = "test.tsx"; + + try { + fs.writeFileSync(filePath, fileContent); + const project = new Project(); + project.addSourceFileAtPath(filePath); + const diagnostics = project + .getPreEmitDiagnostics() + .filter(filterOutModuleErrors); + 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(filterOutModuleErrors); + 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(filterOutModuleErrors); + 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..76d4cada6 --- /dev/null +++ b/packages/pages/src/scaffold/template/sampleTemplates.ts @@ -0,0 +1,618 @@ +export const visualEditorTemplateCode = (templateName: string): string => { + const formattedTemplateName = + templateName.charAt(0).toUpperCase() + templateName.slice(1); + const config = `${templateName}Config`; + + return `import "@yext/visual-editor/style.css"; +import { + Template, + GetPath, + TemplateProps, + TemplateRenderProps, + GetHeadConfig, + HeadConfig, +} from "@yext/pages"; +import { Render } from "@measured/puck"; +import { ${config} } from "../ve.config"; +import { applyTheme, VisualEditorProvider } from "@yext/visual-editor"; +import { themeConfig } from "../../theme.config"; +import { buildSchema } from "../utils/buildSchema"; +import { AnalyticsProvider } from "@yext/pages-components"; + +export const getHeadConfig: GetHeadConfig = ({ + document, +}): HeadConfig => { + return { + title: document.name, + charset: "UTF-8", + viewport: "width=device-width, initial-scale=1", + tags: [ + { + type: "link", + attributes: { + rel: "icon", + type: "image/x-icon", + }, + }, + ], + other: [applyTheme(document, themeConfig), buildSchema(document)].join( + "\\n" + ), + }; +}; + +export const getPath: GetPath = ({ document }) => { + const localePath = document.locale !== "en" ? \`\${document.locale}/\` : ""; + return document.address + ? \`\${localePath}\${document.address.region}/\${document.address.city}/\${document.address.line1}-\${document.id.toString()}\` + : \`\${localePath}\${document.id.toString()}\`; +}; + +const ${formattedTemplateName}: Template = (props) => { + const { document } = props; + + 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 componentRegistry = new Map>([ + ["${templateName}", ${templateName}Config], +]); +`; +}; + +export const newConfig = (formattedTemplateName: string, fileName: string) => { + return ` + type ${formattedTemplateName}Props = {}; + +export const ${fileName}Config: Config<${formattedTemplateName}Props> = { + components: {}, + root: { + render: ({ children }) => { + return <>{children}; + }, + }, +}; + +`; +}; + +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 }) => { + console.log(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, + Template, +} 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}: Template = () => { + return ( +
${templateName} page
+ ); +}; + +export default ${formattedTemplateName}; +`; +}; + +export const veThemeConfig = ` +import { + ThemeConfig, + defaultFonts, + FontRegistry, + getFontWeightOptions, + constructFontSelectOptions, +} from "@yext/visual-editor"; + +const getColorOptions = () => { + return [ + { label: "Primary", value: "var(--colors-palette-primary)" }, + { label: "Secondary", value: "var(--colors-palette-secondary)" }, + { label: "Accent", value: "var(--colors-palette-accent)" }, + { label: "Text", value: "var(--colors-palette-text)" }, + { label: "Background", value: "var(--colors-palette-background)" }, + ]; +}; + +const fonts: FontRegistry = { + // other developer defined fonts here + ...defaultFonts, +}; +const fontOptions = constructFontSelectOptions(fonts); +const fontWeightOptions = (fontVariable?: string) => { + return () => + getFontWeightOptions({ + fontCssVariable: fontVariable, + fontList: fonts, + }); +}; + +export const themeConfig: ThemeConfig = { + palette: { + label: "Color Palette", + styles: { + primary: { + label: "Primary", + type: "color", + default: "#D83B18", + plugin: "colors", + }, + secondary: { + label: "Secondary", + type: "color", + default: "#FFFFFF", + plugin: "colors", + }, + accent: { + label: "Accent", + type: "color", + default: "#FFFFFF", + plugin: "colors", + }, + text: { + label: "Text", + type: "color", + default: "#000000", + plugin: "colors", + }, + background: { + label: "Background", + type: "color", + plugin: "colors", + default: "#FFFFFF", + }, + }, + }, + heading1: { + label: "Heading 1", + styles: { + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 48, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-heading1-fontFamily"), + default: "700", + }, + color: { + label: "Text Color", + type: "select", + plugin: "colors", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: fontOptions, + default: "'Georgia', serif", + }, + }, + }, + heading2: { + label: "Heading 2", + styles: { + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 24, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-heading2-fontFamily"), + default: "700", + }, + color: { + label: "Text Color", + type: "select", + plugin: "colors", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: fontOptions, + default: "serif", + }, + }, + }, + heading3: { + label: "Heading 3", + styles: { + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 24, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-heading3-fontFamily"), + default: "700", + }, + color: { + label: "Text Color", + type: "select", + plugin: "colors", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: fontOptions, + default: "serif", + }, + }, + }, + heading4: { + label: "Heading 4", + styles: { + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 24, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-heading4-fontFamily"), + default: "700", + }, + color: { + label: "Text Color", + type: "select", + plugin: "colors", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: fontOptions, + default: "serif", + }, + }, + }, + heading5: { + label: "Heading 5", + styles: { + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 24, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-heading5-fontFamily"), + default: "700", + }, + color: { + label: "Text Color", + type: "select", + plugin: "colors", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: fontOptions, + default: "serif", + }, + }, + }, + heading6: { + label: "Heading 6", + styles: { + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 24, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-heading6-fontFamily"), + default: "700", + }, + color: { + label: "Text Color", + type: "select", + plugin: "colors", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: fontOptions, + default: "serif", + }, + }, + }, + body: { + label: "Body Text", + styles: { + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 16, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-body-fontFamily"), + default: "400", + }, + color: { + label: "Text Color", + plugin: "colors", + type: "select", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + fontFamily: { + label: "Font", + type: "select", + plugin: "fontFamily", + options: fontOptions, + default: "serif", + }, + }, + }, + grid: { + label: "Grid Section", + styles: { + verticalSpacing: { + label: "Vertical Spacing", + type: "number", + plugin: "gap", + default: 8, + }, + maxWidth: { + label: "Maximum Width", + type: "select", + plugin: "maxWidth", + options: [ + { label: "2XL", value: "1536px" }, + { label: "XL", value: "1280px" }, + { label: "LG", value: "1024px" }, + ], + default: "1280px", + }, + backgroundColor: { + label: "Background Color", + type: "select", + plugin: "backgroundColor", + options: getColorOptions(), + default: "var(--colors-palette-background)", + }, + }, + }, + header: { + label: "Header", + styles: { + backgroundColor: { + label: "Background Color", + type: "select", + plugin: "backgroundColor", + options: getColorOptions(), + default: "var(--colors-palette-background)", + }, + }, + }, + footer: { + label: "Footer", + styles: { + backgroundColor: { + label: "Background Color", + type: "select", + plugin: "backgroundColor", + options: getColorOptions(), + default: "var(--colors-palette-background)", + }, + }, + }, + button: { + label: "Button", + styles: { + borderRadius: { + label: "Border Radius", + type: "number", + plugin: "borderRadius", + default: 20, + }, + fontWeight: { + label: "Font Weight", + type: "select", + plugin: "fontWeight", + options: fontWeightOptions("--fontFamily-body-fontFamily"), + default: "400", + }, + fontSize: { + label: "Font Size", + type: "number", + plugin: "fontSize", + default: 12, + }, + backgroundColor: { + label: "Background Color", + type: "select", + plugin: "backgroundColor", + options: getColorOptions(), + default: "var(--colors-palette-background)", + }, + textColor: { + label: "Text Color", + plugin: "colors", + type: "select", + options: getColorOptions(), + default: "var(--colors-palette-text)", + }, + }, + }, +}; +`; + +export const buildSchemaUtil = ` +import { SchemaWrapper, LocalBusiness } from "@yext/pages-components"; + +export function buildSchema(document: Record) { + const localBusiness = document.address && { + ...LocalBusiness(document), + paymentAccepted: document.paymentOptions, + makesOffer: document.services, + }; + + const json = { + "@graph": [localBusiness].filter(Boolean), + }; + + return SchemaWrapper(json); +} +`; + +export const tailwindConfig = ` +import type { Config } from "tailwindcss"; +import { themeConfig } from "./theme.config"; +import { themeResolver } from "@yext/visual-editor"; + +export default { + content: [ + "./src/**/*.{html,js,jsx,ts,tsx}", + "./node_modules/@yext/visual-editor/dist/**/*.js", + ], + theme: { + extend: themeResolver({}, themeConfig), + }, + plugins: [], +} satisfies Config; +`; + +export const defaultLayoutData = + '{"root":{},"zones":{"GridSection-66bcc9f7-603e-4aa3-a8dc-b615e4f1e0f1:column-0":[{"type":"HeadingText","props":{"id":"HeadingText-1d04d138-a53c-4e19-a085-43677225f035","text":{"field":"name","constantValue":"Text","constantValueEnabled":false},"color":"default","level":3,"weight":"default","content":"Heading","fontSize":"default","transform":"none"}},{"type":"HeadingText","props":{"id":"HeadingText-3bbf663f-b3c9-4842-bf42-1189fae9a1d2","text":{"field":"address.city","constantValue":"Text","constantValueEnabled":false},"color":"default","level":1,"weight":"default","content":"Heading","fontSize":"default","transform":"none"}},{"type":"HoursStatus","props":{"id":"HoursStatus-f066f661-eec6-4b5e-b371-9506523c7c41","hours":{"field":"hours","constantValue":{}},"className":"","timeFormat":"12h","showDayNames":true,"dayOfWeekFormat":"long","showCurrentStatus":true}},{"type":"GetDirections","props":{"id":"GetDirections-5553a427-d704-48c2-b34c-79b0001dd052","size":"default","padding":"none","variant":"primary","fontSize":"default","alignment":"items-start","coordinate":{"field":"yextDisplayCoordinate","constantValue":{"latitude":0,"longitude":0}},"borderRadius":"default","getDirectionsProvider":"google"}}],"GridSection-66bcc9f7-603e-4aa3-a8dc-b615e4f1e0f1:column-1":[{"type":"ImageWrapper","props":{"id":"ImageWrapper-dae23d74-7822-4360-91ef-2f20fc9732f6","size":"full","image":{"field":"primaryPhoto","constantValue":{"url":"https://placehold.co/640x360","width":640,"height":360,"alternateText":""},"constantValueEnabled":true},"rounded":"none","aspectRatio":"auto"}}],"GridSection-25304150-7a70-4b16-a7d0-9e5722cb7758:column-0":[{"type":"HeadingText","props":{"id":"HeadingText-664ceccf-fd50-4e44-be5c-cb6281c2c5bf","text":{"field":"","constantValue":"Information","constantValueEnabled":true},"color":"default","level":2,"weight":"default","content":"Heading","fontSize":"default","transform":"none"}},{"type":"Address","props":{"id":"Address-8aff2a9f-6806-45d1-bc0c-529035535242","address":{"field":"address","constantValue":{"city":"","line1":"","region":"","postalCode":"","countryCode":""}},"padding":"none","alignment":"items-start","getDirectionsProvider":"google"}},{"type":"Emails","props":{"id":"Emails-043e6542-8b37-4298-bfec-e666e7880eea","list":{"field":"","constantValue":[]},"listLength":5,"includeHyperlink":true}},{"type":"Phone","props":{"id":"Phone-eeca7da5-9f77-4f81-9167-63d9a10a28eb","phone":{"field":"mainPhone","constantValue":""},"textSize":16}}],"GridSection-25304150-7a70-4b16-a7d0-9e5722cb7758:column-1":[{"type":"HeadingText","props":{"id":"HeadingText-b4cda241-8c87-42d8-8a4b-f8c009f67f2c","text":{"field":"","constantValue":"Hours","constantValueEnabled":true},"color":"default","level":2,"weight":"default","content":"Heading","fontSize":"default","transform":"none"}},{"type":"HoursTable","props":{"id":"HoursTable-3adc65ce-5682-4080-ae30-f763f0b59438","hours":{"field":"hours","constantValue":{}},"padding":"none","alignment":"items-start","startOfWeek":"today","collapseDays":false,"showAdditionalHoursText":true}}],"GridSection-25304150-7a70-4b16-a7d0-9e5722cb7758:column-2":[{"type":"HeadingText","props":{"id":"HeadingText-2298991f-d93e-442b-9a3e-f29e0b79b44f","text":{"field":"","constantValue":"Services","constantValueEnabled":true},"color":"default","level":2,"weight":"default","content":"Heading","fontSize":"default","transform":"none"}},{"type":"TextList","props":{"id":"TextList-2e4b9958-62b1-4574-acc3-ee538244ea48","list":{"field":"services","constantValue":[],"constantValueEnabled":false},"padding":"none"}}]},"content":[{"type":"Header","props":{"logo":{"image":{"field":"","constantValue":{"height":50,"width":50,"url":"https://placehold.co/50"},"constantValueEnabled":true}},"links":[{"cta":{"field":"","constantValue":{"link":"#","label":"Link"},"constantValueEnabled":true}},{"cta":{"field":"","constantValue":{"link":"#","label":"Link"},"constantValueEnabled":true}},{"cta":{"field":"","constantValue":{"link":"#","label":"Link"},"constantValueEnabled":true}}],"id":"Header-86265fdf-58b6-48f2-b956-35c5b459488c"}},{"type":"GridSection","props":{"id":"GridSection-66bcc9f7-603e-4aa3-a8dc-b615e4f1e0f1","columns":[{"verticalAlignment":"start"},{"verticalAlignment":"start"}],"distribution":"auto","backgroundColor":"default","maxContentWidth":"lg","verticalPadding":"py-2","horizontalSpacing":"medium"}},{"type":"GridSection","props":{"id":"GridSection-25304150-7a70-4b16-a7d0-9e5722cb7758","columns":[{"verticalAlignment":"start"},{"verticalAlignment":"start"},{}],"distribution":"auto","backgroundColor":"default","maxContentWidth":"lg","verticalPadding":"py-2","horizontalSpacing":"medium"}},{"type":"Footer","props":{"copyright":{"text":{"field":"","constantValue":"Copyright Text","constantValueEnabled":true}},"links":[{"cta":{"field":"","constantValue":{"link":"#","label":"Footer Link"},"constantValueEnabled":true}},{"cta":{"field":"","constantValue":{"link":"#","label":"Footer Link"},"constantValueEnabled":true}},{"cta":{"field":"","constantValue":{"link":"#","label":"Footer Link"},"constantValueEnabled":true}}],"id":"Footer-062428b8-84a4-421f-862d-a55218900585"}}]}'; 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/packages/pages/src/upgrade/migrateConfig.test.ts b/packages/pages/src/upgrade/migrateConfig.test.ts index 3340fb547..c65a0ac49 100644 --- a/packages/pages/src/upgrade/migrateConfig.test.ts +++ b/packages/pages/src/upgrade/migrateConfig.test.ts @@ -1,47 +1,5 @@ -import { describe, it, expect, vi } from "vitest"; -import { formatServing, formatSiteStream } from "./migrateConfig.js"; - -const siteStreamPath = "foo/bar"; - -describe("formatSiteStream", () => { - it("errors and exits when there are multiple entityIds", () => { - const testJson = { filter: { entityIds: ["1234", "123"] } }; - const mockExit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never); - formatSiteStream(testJson, siteStreamPath); - expect(mockExit).toHaveBeenCalledWith(1); - }); - - it("returns expected entityId", () => { - const testJson = { filter: { entityIds: ["1234"] } }; - const expectedJson = { entityId: "1234" }; - expect(formatSiteStream(testJson, siteStreamPath)).toEqual(expectedJson); - }); - - it("returns expected id with id first", () => { - const testJson = { fields: ["meta", "name"], $id: "123" }; - const expectedJson = { id: "123", fields: ["meta", "name"] }; - expect(formatSiteStream(testJson, siteStreamPath)).toEqual(expectedJson); - }); - - it("returns expected full config", () => { - const testJson = { - $id: "123", - fields: ["meta", "name"], - filter: { entityIds: ["1234"] }, - source: "foo", - localization: ["en"], - }; - const expectedJson = { - id: "123", - entityId: "1234", - fields: ["meta", "name"], - localization: ["en"], - }; - expect(formatSiteStream(testJson, siteStreamPath)).toEqual(expectedJson); - }); -}); +import { describe, it, expect } from "vitest"; +import { formatServing } from "./migrateConfig.js"; describe("formatServing", () => { it("returns expected reverseProxyPrefix", () => { diff --git a/packages/pages/tests/fixtures/inPlatformTemplate.tsx b/packages/pages/tests/fixtures/inPlatformTemplate.tsx new file mode 100644 index 000000000..550330ac8 --- /dev/null +++ b/packages/pages/tests/fixtures/inPlatformTemplate.tsx @@ -0,0 +1,76 @@ +/** + * This is a simple template that can be used for tests. + */ + +import * as React from "react"; +import { + Template, + GetPath, + GetRedirects, + TemplateConfig, + TemplateProps, + TemplateRenderProps, + GetHeadConfig, + HeadConfig, +} from "@yext/pages"; + +/** + * Defines the path that the generated file will live at for production. + * + * NOTE: This currently has no impact on the local dev path. Local dev urls currently + * take on the form: featureName/entityId + */ +export const getPath: GetPath = ({ document }) => { + return `location/${document.id.toString()}`; +}; + +/** + * This allows the user to define a function which will take in their template + * data and procude a HeadConfig object. When the site is generated, the HeadConfig + * will be used to generate the inner contents of the HTML document's tag. + * This can include the title, meta tags, script tags, etc. + */ +export const getHeadConfig: GetHeadConfig = ({ + relativePrefixToRoot, + path, + document, +}): HeadConfig => { + return { + title: document.name, + charset: "UTF-8", + viewport: "width=device-width, initial-scale=1", + tags: [ + { + type: "meta", + attributes: { + description: "This site was generated by the Yext SSG", + }, + }, + ], + }; +}; + +/** + * This is the main template. It can have any name as long as it's the default export. + * The props passed in here are the direct stream document defined by `config`. + */ +const Location: Template = ({ + relativePrefixToRoot, + path, + document, +}) => { + const { + _site, + name, + address, + openTime, + hours, + mainPhone, + geocodedCoordinate, + services, + } = document; + + return <>Hello, World; +}; + +export default Location; diff --git a/playground/locations-site/sites-config/features.json b/playground/locations-site/sites-config/features.json index f59eb8a8d..74604c486 100644 --- a/playground/locations-site/sites-config/features.json +++ b/playground/locations-site/sites-config/features.json @@ -27,11 +27,40 @@ { "$id": "location-stream", "filter": { - "entityTypes": ["location"] + "entityTypes": [ + "location" + ] }, - "fields": ["id", "uid", "meta", "address", "slug"], + "fields": [ + "id", + "uid", + "meta", + "address", + "slug" + ], "localization": { - "locales": ["en"], + "locales": [ + "en" + ], + "primary": false + }, + "source": "knowledgeGraph", + "destination": "pages" + }, + { + "$id": "closed-location-redirects", + "fields": [ + "slug" + ], + "filter": { + "entityTypes": [ + "location" + ] + }, + "localization": { + "locales": [ + "en" + ], "primary": false }, "source": "knowledgeGraph", @@ -51,4 +80,4 @@ "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 abf67a699..60fc4a07a 100644 --- a/playground/multibrand-site/sites-config/sunglasses.oakley.com/features.json +++ b/playground/multibrand-site/sites-config/sunglasses.oakley.com/features.json @@ -26,12 +26,39 @@ "streams": [ { "$id": "oakley-stream", - "fields": ["id", "name", "slug"], + "fields": [ + "id", + "name", + "slug" + ], "filter": { - "savedFilterIds": ["1241548641"] + "savedFilterIds": [ + "1241548641" + ] }, "localization": { - "locales": ["en"], + "locales": [ + "en" + ], + "primary": false + }, + "source": "knowledgeGraph", + "destination": "pages" + }, + { + "$id": "closed-oakley-redirects", + "fields": [ + "slug" + ], + "filter": { + "savedFilterIds": [ + "1241548641" + ] + }, + "localization": { + "locales": [ + "en" + ], "primary": false }, "source": "knowledgeGraph", @@ -51,4 +78,4 @@ "destination": "pages" } ] -} +} \ No newline at end of file