diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES index 3c978aecb..f10a8b972 100644 --- a/THIRD-PARTY-NOTICES +++ b/THIRD-PARTY-NOTICES @@ -7001,6 +7001,37 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----------- +The following npm package may be included in this product: + + - postcss-selector-parser@6.0.13 + +This package contains the following license and notice below: + +Copyright (c) Ben Briggs (http://beneb.info) + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +----------- + The following npm package may be included in this product: - entities@2.2.0 @@ -7231,6 +7262,7 @@ IN THE SOFTWARE. The following npm packages may be included in this product: + - cssesc@3.0.0 - emoji-regex@10.2.1 - punycode@1.3.2 @@ -10568,7 +10600,7 @@ THE SOFTWARE. The following npm package may be included in this product: - - postcss@8.4.32 + - postcss@8.4.35 This package contains the following license and notice below: @@ -10595,6 +10627,35 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------- +The following npm package may be included in this product: + + - postcss-nested@6.0.1 + +This package contains the following license and notice below: + +The MIT License (MIT) + +Copyright 2014 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +----------- + The following npm package may be included in this product: - nanoid@3.3.7 diff --git a/package.json b/package.json index d9d22ab50..d0529c5d6 100644 --- a/package.json +++ b/package.json @@ -38,16 +38,20 @@ "@types/fs-extra": "^11.0.4", "@types/lodash": "^4.14.202", "@types/minimist": "^1.2.5", + "@types/node": "^20.10.6", "@types/prompts": "^2.4.9", "@types/semver": "^7.5.6", + "@typescript-eslint/eslint-plugin": "^6.13.2", + "@typescript-eslint/parser": "^6.13.2", "eslint": "^8.55.0", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-typescript": "^3.6.1", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-react": "^7.33.2", "execa": "^8.0.1", "fs-extra": "^11.2.0", - "yaml": "^2.3.4", "generate-changelog": "^1.8.0", "generate-license-file": "^3.0.0", - "@typescript-eslint/eslint-plugin": "^6.13.2", - "@typescript-eslint/parser": "^6.13.2", "husky": "^8.0.3", "lint-staged": "^15.2.0", "minimist": "^1.2.8", @@ -55,12 +59,9 @@ "prettier": "^3.1.0", "prompts": "^2.4.2", "semver": "^7.5.4", + "tsx": "^4.6.2", "typescript": "^5.3.3", - "eslint-config-prettier": "^9.1.0", - "eslint-import-resolver-typescript": "^3.6.1", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-react": "^7.33.2", - "tsx": "^4.6.2" + "yaml": "^2.3.4" }, "packageManager": "pnpm@8.3.1", "pnpm": { diff --git a/packages/pages/THIRD-PARTY-NOTICES b/packages/pages/THIRD-PARTY-NOTICES index 8336e27f3..3c7779f9d 100644 --- a/packages/pages/THIRD-PARTY-NOTICES +++ b/packages/pages/THIRD-PARTY-NOTICES @@ -5170,5 +5170,63 @@ SOFTWARE. ----------- +The following npm package may be included in this product: + + - postcss@8.4.35 + +This package contains the following license and notice below: + +The MIT License (MIT) + +Copyright 2013 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +----------- + +The following npm package may be included in this product: + + - postcss-nested@6.0.1 + +This package contains the following license and notice below: + +The MIT License (MIT) + +Copyright 2014 Andrey Sitnik + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +----------- + This file was generated with the generate-license-file npm package! https://www.npmjs.com/package/generate-license-file diff --git a/packages/pages/docs/api/pages.md b/packages/pages/docs/api/pages.md index 2a7c39306..e25f1f306 100644 --- a/packages/pages/docs/api/pages.md +++ b/packages/pages/docs/api/pages.md @@ -10,6 +10,8 @@ | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [FunctionModule](./pages.functionmodule.md) | Defines the functions and fields that are available to the serverless function. | | [HeadConfig](./pages.headconfig.md) | The configuration that allows users to entirely arbitarily set the inner contents of the head element that will be prepended to the generated HTML document. | +| [ModuleConfig](./pages.moduleconfig.md) | The configuration options for a Module. | +| [ModuleProps](./pages.moduleprops.md) | The shape of the data passed directly to the module's default export. | | [PagesHttpRequest](./pages.pageshttprequest.md) | The argument passed to a http/api type function. | | [PagesHttpResponse](./pages.pageshttpresponse.md) | The return value for a http/api serverless function. | | [PagesOnUrlChangeRequest](./pages.pagesonurlchangerequest.md) | The argument passed to an onUrlChange type plugin. | @@ -41,6 +43,7 @@ | [GetRedirects](./pages.getredirects.md) | The type definiton for the template's getRedirects function. | | [HttpFunction](./pages.httpfunction.md) | A function that runs when a specific path is visited on the site. | | [Manifest](./pages.manifest.md) | A manifest of bundled files present during a production build. | +| [Module](./pages.module.md) | The type definition for the module's default function. | | [OnUrlChangeFunction](./pages.onurlchangefunction.md) | A function that runs when the path of a production page changes. | | [PagesOnUrlChangeResponse](./pages.pagesonurlchangeresponse.md) | onUrlUpdate plugins return void. | | [Render](./pages.render.md) | The type definition for the template's render function. | diff --git a/packages/pages/docs/api/pages.module.md b/packages/pages/docs/api/pages.module.md new file mode 100644 index 000000000..edfee77ea --- /dev/null +++ b/packages/pages/docs/api/pages.module.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/pages](./pages.md) > [Module](./pages.module.md) + +## Module type + +The type definition for the module's default function. + +**Signature:** + +```typescript +export type Module = () => React.JSX.Element; +``` diff --git a/packages/pages/docs/api/pages.moduleconfig.md b/packages/pages/docs/api/pages.moduleconfig.md new file mode 100644 index 000000000..87ca93f5b --- /dev/null +++ b/packages/pages/docs/api/pages.moduleconfig.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [@yext/pages](./pages.md) > [ModuleConfig](./pages.moduleconfig.md) + +## ModuleConfig interface + +The configuration options for a Module. + +**Signature:** + +```typescript +export interface ModuleConfig +``` + +## Properties + +| Property | Modifiers | Type | Description | +| ------------------------------------- | --------- | ------ | -------------------------------------------------------------------------------------------- | +| [name?](./pages.moduleconfig.name.md) | | string | _(Optional)_ Name of the module. If not defined uses the module filename (without extension) | diff --git a/packages/pages/docs/api/pages.moduleconfig.name.md b/packages/pages/docs/api/pages.moduleconfig.name.md new file mode 100644 index 000000000..36c99e29b --- /dev/null +++ b/packages/pages/docs/api/pages.moduleconfig.name.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/pages](./pages.md) > [ModuleConfig](./pages.moduleconfig.md) > [name](./pages.moduleconfig.name.md) + +## ModuleConfig.name property + +Name of the module. If not defined uses the module filename (without extension) + +**Signature:** + +```typescript +name?: string; +``` diff --git a/packages/pages/docs/api/pages.moduleprops.__meta.md b/packages/pages/docs/api/pages.moduleprops.__meta.md new file mode 100644 index 000000000..71328a7f1 --- /dev/null +++ b/packages/pages/docs/api/pages.moduleprops.__meta.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [@yext/pages](./pages.md) > [ModuleProps](./pages.moduleprops.md) > [\_\_meta](./pages.moduleprops.__meta.md) + +## ModuleProps.\_\_meta property + +Additional metadata added by the toolchain + +**Signature:** + +```typescript +__meta: { + mode: "development" | "production"; +} +``` diff --git a/packages/pages/docs/api/pages.moduleprops.md b/packages/pages/docs/api/pages.moduleprops.md new file mode 100644 index 000000000..a194458f5 --- /dev/null +++ b/packages/pages/docs/api/pages.moduleprops.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [@yext/pages](./pages.md) > [ModuleProps](./pages.moduleprops.md) + +## ModuleProps interface + +The shape of the data passed directly to the module's default export. + +**Signature:** + +```typescript +export interface ModuleProps +``` + +## Properties + +| Property | Modifiers | Type | Description | +| ----------------------------------------- | --------- | ---------------------------------------- | ------------------------------------------ | +| [\_\_meta](./pages.moduleprops.__meta.md) | | { mode: "development" \| "production"; } | Additional metadata added by the toolchain | diff --git a/packages/pages/etc/pages.api.md b/packages/pages/etc/pages.api.md index 1b4b703e0..f54a53ac4 100644 --- a/packages/pages/etc/pages.api.md +++ b/packages/pages/etc/pages.api.md @@ -91,6 +91,21 @@ export type Manifest = { bundlerManifest?: any; }; +// @public +export type Module = () => React.JSX.Element; + +// @public +export interface ModuleConfig { + name?: string; +} + +// @public +export interface ModuleProps { + __meta: { + mode: "development" | "production"; + }; +} + // @public export type OnUrlChangeFunction = ( arg: PagesOnUrlChangeRequest diff --git a/packages/pages/package.json b/packages/pages/package.json index 0101ae392..1c124c710 100644 --- a/packages/pages/package.json +++ b/packages/pages/package.json @@ -75,6 +75,8 @@ "open": "^9.1.0", "ora": "^7.0.1", "picocolors": "^1.0.0", + "postcss": "8.4.35", + "postcss-nested": "6.0.1", "pretty-ms": "^8.0.0", "prompts": "^2.4.2", "rollup": "^4.7.0", diff --git a/packages/pages/src/common/src/module/types.ts b/packages/pages/src/common/src/module/types.ts new file mode 100644 index 000000000..dee12a151 --- /dev/null +++ b/packages/pages/src/common/src/module/types.ts @@ -0,0 +1,29 @@ +/** + * The type definition for the module's default function. + * + * @public + */ +export type Module = () => React.JSX.Element; + +/** + * The configuration options for a Module. + * + * @public + */ +export interface ModuleConfig { + /** Name of the module. If not defined uses the module filename (without extension) */ + name?: string; +} + +/** + * The shape of the data passed directly to the module's default export. + * + * @public + */ +export interface ModuleProps { + /** Additional metadata added by the toolchain */ + __meta: { + /** Specifies if the data is returned in development or production mode */ + mode: "development" | "production"; + }; +} diff --git a/packages/pages/src/common/src/parsers/sourceFileParser.test.ts b/packages/pages/src/common/src/parsers/sourceFileParser.test.ts index 00a32d633..4f4fc9608 100644 --- a/packages/pages/src/common/src/parsers/sourceFileParser.test.ts +++ b/packages/pages/src/common/src/parsers/sourceFileParser.test.ts @@ -165,6 +165,57 @@ describe("getChildExpressions", () => { }); }); +describe("getVariableDeclarationByType", () => { + it("correctly gets a string variable", () => { + const parser = createParser(`const foo: string = "foo";`); + const variableDeclaration = parser.getVariableDeclarationByType("string"); + expect(variableDeclaration).toBeDefined(); + expect(variableDeclaration?.getType().getText()).toEqual("string"); + expect(variableDeclaration?.getName()).toEqual("foo"); + }); + + it("correctly gets a Module variable", () => { + const parser = createParser(`const foo: Module = () => {return
;}`); + const variableDeclaration = parser.getVariableDeclarationByType("Module"); + expect(variableDeclaration).toBeDefined(); + expect(variableDeclaration?.getName()).toEqual("foo"); + }); +}); + +describe("getVariablePropertyByName", () => { + it("correctly gets a config's name", () => { + const parser = createParser(`export const config = { name: "foo" }`); + const variableDeclaration = parser.getVariablePropertyByName( + "config", + "name" + ); + expect(variableDeclaration).toEqual(`"foo"`); + }); +}); + +describe("removeUnusedImports", () => { + it("correctly removes unused imports", () => { + const parser = createParser(`import * as React from "react";`); + parser.removeUnusedImports(); + expect(parser.getAllText()).toEqual(""); + }); + + it("doesn't remove used imports", () => { + const parser = createParser( + `import { ModuleConfig } from "@yext/pages/*"; + export const config: ModuleConfig = { + name: "foo" + }` + ); + parser.removeUnusedImports(); + expect( + parser + .getAllText() + .includes(`import { ModuleConfig } from "@yext/pages/*";`) + ).toBeTruthy(); + }); +}); + 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 b5c313cc2..054f6e2be 100644 --- a/packages/pages/src/common/src/parsers/sourceFileParser.ts +++ b/packages/pages/src/common/src/parsers/sourceFileParser.ts @@ -5,6 +5,7 @@ import { ImportDeclarationStructure, OptionalKind, ImportAttributeStructure, + VariableDeclaration, } from "ts-morph"; import typescript from "typescript"; @@ -207,4 +208,73 @@ export default class SourceFileParser { getAllText(): string { return this.sourceFile.getFullText(); } + + /** + * For example, we can do getVariablePropertyByName("config", "name") + * and recieve the string value that the user set as name for their + * variable named config. This only works for exported variables. + * + * @param name of the variable + * @param property of the variable + * @returns property of the variable as any + */ + getVariablePropertyByName(name: string, property: string): any { + const variableDeclarations = this.sourceFile.getVariableDeclarations(); + for (const declaration of variableDeclarations) { + if (declaration.getName() === name && declaration.isExported()) { + const initializer = declaration.getInitializer(); + const objectLiteral = initializer as any; + const variableProperty = objectLiteral + .getProperties() + .find( + (prop: { getName: () => string }) => prop.getName() === property + ); + if (variableProperty) { + const value = variableProperty + .getFirstChildByKind(SyntaxKind.StringLiteral) + ?.getText(); + if (value) { + return value; + } else { + return variableProperty; + } + } + } + } + return; + } + + getVariableDeclarationByType(type: string): VariableDeclaration | undefined { + const declaration = + this.sourceFile.getVariableDeclaration( + (v) => v.getType().getText() === type + ) ?? + this.sourceFile.getVariableDeclaration((v) => + v + .getType() + .getText() + .endsWith("." + type) + ); + if (declaration === undefined) { + throw new Error(`Type ${type} cannot be found.`); + } + return declaration; + } + + insertStatement(code: string, index = 0) { + this.sourceFile.insertText(index, code); + return this.getEndPos(); + } + + removeStatement(startIndex: number, endIndex: number) { + this.sourceFile.removeText(startIndex, endIndex); + } + + getEndPos() { + return this.sourceFile.getEnd(); + } + + removeUnusedImports() { + this.sourceFile.fixUnusedIdentifiers(); + } } diff --git a/packages/pages/src/common/src/project/structure.ts b/packages/pages/src/common/src/project/structure.ts index dd1dc34cc..cc46c7d48 100644 --- a/packages/pages/src/common/src/project/structure.ts +++ b/packages/pages/src/common/src/project/structure.ts @@ -24,6 +24,8 @@ export interface RootFolders { export interface Subfolders { /** The templates folder */ templates: string; + /** The modules folder */ + modules: string; /** The Node functions folder */ serverlessFunctions: string; // Node functions /** Where to output the bundled static assets */ @@ -142,6 +144,7 @@ const defaultProjectStructureConfig: ProjectStructureConfig = { }, subfolders: { templates: "templates", + modules: "modules", serverlessFunctions: "functions", assets: DEFAULT_ASSETS_DIR, public: DEFAULT_PUBLIC_DIR, diff --git a/packages/pages/src/generate/artifacts/createArtifactsJson.test.ts b/packages/pages/src/generate/artifacts/createArtifactsJson.test.ts index baffe7160..7b45f3c68 100644 --- a/packages/pages/src/generate/artifacts/createArtifactsJson.test.ts +++ b/packages/pages/src/generate/artifacts/createArtifactsJson.test.ts @@ -18,6 +18,10 @@ describe("createArtifactsJson - getArtifactsConfig", () => { root: "dist/public_assets", pattern: "**/*", }, + { + root: "dist", + pattern: "modules/**/*", + }, ], plugins: [ { diff --git a/packages/pages/src/generate/artifacts/createArtifactsJson.ts b/packages/pages/src/generate/artifacts/createArtifactsJson.ts index a3a1df657..983bd515c 100644 --- a/packages/pages/src/generate/artifacts/createArtifactsJson.ts +++ b/packages/pages/src/generate/artifacts/createArtifactsJson.ts @@ -48,6 +48,10 @@ export const getArtifactsConfig = async ( root: `${projectStructure.config.rootFolders.dist}/public_assets`, pattern: "**/*", }, + { + root: projectStructure.config.rootFolders.dist, + pattern: `${projectStructure.config.subfolders.modules}/**/*`, + }, ], plugins: [getGeneratorPlugin(projectStructure)], }, diff --git a/packages/pages/src/generate/ci/ci.test.ts b/packages/pages/src/generate/ci/ci.test.ts index 0392f4b52..2f5bdefd1 100644 --- a/packages/pages/src/generate/ci/ci.test.ts +++ b/packages/pages/src/generate/ci/ci.test.ts @@ -36,6 +36,10 @@ describe("ci - getUpdatedCiConfig", () => { root: "dist", pattern: "assets/**/*", }, + { + root: "dist", + pattern: "modules/**/*", + }, { root: "dist/public_assets", pattern: "**/*", @@ -125,6 +129,10 @@ describe("ci - getUpdatedCiConfig", () => { root: "dist", pattern: "assets/**/*", }, + { + root: "dist", + pattern: "modules/**/*", + }, { root: "dist/public_assets", pattern: "**/*", diff --git a/packages/pages/src/generate/ci/ci.ts b/packages/pages/src/generate/ci/ci.ts index d9f489ff1..6a8ff0be1 100644 --- a/packages/pages/src/generate/ci/ci.ts +++ b/packages/pages/src/generate/ci/ci.ts @@ -91,6 +91,11 @@ export const getUpdatedCiConfig = async ( pattern: `${projectStructure.config.subfolders.assets}/**/*`, }); + ciConfigCopy.artifactStructure.assets.push({ + root: projectStructure.config.rootFolders.dist, + pattern: `${projectStructure.config.subfolders.modules}/**/*`, + }); + // static assets based on the Vite publicDir ciConfigCopy.artifactStructure.assets.push({ root: `${projectStructure.config.rootFolders.dist}/public_assets`, diff --git a/packages/pages/src/index.ts b/packages/pages/src/index.ts index 622c23d91..2a6410880 100644 --- a/packages/pages/src/index.ts +++ b/packages/pages/src/index.ts @@ -4,3 +4,4 @@ export * from "./common/src/template/head.js"; export * from "./common/src/template/paths.js"; export * from "./common/src/template/types.js"; export * from "./common/src/function/types.js"; +export * from "./common/src/module/types.js"; diff --git a/packages/pages/src/util/editConfigYaml.ts b/packages/pages/src/util/editConfigYaml.ts new file mode 100644 index 000000000..10eb91e4c --- /dev/null +++ b/packages/pages/src/util/editConfigYaml.ts @@ -0,0 +1,51 @@ +import YAML from "yaml"; +import fs from "node:fs"; +import { ProjectStructure } from "../common/src/project/structure.js"; + +export interface ResponseHeaderProps { + pathPattern: string; + headerKey: string; + headerValues: Array; +} + +/** + * Only adds response header if they don't already exist with the same pathPattern + * + * @param projectStructure + * @param responseHeaderProps + * @param comment to be placed in responseHeader + */ +export const addResponseHeadersToConfigYaml = ( + projectStructure: ProjectStructure, + responseHeaderProps: ResponseHeaderProps, + comment: string +) => { + const configYamlPath = projectStructure.getConfigYamlPath().getAbsolutePath(); + if (!fs.existsSync(configYamlPath)) { + return; + } + + const yaml = YAML.parse(fs.readFileSync(configYamlPath, "utf-8")); + if ( + Object.hasOwn(yaml, "responseHeaders") && + yaml.responseHeaders.find( + (e: ResponseHeaderProps) => + e.pathPattern === responseHeaderProps.pathPattern + ) + ) { + return; + } else if (Object.hasOwn(yaml, "responseHeaders")) { + yaml.responseHeaders.push(responseHeaderProps); + } else { + Object.assign(yaml, { responseHeaders: [responseHeaderProps] }); + } + + let yamlDoc = YAML.stringify(yaml); + if (!yamlDoc.includes(comment)) { + const index = yamlDoc.indexOf("responseHeaders:"); + if (index !== -1) { + yamlDoc = yamlDoc.slice(0, index) + comment + yamlDoc.slice(index); + } + } + fs.writeFileSync(configYamlPath, yamlDoc); +}; diff --git a/packages/pages/src/vite-plugin/build/build.ts b/packages/pages/src/vite-plugin/build/build.ts index eef2bab8a..9450806aa 100644 --- a/packages/pages/src/vite-plugin/build/build.ts +++ b/packages/pages/src/vite-plugin/build/build.ts @@ -4,6 +4,7 @@ import closeBundle from "./closeBundle/closeBundle.js"; import { ProjectStructure } from "../../common/src/project/structure.js"; import { processEnvVariables } from "../../util/processEnvVariables.js"; import { buildServerlessFunctions } from "../serverless-functions/plugin.js"; +import { buildModules } from "../modules/plugin.js"; const intro = ` var global = globalThis; @@ -33,6 +34,7 @@ export const build = async ( sequential: true, handler: async (): Promise => { await buildServerlessFunctions(projectStructure); + await buildModules(projectStructure); }, }, config: async (): Promise => { diff --git a/packages/pages/src/vite-plugin/build/closeBundle/closeBundle.ts b/packages/pages/src/vite-plugin/build/closeBundle/closeBundle.ts index 32895bfbb..310f73b56 100644 --- a/packages/pages/src/vite-plugin/build/closeBundle/closeBundle.ts +++ b/packages/pages/src/vite-plugin/build/closeBundle/closeBundle.ts @@ -44,12 +44,19 @@ export default (projectStructure: ProjectStructure) => { ) ), { - ignore: path.join( - path.resolve(rootFolders.dist, subfolders.serverlessFunctions), - "**" - ), + ignore: [ + path.join( + path.resolve(rootFolders.dist, subfolders.serverlessFunctions), + "**" + ), + path.join( + path.resolve(rootFolders.dist, subfolders.modules), + "**" + ), + ], } ); + templateModules = await loadTemplateModules( serverBundles, false, diff --git a/packages/pages/src/vite-plugin/modules/plugin.ts b/packages/pages/src/vite-plugin/modules/plugin.ts new file mode 100644 index 000000000..84cda0860 --- /dev/null +++ b/packages/pages/src/vite-plugin/modules/plugin.ts @@ -0,0 +1,288 @@ +import { build, createLogger, Plugin } from "vite"; +import { ProjectStructure } from "../../common/src/project/structure.js"; +import { glob } from "glob"; +import path from "node:path"; +import fs from "node:fs"; +import { convertToPosixPath } from "../../common/src/template/paths.js"; +import { processEnvVariables } from "../../util/processEnvVariables.js"; +import { nodePolyfills } from "vite-plugin-node-polyfills"; +import pc from "picocolors"; +import { addResponseHeadersToConfigYaml } from "../../util/editConfigYaml.js"; +import SourceFileParser, { + createTsMorphProject, +} from "../../common/src/parsers/sourceFileParser.js"; +import { logWarning } from "../../util/logError.js"; +import postcss from "postcss"; +import nested from "postcss-nested"; + +const moduleResponseHeaderProps = { + headerKey: "Access-Control-Allow-Origin", + headerValues: ["*"], +}; + +type FileInfo = { + path: string; + name: string; +}; + +export const buildModules = async ( + projectStructure: ProjectStructure +): Promise => { + if (!shouldBundleModules(projectStructure)) { + return; + } + + const { rootFolders, subfolders, envVarConfig } = projectStructure.config; + const outdir = path.join(rootFolders.dist, subfolders.modules); + + const filepaths: { [s: string]: FileInfo } = {}; + glob + .sync( + convertToPosixPath( + path.join(rootFolders.source, subfolders.modules, "**/*.{jsx,tsx}") + ), + { nodir: true } + ) + .forEach((f) => { + const filepath = path.resolve(f); + const moduleName = getModuleName(filepath); + const { name } = path.parse(filepath); + filepaths[moduleName ?? name] = { path: filepath, name: name }; + }); + + const logger = createLogger(); + const loggerInfo = logger.info; + const loggerWarning = logger.warn; + logger.warn = (msg, options) => { + // Suppress this warning b/c nested @tailwind rules are the best option for handling @tailwind base. + if ( + msg.includes("vite:css") && + msg.includes( + "Nested @tailwind rules were detected, but are not supported." + ) + ) + return; + loggerWarning(msg, options); + }; + + if (tailwindBaseExists()) { + // TODO add link to recommended implementation for user. + logWarning( + `Please be aware that using @tailwind base applies styles globally. This can affect code outside of the widget.` + ); + } + + for (const [moduleName, fileInfo] of Object.entries(filepaths)) { + logger.info = (msg, options) => { + if (msg.includes("building for production")) { + loggerInfo(pc.green(`\nBuilding ${moduleName} module...`)); + return; + } + loggerInfo(msg, options); + }; + + // For each module, add response header to config.yaml. + // Users can manually adjust headerKey and headerValue in their config.yaml. + // As long as pathPattern matches, it won't be overwitten. + addResponseHeadersToConfigYaml( + projectStructure, + { + pathPattern: `^modules/${moduleName}.*`, + ...moduleResponseHeaderProps, + }, + "# The ^modules/ header allows access to your modules from other sites\n" + ); + + await build({ + customLogger: logger, + configFile: false, + envDir: envVarConfig.envVarDir, + envPrefix: envVarConfig.envVarPrefix, + resolve: { + conditions: ["worker", "webworker"], + }, + publicDir: false, + css: { + postcss: getPostCssConfigFilepath( + rootFolders, + subfolders, + fileInfo.name + ), + }, + esbuild: { + logOverride: { + "css-syntax-error": "silent", + }, + }, + build: { + chunkSizeWarningLimit: 2000, + emptyOutDir: false, + outDir: outdir, + minify: true, + rollupOptions: { + input: fileInfo.path, + output: { + format: "umd", + entryFileNames: `${moduleName}.umd.js`, + }, + }, + reportCompressedSize: false, + }, + define: processEnvVariables(envVarConfig.envVarPrefix), + plugins: [ + addWrappedCodePlugin(fileInfo.path, moduleName), + nodePolyfills({ + globals: { + Buffer: "build", + global: "build", + process: "build", + }, + }), + ], + }); + } +}; + +const wrappedCode = (moduleName: string, containerName: string): string => { + return ` + const moduleContainerForBuildUseOnly = document.getElementById('${containerName}'); + if (!moduleContainerForBuildUseOnly) { + throw new Error('could not find ${containerName} element'); + } + ReactDOM.render( + <${moduleName}/>, + moduleContainerForBuildUseOnly + );`; +}; + +export default function addWrappedCodePlugin( + path: string, + moduleName: string +): Plugin { + return { + name: "wrapped-code-plugin", + enforce: "pre", + transform(source: string, id: string) { + if (id === path) { + return ( + getReactImports(source) + source + extraModuleCode(path, moduleName) + ); + } + return null; + }, + }; +} + +const getReactImports = (source: string): string => { + let imports = ""; + if (!(source.includes(`from 'react'`) || source.includes(`from "react"`))) { + imports += `import * as React from 'react';\n`; + } + if ( + !( + source.includes(`from 'react-dom'`) || source.includes(`from "react-dom"`) + ) + ) { + imports += `import * as ReactDOM from 'react-dom';\n`; + } + return imports; +}; + +const shouldBundleModules = (projectStructure: ProjectStructure) => { + const { rootFolders, subfolders } = projectStructure.config; + return fs.existsSync(path.join(rootFolders.source, subfolders.modules)); +}; + +/** + * + * @param modulePath + * @returns name of module if set by user via ModuleConfig + */ +const getModuleName = (modulePath: string): string | undefined => { + const sfp = new SourceFileParser(modulePath, createTsMorphProject()); + return sfp.getVariablePropertyByName("config", "name")?.replace(/['"`]/g, ""); +}; + +/** + * Adds custom code to module when bundled into umd.js. + * + * @param modulePath + * @param name set by ModuleConfig or filename + */ +const extraModuleCode = (modulePath: string, name: string) => { + const sfp = new SourceFileParser(modulePath, createTsMorphProject()); + const declaration = sfp.getVariableDeclarationByType("Module"); + if (declaration === undefined) { + throw new Error(`Cannot find variable Module in ${modulePath}`); + } + const moduleName = declaration.getName(); + return wrappedCode(moduleName, name); +}; + +/** + * Returns the postcss.config filepath if it exists in the module. + * Else returns the root postcss.config filepath. + * If there is none, throws error. + * + * @param rootFolders + * @param subfolders + * @param filename of module + * @returns string + */ +const getPostCssConfigFilepath = ( + rootFolders: any, + subfolders: any, + filename: string +): string | undefined => { + const filePath = path.join( + rootFolders.source, + subfolders.modules, + `${filename}/postcss.config` + ); + let filePaths = glob.sync(filePath + ".{js,cjs,ts,mjs}"); + if (filePaths.length == 1) { + return filePaths[0]; + } + + filePaths = glob.sync("postcss.config" + ".{js,cjs,ts,mjs}"); + if (filePaths.length == 1) { + return filePaths[0]; + } + + return; +}; + +/** + * Looks at all css files in src and returns true + * if there is an unwrapped tailwind base. + * + * @returns boolean + */ +const tailwindBaseExists = (): boolean => { + const files = glob.sync("./src/**/*.css"); + let isTailwindBaseInRule = false; + for (const filePath of files) { + try { + const data = fs.readFileSync(filePath, "utf8"); + postcss([nested]) + .process(data, { from: undefined }) + .then((result) => { + result.root.walkRules((rule) => { + // Check if the rule contains @tailwind base + if (rule.toString().includes("@tailwind base")) { + isTailwindBaseInRule = true; + } + }); + }) + .then(() => { + if (data.includes(`@tailwind base`) && !isTailwindBaseInRule) { + return true; + } + }); + } catch (err) { + // Purposefully ignore error, in case user has odd file we want to skip. + return false; + } + } + return false; +}; diff --git a/packages/pages/yext-pages-1.0.0-rc.7.tgz b/packages/pages/yext-pages-1.0.0-rc.7.tgz new file mode 100644 index 000000000..36c102ac1 Binary files /dev/null and b/packages/pages/yext-pages-1.0.0-rc.7.tgz differ diff --git a/packages/yext-function/manifest.ts b/packages/yext-function/manifest.ts index f0e49b907..5f24df0de 100644 --- a/packages/yext-function/manifest.ts +++ b/packages/yext-function/manifest.ts @@ -45,6 +45,8 @@ export interface RootFolders { export interface Subfolders { /** The templates folder */ templates: string; + /** The modules folder */ + modules: string; /** The Node functions folder */ serverlessFunctions: string; // Node functions /** Where to output the bundled static assets */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fd24bba9..27ec30d83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@types/minimist': specifier: ^1.2.5 version: 1.2.5 + '@types/node': + specifier: ^20.10.6 + version: 20.10.6 '@types/prompts': specifier: ^2.4.9 version: 2.4.9 @@ -152,6 +155,12 @@ importers: picocolors: specifier: ^1.0.0 version: 1.0.0 + postcss: + specifier: 8.4.35 + version: 8.4.35 + postcss-nested: + specifier: 6.0.1 + version: 6.0.1(postcss@8.4.35) pretty-ms: specifier: ^8.0.0 version: 8.0.0 @@ -293,7 +302,7 @@ importers: version: 5.1.6 vite: specifier: ^5.0.2 - version: 5.0.2 + version: 5.0.2(@types/node@20.10.6) playground/multibrand-site: dependencies: @@ -330,7 +339,7 @@ importers: version: 5.1.6 vite: specifier: ^5.0.2 - version: 5.0.2 + version: 5.0.2(@types/node@20.10.6) packages: @@ -1650,13 +1659,13 @@ packages: resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==} dependencies: '@types/connect': 3.4.35 - '@types/node': 20.10.4 + '@types/node': 20.10.6 dev: true /@types/connect@3.4.35: resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} dependencies: - '@types/node': 20.10.4 + '@types/node': 20.10.6 dev: true /@types/escape-html@1.0.4: @@ -1718,7 +1727,7 @@ packages: /@types/jsonfile@6.1.1: resolution: {integrity: sha512-GSgiRCVeapDN+3pqA35IkQwasaCh/0YFH5dEF6S88iDvEn901DjOeH3/QPY+XYP1DFzDZPvIvfeEgk+7br5png==} dependencies: - '@types/node': 20.10.4 + '@types/node': 20.10.6 dev: true /@types/lodash@4.14.202: @@ -1750,6 +1759,12 @@ packages: dependencies: undici-types: 5.26.5 + /@types/node@20.10.6: + resolution: {integrity: sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==} + dependencies: + undici-types: 5.26.5 + dev: true + /@types/prompts@2.4.9: resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} dependencies: @@ -1809,14 +1824,14 @@ packages: resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==} dependencies: '@types/mime': 1.3.2 - '@types/node': 20.10.4 + '@types/node': 20.10.6 dev: true /@types/serve-static@1.15.0: resolution: {integrity: sha512-z5xyF6uh8CbjAu9760KDKsH2FcDxZ2tFCsA4HIMWE6IkiYMXfVoa+4f9KX+FN0ZLsaMw1WNG2ETLA6N+/YA+cg==} dependencies: '@types/mime': 3.0.1 - '@types/node': 20.10.4 + '@types/node': 20.10.6 dev: true /@types/ws@8.5.10: @@ -1970,7 +1985,7 @@ packages: '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.23.2) '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2) react-refresh: 0.14.0 - vite: 5.0.2 + vite: 5.0.2(@types/node@20.10.6) transitivePeerDependencies: - supports-color dev: true @@ -2929,7 +2944,6 @@ packages: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - dev: true /cssstyle@3.0.0: resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==} @@ -6197,29 +6211,29 @@ packages: fsevents: 2.3.2 dev: true - /postcss-import@15.1.0(postcss@8.4.27): + /postcss-import@15.1.0(postcss@8.4.35): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: postcss: ^8.0.0 dependencies: - postcss: 8.4.27 + postcss: 8.4.35 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 dev: true - /postcss-js@4.0.1(postcss@8.4.27): + /postcss-js@4.0.1(postcss@8.4.35): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 dependencies: camelcase-css: 2.0.1 - postcss: 8.4.27 + postcss: 8.4.35 dev: true - /postcss-load-config@4.0.1(postcss@8.4.27): + /postcss-load-config@4.0.1(postcss@8.4.35): resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} engines: {node: '>= 14'} peerDependencies: @@ -6232,19 +6246,18 @@ packages: optional: true dependencies: lilconfig: 2.1.0 - postcss: 8.4.27 + postcss: 8.4.35 yaml: 2.3.4 dev: true - /postcss-nested@6.0.1(postcss@8.4.27): + /postcss-nested@6.0.1(postcss@8.4.35): resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 dependencies: - postcss: 8.4.27 + postcss: 8.4.35 postcss-selector-parser: 6.0.13 - dev: true /postcss-selector-parser@6.0.13: resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==} @@ -6252,7 +6265,6 @@ packages: dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 - dev: true /postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -6267,17 +6279,8 @@ packages: source-map-js: 1.0.2 dev: true - /postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 - dev: true - - /postcss@8.4.32: - resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==} + /postcss@8.4.35: + resolution: {integrity: sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==} engines: {node: ^10 || ^12 || >=14} dependencies: nanoid: 3.3.7 @@ -7214,11 +7217,11 @@ packages: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.0.0 - postcss: 8.4.27 - postcss-import: 15.1.0(postcss@8.4.27) - postcss-js: 4.0.1(postcss@8.4.27) - postcss-load-config: 4.0.1(postcss@8.4.27) - postcss-nested: 6.0.1(postcss@8.4.27) + postcss: 8.4.35 + postcss-import: 15.1.0(postcss@8.4.35) + postcss-js: 4.0.1(postcss@8.4.35) + postcss-load-config: 4.0.1(postcss@8.4.35) + postcss-nested: 6.0.1(postcss@8.4.35) postcss-selector-parser: 6.0.13 postcss-value-parser: 4.2.0 resolve: 1.22.2 @@ -7648,7 +7651,7 @@ packages: - rollup dev: false - /vite@5.0.2: + /vite@5.0.2(@types/node@20.10.6): resolution: {integrity: sha512-6CCq1CAJCNM1ya2ZZA7+jS2KgnhbzvxakmlIjN24cF/PXhRMzpM/z8QgsVJA/Dm5fWUWnVEsmtBoMhmerPxT0g==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -7676,8 +7679,9 @@ packages: terser: optional: true dependencies: + '@types/node': 20.10.6 esbuild: 0.19.8 - postcss: 8.4.31 + postcss: 8.4.35 rollup: 4.7.0 optionalDependencies: fsevents: 2.3.3 @@ -7713,7 +7717,7 @@ packages: dependencies: '@types/node': 20.10.4 esbuild: 0.19.8 - postcss: 8.4.32 + postcss: 8.4.35 rollup: 4.7.0 optionalDependencies: fsevents: 2.3.3