Skip to content

Commit

Permalink
feat: generate remote components & remote elements automatically
Browse files Browse the repository at this point in the history
  • Loading branch information
maaaathis committed Dec 18, 2024
1 parent dd144d1 commit 419db0e
Show file tree
Hide file tree
Showing 196 changed files with 18,025 additions and 459 deletions.
250 changes: 165 additions & 85 deletions .pnp.cjs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions dev/remote-components-generator/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const remoteComponentGeneratorConfig = {
ignoreComponents: [""],
ignoreProps: ["tunnelId"],
};
76 changes: 76 additions & 0 deletions dev/remote-components-generator/generateRemoteComponents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ComponentFileLoader } from "./loading/ComponentFileLoader";
import { ComponentFileContentLoader } from "./loading/ComponentFileContentLoader";
import {
generateRemoteReactComponentFile,
generateRemoteReactComponentIndexFile,
} from "./generation/generateRemoteReactComponentFile";
import jetpack from "fs-jetpack";
import { prepareTypeScriptOutput } from "./generation/prepareTypeScriptOutput";
import { generateRemoteElementFile } from "./generation/generateRemoteElementFile";
import { remoteComponentGeneratorConfig } from "./config";

const componentFileLoader = new ComponentFileLoader();
const componentFileContentLoader = new ComponentFileContentLoader(
componentFileLoader,
);

async function generate() {
const config = remoteComponentGeneratorConfig;

console.log("🤓 Read component specification file");
const componentSpecificationFile = await componentFileLoader.loadFile();
console.log("✅ Done");
console.log("");

console.log("🧐 Parse component specification file");
let components = await componentFileContentLoader.parseJson(
componentSpecificationFile,
);
console.log("✅ Done");
console.log("");

console.log("💣 Remove ignored components");
config.ignoreComponents.map((ignoredComponent) => {
components = components.filter(
(item) => item.displayName != ignoredComponent,
);
});
console.log("✅ Done");
console.log("");

console.log("📝️ Generating remote-react-component files");
for (const component of components) {
const remoteReactComponentFile =
generateRemoteReactComponentFile(component);
await jetpack.writeAsync(
`packages/remote-react-components/src/${component.displayName}.ts`,
await prepareTypeScriptOutput(remoteReactComponentFile),
);
}
const remoteReactComponentsIndexFile =
generateRemoteReactComponentIndexFile(components);
await jetpack.writeAsync(
"packages/remote-react-components/src/index.ts",
await prepareTypeScriptOutput(remoteReactComponentsIndexFile),
);
console.log("✅ Done");
console.log("");

console.log("📝️ Generating remote-element files");
for (const component of components) {
const remoteElementFile = generateRemoteElementFile(component);
await jetpack.writeAsync(
`packages/remote-elements/src/${component.displayName}.ts`,
await prepareTypeScriptOutput(remoteElementFile),
);
}
await jetpack.writeAsync(
"packages/remote-elements/src/index.ts",
await prepareTypeScriptOutput(remoteReactComponentsIndexFile),
);
console.log("✅ Done");
console.log("");
console.log("✅ Generation finished successfully");
}

void generate();
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { ComponentDoc } from "react-docgen-typescript";
import { kebabize } from "../lib/kebabize";
import { remoteComponentGeneratorConfig } from "../config";

export function generateRemoteElementFile(
componentSpecification: ComponentDoc,
) {
const config = remoteComponentGeneratorConfig;
const componentProps = componentSpecification.props;

config.ignoreProps.map((prop) => delete componentProps[prop]);

const t = {
element: `Remote${componentSpecification.displayName}Element`,
propsType: `${componentSpecification.displayName}Props`,
name: componentSpecification.displayName,
props: Object.keys(componentProps)
.filter((propName) => !propName.startsWith("on"))
.map((propName) => {
const key = propName.includes("-") ? `'${propName}'` : propName;
return `${key}: {}`;
})
.join(",\n"),
events: Object.keys(componentProps)
.filter((propName) => propName.startsWith("on"))
.map((propName) => {
const formattedName = propName[2].toLowerCase() + propName.slice(3);
return `${formattedName}: {}`;
})
.join(",\n"),
};

return `\
import { createRemoteElement } from "@remote-dom/core/elements";
import type { ${t.propsType} } from "@mittwald/flow-react-components/${t.name}";
export type { ${t.propsType} } from "@mittwald/flow-react-components/${t.name}";
export const ${t.element} = createRemoteElement<${t.propsType}>({
properties: {
${t.props}
},
${
t.events && t.events.length > 0
? `events: {
${t.events}
},`
: "events: {},"
}
});
declare global {
interface HTMLElementTagNameMap {
"flr-${kebabize(t.name)}": InstanceType<typeof ${t.element}>;
}
}
customElements.define("flr-${kebabize(t.name)}", ${t.element});
`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ComponentDoc } from "react-docgen-typescript";
import { kebabize } from "../lib/kebabize";
import { remoteComponentGeneratorConfig } from "../config";

export function generateRemoteReactComponentFile(
componentSpecification: ComponentDoc,
) {
const config = remoteComponentGeneratorConfig;
const componentProps = componentSpecification.props;

config.ignoreProps.map((prop) => delete componentProps[prop]);

const t = {
component: `Remote${componentSpecification.displayName}Element`,
name: componentSpecification.displayName,
events: Object.keys(componentProps)
.filter((propName) => propName.startsWith("on"))
.map((propName) => {
const formattedName = propName[2].toLowerCase() + propName.slice(3);
return `${propName}: { event: "${formattedName}" } as never`;
})
.join(",\n"),
};

return `\
import { createRemoteComponent } from "@remote-dom/react";
import { ${t.component} } from "@mittwald/flow-remote-elements";
export const ${t.name} = createRemoteComponent("flr-${kebabize(t.name)}", ${t.component}, {${
t.events && t.events.length > 0
? `eventProps: {
${t.events}
},`
: ""
}});
`;
}

export function generateRemoteReactComponentIndexFile(
componentSpecifications: ComponentDoc[],
) {
let indexFile = "";

componentSpecifications.map((component) => {
indexFile += `export * from "./${component.displayName}";`;
});

return indexFile;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { format } from "../lib/format";

const header = `\
/* eslint-disable */
/* prettier-ignore */
/* This file is auto-generated with the remote-components-generator */
`;

export const prepareTypeScriptOutput = async (
content: string,
): Promise<string> => {
const formatted = await format(content);
return header + formatted;
};
20 changes: 20 additions & 0 deletions dev/remote-components-generator/lib/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import prettier from "prettier";
import VError from "verror";
import { makeError } from "./makeError";

export const format = async (ts: string): Promise<string> => {
try {
return await prettier.format(ts, {
plugins: [],
parser: "typescript",
});
} catch (error) {
throw new VError(
{
cause: makeError(error),
name: "CodeFormattingError",
},
"Failed to format the generated code. This usually happens, when the generated code has syntax errors. Please file an issue.",
);
}
};
5 changes: 5 additions & 0 deletions dev/remote-components-generator/lib/kebabize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const kebabize = (str: string): string =>
str.replace(
/[A-Z]+(?![a-z])|[A-Z]/g,
($, ofs) => (ofs ? "-" : "") + $.toLowerCase(),
);
12 changes: 12 additions & 0 deletions dev/remote-components-generator/lib/makeError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import VError from "verror";
import { getProperty } from "dot-prop";

export const makeError = (error: unknown): Error =>
error instanceof Error
? error
: new VError(
{
name: getProperty(error, "name") ?? "Error",
},
getProperty(error, "message") ?? "",
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { parseAsync } from "yieldable-json";
import VError from "verror";
import { makeError } from "../lib/makeError";
import type { FileContentLoader, FileLoader } from "./types";
import type { ComponentDoc } from "react-docgen-typescript";

export class ComponentFileContentLoader implements FileContentLoader {
public readonly fileLoader: FileLoader;

public constructor(fileLoader: FileLoader) {
this.fileLoader = fileLoader;
}

public async load() {
try {
const fileContent = await this.fileLoader.loadFile();
return await this.parseJson(fileContent);
} catch (error) {
throw new VError(
{
cause: makeError(error),
name: "ComponentFileContentLoaderError",
},
"Failed loading content",
);
}
}

public async parseJson(json: string): Promise<ComponentDoc[]> {
return new Promise((res, rej) => {
return parseAsync(json, (err: Error | null, data: unknown) => {
if (err) {
rej(err);
} else {
res(data as ComponentDoc[]);
}
});
});
}
}
26 changes: 26 additions & 0 deletions dev/remote-components-generator/loading/ComponentFileLoader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import jetpack from "fs-jetpack";
import VError from "verror";
import { makeError } from "../lib/makeError";
import type { FileLoader } from "./types";

export class ComponentFileLoader implements FileLoader {
public async loadFile(): Promise<string> {
try {
const file = await jetpack.readAsync(
"./packages/components/out/doc-properties.json",
);
if (file === undefined || file === "") {
throw new Error(`doc-properties.json file not found`);
}
return file;
} catch (error) {
throw new VError(
{
cause: makeError(error),
name: "ComponentFileLoaderError",
},
"File loading failed",
);
}
}
}
9 changes: 9 additions & 0 deletions dev/remote-components-generator/loading/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ComponentDoc } from "react-docgen-typescript";

export interface FileLoader {
loadFile(): Promise<string>;
}

export interface FileContentLoader {
parseJson(json: string): Promise<ComponentDoc[]>;
}
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"all": "nx run-many --targets=\"$@\"",
"build": "run all build",
"build:deps:watch": "nx watch --projects=$@ --includeDependentProjects -- nx run-many --targets=build --projects=$(tsx dev/nxDependencies.ts $@)",
"build:remote-components": "tsx ./dev/remote-components-generator/generateRemoteComponents.ts",
"components": "nx run @mittwald/flow-react-components:\"$@\"",
"demo:remote-dom": "nx run @mittwald/flow-demo-remote-dom:\"$@\"",
"dev:init-githooks": "yarn dlx simple-git-hooks",
Expand All @@ -29,20 +30,28 @@
"@eslint/js": "^9.16.0",
"@nx/devkit": "^20.2.0",
"@types/eslint__js": "^8.42.3",
"@types/node": "^22.10.1",
"@types/verror": "^1.10.10",
"@types/yieldable-json": "^2.0.2",
"dot-prop": "^9.0.0",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-json": "^4.0.1",
"eslint-plugin-prettier": "^5.2.1",
"fs-jetpack": "^5.1.0",
"lerna": "^8.1.9",
"nx": "^20.2.0",
"prettier": "^3.4.2",
"prettier-plugin-jsdoc": "^1.3.0",
"prettier-plugin-pkgsort": "^0.2.1",
"prettier-plugin-sort-json": "^4.0.0",
"react-docgen-typescript": "^2.2.2",
"simple-git-hooks": "^2.11.1",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"typescript-eslint": "^8.17.0"
"typescript-eslint": "^8.16.0",
"verror": "^1.10.1",
"yieldable-json": "^2.1.0"
},
"dependenciesMeta": {
"@fortawesome/fontawesome-common-types": {
Expand Down
Loading

0 comments on commit 419db0e

Please sign in to comment.