@zyrab/domo-ssg
Version:
A Static Site Generator (SSG) for Domo-based projects.
246 lines (200 loc) • 7.5 kB
JavaScript
import { writeFileSync, mkdirSync, existsSync, rmSync, readFileSync } from "fs";
import { join, dirname, resolve } from "path";
import { fileURLToPath, pathToFileURL } from "url";
import { build } from "esbuild";
import { createRequire } from "module";
import { generateElementScript, getHash } from "./event-extraction.js";
import { registry } from "./Registry.js";
import { formatComponentName } from "./utils.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const DOMO_CLIENT_PACKAGE = "@zyrab/domo/client";
let DOMO_CLIENT_SOURCE;
try {
// This works across different package managers
DOMO_CLIENT_SOURCE = require.resolve(DOMO_CLIENT_PACKAGE);
} catch (e) {
// Fallback for local dev if not linked
DOMO_CLIENT_SOURCE = join(__dirname, "../../domo/src/client/domo.client.js");
}
const cache = {
runtime: null,
events: new Map(), // normalizedHash -> file
islands: new Map(), // hash -> file
};
/**
* Converts a function name to your file naming convention based on strict patterns.
* @param {string} funcName - e.g., "createHeader", "createPreviewPage", "copyCode"
* @returns {string} - e.g., "header", "preview-page", "handle-copy-code"
*/
const rewriteDomoPlugin = {
name: "rewrite-domo",
setup(build) {
build.onResolve({ filter: /^@zyrab\/domo$|^domo$/ }, (args) => {
// Points the browser to your pre-bundled runtime
return { path: "/js/domo.runtime.js", external: true };
});
},
};
/**
* Traverses the Domo tree to find all elements with events or islands.
*/
export function collectMetadata(node, out = { events: [], islands: [] }) {
if (!node || typeof node !== "object") return out;
const el = node.element;
if ((el?._events?.length > 0 || el?._refs?.length > 0) && !el?._island) {
out.events.push({
id: el._attr["data-domo-id"] || el._attr["id"],
events: el._events || [],
states: el._state || {},
refs: el._refs || [],
});
}
if (el?._island && el?.__island) {
const rawName = el.__island.name; // e.g., "createPreviewPage"
const fileKey = formatComponentName(rawName); // "preview-page"
// Look up the exact file path from your singleton
const filePath = registry.getRoute(fileKey);
if (!filePath) {
console.warn(`[Domo-SSG] Could not find file for island component: ${rawName}`);
} else {
out.islands.push({
id: el._attr["data-domo-id"] || el._attr["id"],
path: filePath,
});
}
}
if (Array.isArray(el?._child)) {
for (const child of el._child) {
collectMetadata(child, out);
}
}
return out;
}
/**
* Bundle runtime (once)
*/
async function bundleRuntime(outputDir) {
if (cache.runtime) return cache.runtime;
const file = "domo.runtime.js";
const out = join(outputDir, "js", file);
await build({
entryPoints: [DOMO_CLIENT_SOURCE],
bundle: true,
minify: true,
format: "esm",
outfile: out,
platform: "browser",
});
cache.runtime = file;
return file;
}
/**
* Bundle event logic
*/
async function bundleEvents(metadata, jsDir, tempDir) {
if (metadata.events.length === 0) return null;
const raw = metadata.events
.map(({ id, events, states, refs }) => generateElementScript(id, events, states, refs))
.join("\n\n");
const idSignature = metadata.events.map((e) => e.id).join("|");
let numericalHash = 5381;
for (let i = 0; i < idSignature.length; i++) {
numericalHash = (numericalHash * 33) ^ idSignature.charCodeAt(i);
}
const fileHash = (numericalHash >>> 0).toString(36);
if (cache.events.has(fileHash)) return cache.events.get(fileHash);
const file = `${fileHash}.events.js`;
const entry = join(tempDir, `${fileHash}.entry.js`);
writeFileSync(entry, raw, "utf8");
await build({
entryPoints: [entry],
bundle: true,
minify: true,
format: "esm",
outfile: join(jsDir, file),
packages: "external",
plugins: [rewriteDomoPlugin],
platform: "browser",
});
rmSync(entry);
cache.events.set(fileHash, file);
return file;
}
async function bundleIslands(metadata, jsDir, tempDir) {
const islandsToBundle = metadata.islands.filter((i) => i.path);
if (islandsToBundle.length === 0) return [];
const entryPoints = {};
// Create wrappers for the islands
for (const island of islandsToBundle) {
const { path: filePath, id } = island;
const content = readFileSync(filePath, "utf8");
const hash = getHash(content);
const entryPath = join(tempDir, `${hash}.entry.js`);
const absolutePath = resolve(process.cwd(), filePath).replace(/\\/g, "/");
const wrapper = `
import Island from "${absolutePath}";
const el = document.querySelector('[data-domo-id="${id}"]');
if (el) {
const instance = Island();
if (instance && instance._isDomo) {
el.appendChild(instance.build());
} else if (instance instanceof DocumentFragment || instance instanceof HTMLElement) {
el.appendChild(instance);
}
}
`;
writeFileSync(entryPath, wrapper, "utf8");
// Outputs to: dist/js/islands/hash.js
entryPoints[`islands/${hash}`] = entryPath;
}
// 2. The Modern esbuild Call
const result = await build({
entryPoints,
bundle: true,
splitting: true,
minify: true,
format: "esm",
outdir: jsDir,
// --> THE MAGIC BULLET FOR NPM PACKAGES <--
packages: "external",
plugins: [rewriteDomoPlugin], // Injects our Domo rewrite
// Tells esbuild how to name the shared files so it looks like your project
// instead of random chunk strings
chunkNames: "components/[name]-[hash]",
metafile: true,
platform: "browser",
});
// Clean up temp files
Object.values(entryPoints).forEach((entryPath) => rmSync(entryPath));
// Extract generated paths for injection
const allGeneratedPaths = Object.keys(result.metafile.outputs).map((filePath) => {
const relativeToDist = filePath.replace(/\\/g, "/").split("/").slice(1).join("/");
let path = `/${relativeToDist}`;
// 3. Remove "js/" specifically if it appears immediately after the leading slash
// This transforms "/js/main.js" -> "main.js"
return path.replace(/^\/js\//, "");
});
return allGeneratedPaths.filter((path) => path.endsWith(".js"));
}
/**
* Main orchestrator
*/
export async function writeJs(content, outputDir) {
const metadata = collectMetadata(content);
const hasInteractivity = metadata.events.length > 0 || metadata.islands.length > 0;
if (!hasInteractivity) return null;
// console.log(metadata);
const jsDir = join(outputDir, "js");
const tempDir = join(outputDir, ".domo_temp");
if (!existsSync(jsDir)) mkdirSync(jsDir, { recursive: true });
if (!existsSync(tempDir)) mkdirSync(tempDir, { recursive: true });
const hasIslands = metadata.islands.length > 0;
const [runtime, events, islands] = await Promise.all([
// Only bundle the client runtime when there are actual islands (R4)
hasIslands ? bundleRuntime(outputDir) : Promise.resolve(null),
bundleEvents(metadata, jsDir, tempDir),
bundleIslands(metadata, jsDir, tempDir),
]);
return [runtime, events, ...islands].filter(Boolean);
}