@embeddable.com/sdk-core
Version:
Core Embeddable SDK module responsible for web-components bundling and publishing.
254 lines (218 loc) • 7.69 kB
text/typescript
import * as fsSync from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as vite from "vite";
import { existsSync } from "node:fs";
import ora from "ora";
import {
EXTERNAL_LIBRARY_GLOBAL_HOOKS_META_NAME,
getComponentLibraryConfig,
getContentHash,
getGlobalHooksMeta,
} from "@embeddable.com/sdk-utils";
import { ResolvedEmbeddableConfig } from "./defineConfig";
import { RollupWatcher, RollupWatcherEvent } from "rollup";
const TEMP_JS_HOOK_FILE = "embeddableThemeHook.js";
const LIFECYCLE_OUTPUT_NAME = "embeddable-lifecycle";
const THEME_PROVIDER_OUTPUT_NAME = "embeddable-theme";
export default async (ctx: ResolvedEmbeddableConfig) => {
const watch = ctx.dev?.watch;
const progress = watch ? undefined : ora("Building global hooks...").start();
try {
await fs.mkdir(ctx.client.tmpDir, { recursive: true });
const { fileName: themeProvider, watcher: themeWatcher } =
await buildThemeHook(ctx);
const { lifecycleHooks, watcher: lifecycleWatcher } =
await buildLifecycleHooks(ctx);
await saveGlobalHooksMeta(ctx, themeProvider, lifecycleHooks);
progress?.succeed("Global hooks build completed");
return { themeWatcher, lifecycleWatcher };
} catch (error) {
progress?.fail("Global hooks build failed");
throw error;
}
};
/**
* Build theme hooks for a given component library.
*/
async function buildThemeHook(ctx: ResolvedEmbeddableConfig) {
const componentLibraries = ctx.client.componentLibraries;
const repoThemeHookExists = existsSync(ctx.client.customizationFile);
const imports = [];
const functionNames = [];
for (let i = 0; i < componentLibraries.length; i++) {
const libraryConfig = componentLibraries[i];
const { libraryName } = getComponentLibraryConfig(libraryConfig);
const libMeta = await getGlobalHooksMeta(ctx, libraryName);
const themeProvider = libMeta.themeProvider;
if (!themeProvider) continue;
// Prepare imports: library theme + repo theme (if exists)
const functionName = `libraryThemeProvider${i}`;
const libraryThemeImport = `import ${functionName} from '${libraryName}/dist/${themeProvider}'`;
functionNames.push(functionName);
imports.push(libraryThemeImport);
}
if (!imports.length && !repoThemeHookExists) {
return { fileName: undefined, watcher: undefined };
}
const repoThemeImport = repoThemeHookExists
? `import localThemeProvider from '${ctx.client.customizationFile.replace(/\\/g, "/")}';`
: "const localThemeProvider = () => {};";
// Generate a temporary file that imports both library and repo theme
await generateTemporaryHookFile(ctx, imports, functionNames, repoThemeImport);
// Build the temporary file with Vite
const buildResults = await buildWithVite(
ctx,
getTempHookFilePath(ctx),
THEME_PROVIDER_OUTPUT_NAME,
ctx.dev?.watch,
!ctx.dev?.watch,
);
// Cleanup temporary file
if (!ctx.dev?.watch) {
await cleanupTemporaryHookFile(ctx);
}
return buildResults;
}
/**
* Build theme hooks for a given component library.
*/
async function buildLifecycleHooks(ctx: ResolvedEmbeddableConfig) {
const componentLibraries = ctx.client.componentLibraries;
const builtLifecycleHooks: string[] = [];
const repoLifecycleExist = existsSync(ctx.client.lifecycleHooksFile);
let lifecycleWatcher: RollupWatcher | undefined = undefined;
// If lifecycle exists, build it right away to get the hashed output
if (repoLifecycleExist) {
const { fileName: repoLifecycleFileName, watcher } = await buildWithVite(
ctx,
ctx.client.lifecycleHooksFile,
LIFECYCLE_OUTPUT_NAME,
ctx.dev?.watch,
false,
);
if (ctx.dev?.watch) {
lifecycleWatcher = watcher;
}
builtLifecycleHooks.push(repoLifecycleFileName);
}
for (const libraryConfig of componentLibraries) {
const { libraryName } = getComponentLibraryConfig(libraryConfig);
const libMeta = await getGlobalHooksMeta(ctx, libraryName);
const lifecycleHooks = libMeta.lifecycleHooks;
for (const lifecycleHook of lifecycleHooks) {
const libLifecycleHook = path.resolve(
ctx.client.rootDir,
"node_modules",
libraryName,
"dist",
lifecycleHook,
);
const { fileName: lifecycleHookFileName } = await buildWithVite(
ctx,
libLifecycleHook,
LIFECYCLE_OUTPUT_NAME,
);
builtLifecycleHooks.push(lifecycleHookFileName);
}
}
return { lifecycleHooks: builtLifecycleHooks, watcher: lifecycleWatcher };
}
/**
* Write the final global hooks metadata to disk (themeHooksMeta, lifecycleHookMeta).
*/
async function saveGlobalHooksMeta(
ctx: ResolvedEmbeddableConfig,
themeProvider?: string,
lifecycleHooks?: string[],
) {
const metaFilePath = path.resolve(
ctx.client.buildDir,
EXTERNAL_LIBRARY_GLOBAL_HOOKS_META_NAME,
);
const data = JSON.stringify({ themeProvider, lifecycleHooks }, null, 2);
fsSync.writeFileSync(metaFilePath, data);
}
/**
* Generate a temporary file which imports the library theme and repository theme,
* replacing template placeholders.
*/
async function generateTemporaryHookFile(
ctx: ResolvedEmbeddableConfig,
libraryThemeImports: string[],
functionNames: string[],
repoThemeImport: string,
) {
const templatePath = path.resolve(
ctx.core.templatesDir,
"embeddableThemeHook.js.template",
);
const templateContent = await fs.readFile(templatePath, "utf8");
const newContent = templateContent
.replace("{{LIBRARY_THEME_IMPORTS}}", libraryThemeImports.join("\n"))
.replace("{{ARRAY_OF_LIBRARY_THEME_PROVIDERS}}", functionNames.join("\n"))
.replace("{{LOCAL_THEME_IMPORT}}", repoThemeImport);
// Write to temporary hook file
await fs.writeFile(getTempHookFilePath(ctx), newContent, "utf8");
}
/**
* Build a file with Vite and return the hashed output file name (e.g., embeddable-theme-xxxx.js).
*/
async function buildWithVite(
ctx: ResolvedEmbeddableConfig,
entryFile: string,
outputFile: string,
watch = false,
useHash = true,
) {
const fileContent = await fs.readFile(entryFile, "utf8");
const fileHash = getContentHash(fileContent);
// Bundle using Vite
const fileName = useHash ? `${outputFile}-${fileHash}` : outputFile;
const fileWatcher = await vite.build({
logLevel: watch ? "info" : "error",
build: {
emptyOutDir: false,
lib: {
entry: entryFile,
formats: ["es"],
fileName: fileName,
},
outDir: ctx.client.buildDir,
watch: watch ? {} : undefined,
},
});
if (watch) {
await waitForInitialBuild(fileWatcher as RollupWatcher);
}
const watcher: RollupWatcher | undefined = watch
? (fileWatcher as RollupWatcher)
: undefined;
return { fileName: `${fileName}.js`, watcher };
}
/**
* Remove the temporary hook file after building.
*/
async function cleanupTemporaryHookFile(ctx: ResolvedEmbeddableConfig) {
await fs.rm(getTempHookFilePath(ctx), { force: true });
}
/**
* Get the path to the temporary hook file in the build directory.
*/
function getTempHookFilePath(ctx: ResolvedEmbeddableConfig): string {
return path.resolve(ctx.client.tmpDir, TEMP_JS_HOOK_FILE);
}
function waitForInitialBuild(watcher: RollupWatcher): Promise<void> {
return new Promise((resolve, reject) => {
function onEvent(event: RollupWatcherEvent) {
if (event.code === "END") {
watcher.off("event", onEvent);
resolve();
} else if (event.code === "ERROR") {
watcher.off("event", onEvent);
reject(event.error);
}
}
watcher.on("event", onEvent);
});
}