Skip to content

Commit

Permalink
Import CSS and Inject into Iframe (#412)
Browse files Browse the repository at this point in the history
This PR adds user styling into the preview iframe. First, Studio
dynamically imports user CSS and adds it to the head of Studio as a
disabled style tag. Then, the `IframePortal` injects the style into the
preview panel depending on the page CSS and the CSS of the components in
the component tree.

Note that this PR would break acceptance test screenshots since Tailwind
is not currently configured in e2e-tests, so we turned off screenshots
in this PR. This will be reverted in the following PR when we add
Tailwind.

J-SLAP-2912
TEST=manual
  • Loading branch information
alextaing authored Oct 23, 2023
1 parent de44641 commit c030a42
Show file tree
Hide file tree
Showing 23 changed files with 316 additions and 121 deletions.
3 changes: 1 addition & 2 deletions e2e-tests/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { PlaywrightTestConfig, expect } from "@playwright/test";
import fs from "node:fs";
import os from "node:os";

expect.extend({
async toHaveContents(filepath: string, expectedContents: string) {
Expand Down Expand Up @@ -52,7 +51,7 @@ const config: PlaywrightTestConfig = {
video: "on",
},
workers: 1,
ignoreSnapshots: os.platform() !== "darwin",
ignoreSnapshots: true,
};

export default config;
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/studio-plugin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"colors": "^1.4.0",
"cross-fetch": "^4.0.0",
"dependency-tree": "^10.0.9",
"filing-cabinet": "^4.1.6",
"kill-port": "^2.0.1",
"prettier": "2.8.3",
"simple-git": "^3.16.0",
Expand Down
43 changes: 36 additions & 7 deletions packages/studio-plugin/src/orchestrators/ParsingOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@ export function createTsMorphProject(tsConfigFilePath: string) {
*/
export default class ParsingOrchestrator {
private filepathToFileMetadata: Record<string, FileMetadata> = {};
private filepathToDependencyTree: Record<string, Tree> = {};
private pageNameToPageFile: Record<string, PageFile> = {};
private siteSettingsFile?: SiteSettingsFile;
private studioData?: StudioData;
private paths: UserPaths;
private layoutOrchestrator: LayoutOrchestrator;
/**
* Each key in this object is a ComponentFile filepath which
* maps to the rest of the ComponentFile's dependency tree.
*/
private dependencyTreesObject: Record<string, Tree> = {};

/** All paths are assumed to be absolute. */
constructor(
Expand Down Expand Up @@ -113,7 +117,10 @@ export default class ParsingOrchestrator {
}

if (filepath.startsWith(this.paths.components)) {
delete this.filepathToDependencyTree[filepath];
const componentDepTreeRoot = this.getComponentDepTreeRoot(filepath);
if (componentDepTreeRoot) {
delete this.dependencyTreesObject[componentDepTreeRoot];
}
if (this.filepathToFileMetadata.hasOwnProperty(filepath)) {
const originalMetadataUUID =
this.filepathToFileMetadata[filepath].metadataUUID;
Expand Down Expand Up @@ -197,11 +204,15 @@ export default class ParsingOrchestrator {
});

if (absPath.startsWith(this.paths.components)) {
this.updateFilepathToDependencyTree(absPath);
this.updateDependencyTrees(absPath);
const componentDepTreeRoot = this.getComponentDepTreeRoot(absPath);
if (!componentDepTreeRoot) {
throw new Error(`Could not find dependency tree for ${absPath}`);
}
const componentFile = new ComponentFile(
absPath,
this.project,
this.filepathToDependencyTree[absPath]
this.dependencyTreesObject[componentDepTreeRoot]
);
const result = componentFile.getComponentMetadata();
if (result.isErr) {
Expand All @@ -216,12 +227,30 @@ export default class ParsingOrchestrator {
);
};

private updateFilepathToDependencyTree(absPath: string) {
this.filepathToDependencyTree[absPath] = dependencyTree({
private updateDependencyTrees(absPath: string) {
const newDepTree = dependencyTree({
filename: absPath,
directory: upath.dirname(absPath),
visited: this.filepathToDependencyTree,
visited: this.dependencyTreesObject,
});
if (typeof newDepTree === "string") {
throw new Error(`Invalid dependency tree returned for ${absPath}.`);
}
this.dependencyTreesObject = {
...this.dependencyTreesObject,
...newDepTree,
};
}

/**
* Given a Unix filepath, this function finds the corresponding
* dependency tree root. This is important since the paths within
* the dependency tree may be either Unix or Windows.
*/
private getComponentDepTreeRoot(unixFilepath: string) {
return Object.keys(this.dependencyTreesObject).find(
(path) => upath.toUnix(path) === unixFilepath
);
}

private initPageNameToPageFile(): Record<string, PageFile> {
Expand Down
29 changes: 20 additions & 9 deletions packages/studio-plugin/src/parsers/StudioSourceFileParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import TypeNodeParsingHelper, {
import { parseSync as babelParseSync } from "@babel/core";
import NpmLookup from "./helpers/NpmLookup";
import { TypelessPropVal } from "../types";
import cabinet from "filing-cabinet";

export type ParsedImport = {
importSource: string;
Expand Down Expand Up @@ -123,19 +124,29 @@ export default class StudioSourceFileParser {
parseCssImports(): string[] {
const cssImports: string[] = [];

const resolveRelativeFilepath = (filepath: string) => {
if (filepath.startsWith(".")) {
return upath.resolve(
upath.dirname(this.sourceFile.getFilePath()),
filepath
const getAbsoluteImportFilepath = (importPath: string) => {
if (upath.isAbsolute(importPath)) {
return upath.toUnix(importPath);
}
const resolvedPath = cabinet({
partial: importPath,
directory: upath.dirname(this.sourceFile.getFilePath()),
filename: this.sourceFile.getFilePath(),
});
if (!resolvedPath) {
throw new Error(
`${importPath} could not be resolved when parsing ` +
`${this.sourceFile.getFilePath()} for CSS imports.`
);
}
return filepath;
return upath.toUnix(resolvedPath);
};

this.sourceFile.getImportDeclarations().forEach((importDeclaration) => {
const { source } = StaticParsingHelpers.parseImport(importDeclaration);
if (source.endsWith(".css")) {
cssImports.push(resolveRelativeFilepath(source));
const { source: importPath } =
StaticParsingHelpers.parseImport(importDeclaration);
if (importPath.endsWith(".css")) {
cssImports.push(getAbsoluteImportFilepath(importPath));
}
});
return cssImports;
Expand Down
7 changes: 4 additions & 3 deletions packages/studio-plugin/src/sourcefiles/ComponentFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import tryUsingResult from "../errors/tryUsingResult";
import { ParsingError, ParsingErrorKind } from "../errors/ParsingError";
import { Result } from "true-myth";
import { Tree } from "dependency-tree";
import upath from "upath";

/**
* ComponentFile is responsible for parsing a single component file, for example
Expand Down Expand Up @@ -61,9 +62,9 @@ export default class ComponentFile {

function getCssFilesFromDependencyTree(dependencyTree: Tree): string[] {
const cssFiles = Object.entries(dependencyTree).reduce(
(cssFiles, [filename, subDependencyTree]) => {
if (filename.includes(".css")) {
cssFiles.add(filename);
(cssFiles, [absFilepath, subDependencyTree]) => {
if (absFilepath.endsWith(".css")) {
cssFiles.add(upath.toUnix(absFilepath));
}
return new Set([
...cssFiles,
Expand Down
11 changes: 6 additions & 5 deletions packages/studio-plugin/src/writers/StudioSourceFileWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import prettier from "prettier";
import fs from "fs";
import { PropVal, PropValueKind, PropValues, PropValueType } from "../types";
import { getImportSpecifierWithExtension } from "../utils/getImportSpecifier";
import upath from "upath";

/**
* StudioSourceFileWriter contains shared business logic for
Expand Down Expand Up @@ -78,13 +77,15 @@ export default class StudioSourceFileWriter {
});
});
this.sourceFile.fixMissingImports();

const NODE_MODULES_DIR = "/node_modules/";
cssImports?.forEach((importSource) => {
const moduleSpecifier = upath.isAbsolute(importSource)
? getImportSpecifierWithExtension(
const moduleSpecifier = importSource.includes(NODE_MODULES_DIR)
? importSource.split(NODE_MODULES_DIR)[1]
: getImportSpecifierWithExtension(
this.sourceFile.getFilePath(),
importSource
)
: importSource;
);
this.sourceFile.addImportDeclaration({ moduleSpecifier });
});
this.sourceFile.organizeImports();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "./index.css";
import "@yext/search-ui-react/lib/bundle.css";

import ComplexBanner from "../ComponentFile/ComplexBanner";
import "@yext/search-ui-react/index.css";
import { TemplateProps } from "@yext/pages";

export default function BasicLayout({ document }: TemplateProps) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
div {
background-color: lightsalmon;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "./index.css";
import "@yext/search-ui-react/lib/bundle.css";

import ComplexBanner from "../ComponentFile/ComplexBanner";
import "@yext/search-ui-react/index.css";
import { GetPath, TemplateConfig, TemplateProps } from "@yext/pages";

export const config: TemplateConfig = {
Expand Down
3 changes: 3 additions & 0 deletions packages/studio-plugin/tests/__fixtures__/PageFile/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
div {
background-color: lightsalmon;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import "@yext/search-ui-react/bundle.css";
import ComplexBanner from "../ComponentFile/ComplexBanner";
import { GetPath, TemplateConfig, TemplateProps } from "@yext/pages";

export const config: TemplateConfig = {
stream: {
$id: "studio-stream-id",
localization: { locales: ["en"] },
filter: { entityTypes: ["location"] },
fields: ["title", "slug"],
},
};

export const getPath: GetPath<TemplateProps> = ({
document,
}: TemplateProps) => {
return document.slug;
};

export default function UnsupportedCss({ document }: TemplateProps) {
return <ComplexBanner title={document.title} num={3} bool={false} />;
}
12 changes: 6 additions & 6 deletions packages/studio-plugin/tests/sourcefiles/ComponentFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ describe("getComponentMetadata", () => {

it("correctly parses a string literal type", () => {
const pathToComponent = getComponentPath("StringLiteralBanner");
const componentFile = new ComponentFile(pathToComponent, project);
const componentFile = new ComponentFile(pathToComponent, project, {});
const result = componentFile.getComponentMetadata();
assertIsOk(result);
expect(result.value.propShape).toEqual({
Expand All @@ -191,7 +191,7 @@ describe("getComponentMetadata", () => {

it("can parse a type that shares the same name as a Studio type", () => {
const pathToComponent = getComponentPath("StudioTypeNameBanner");
const componentFile = new ComponentFile(pathToComponent, project);
const componentFile = new ComponentFile(pathToComponent, project, {});
const result = componentFile.getComponentMetadata();
assertIsOk(result);
expect(result.value.propShape).toEqual({
Expand All @@ -204,23 +204,23 @@ describe("getComponentMetadata", () => {

it("Throws an Error if the prop interface is a utility type", () => {
const pathToComponent = getComponentPath("UtilityTypeBanner");
const componentFile = new ComponentFile(pathToComponent, project);
const componentFile = new ComponentFile(pathToComponent, project, {});
expect(componentFile.getComponentMetadata()).toHaveErrorMessage(
'Unable to resolve type Omit<UtilityTypeBannerProps, "missing">.'
);
});

it("Throws an Error if HexColor is not imported from Studio", () => {
const pathToComponent = getComponentPath("NonStudioImportBanner");
const componentFile = new ComponentFile(pathToComponent, project);
const componentFile = new ComponentFile(pathToComponent, project, {});
expect(componentFile.getComponentMetadata()).toHaveErrorMessage(
"Prop type HexColor is invalid because it is not imported from @yext/studio"
);
});

it("Throws an Error if imported prop interface is missing HexColor import", () => {
const pathToComponent = getComponentPath("MissingExternalImportBanner");
const componentFile = new ComponentFile(pathToComponent, project);
const componentFile = new ComponentFile(pathToComponent, project, {});
expect(componentFile.getComponentMetadata()).toHaveErrorMessage(
"Prop type HexColor is invalid because it is not imported from @yext/studio"
);
Expand All @@ -236,7 +236,7 @@ describe("getComponentMetadata", () => {

it("Throws an Error when an prop type is missing for prop within initialProps", () => {
const pathToComponent = getComponentPath("MissingTypeInitialBanner");
const componentFile = new ComponentFile(pathToComponent, project);
const componentFile = new ComponentFile(pathToComponent, project, {});
expect(componentFile.getComponentMetadata()).toHaveErrorMessage(
/^Could not find prop type for/
);
Expand Down
4 changes: 3 additions & 1 deletion packages/studio-plugin/tests/sourcefiles/LayoutFile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ describe("getLayoutState", () => {
const expectedIndexCssPath = getFixturePath("LayoutFile/index.css");
expect(result.value.cssImports).toEqual([
expectedIndexCssPath,
"@yext/search-ui-react/index.css",
expect.stringContaining(
"/node_modules/@yext/search-ui-react/lib/bundle.css"
),
]);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ describe("getPageState", () => {
const expectedIndexCssPath = getFixturePath("PageFile/index.css");
expect(result.value.cssImports).toEqual([
expectedIndexCssPath,
"@yext/search-ui-react/index.css",
expect.stringContaining(
"/node_modules/@yext/search-ui-react/lib/bundle.css"
),
]);
});

Expand Down Expand Up @@ -124,5 +126,13 @@ describe("getPageState", () => {
);
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
});

it("cannot resolve node_module CSS import using package.json export alias", () => {
const pageFile = createPageFile("unsupportedCssImport");

expect(pageFile.getPageState()).toHaveErrorMessage(
/^@yext\/search-ui-react\/bundle.css could not be resolved /
);
});
});
});
3 changes: 2 additions & 1 deletion packages/studio-ui/src/AppWithLazyLoading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import LoadingOverlay from "./components/LoadingOverlay";
import { Suspense, lazy, useEffect, useState } from "react";
import useStudioStore from "./store/useStudioStore";
import ProgressBar from "./components/ProgressBar";
import loadComponents from "./utils/loadComponents";
import { loadComponents, loadStyling } from "./utils/loadUserAssets";
import classNames from "classnames";

const AppPromise = import("./App");
Expand All @@ -18,6 +18,7 @@ export default function AppWithLazyLoading() {

useEffect(() => {
loadComponents();
loadStyling();
void AppPromise.then(() => setAppLoaded(true));
}, []);

Expand Down
Loading

0 comments on commit c030a42

Please sign in to comment.