import * as fs from "node:fs"; import * as path from "node:path"; import dotenv from "dotenv"; import beautify from "js-beautify"; dotenv.config(); const { html: beautifyHtml } = beautify; /** * Recursively deletes a directory. */ function clearDirectory(dir: string) { if (!fs.existsSync(dir)) return; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { clearDirectory(entryPath); fs.rmdirSync(entryPath); } else { fs.unlinkSync(entryPath); } } } /** * Copies all .js files from dist/ to output/ */ function copyAllJSFiles(inputDir: string, outputDir: string): void { const jsFiles = getAllFilesWithExtension(inputDir, ".js"); if (jsFiles.length === 0) { console.warn(`โš ๏ธ No JavaScript files found in ${inputDir}`); return; } for (const inputFile of jsFiles) { const relativePath = path.relative(inputDir, inputFile); const outputFile = path.join(outputDir, relativePath); ensureDirExists(path.dirname(outputFile)); fs.copyFileSync(inputFile, outputFile); console.log(`๐Ÿ“œ Copied JS: ${relativePath}`); } } /** * Recursively copies a directory and its contents. */ function copyDirectory(src: string, dest: string) { if (!fs.existsSync(src)) { console.warn(`โš ๏ธ Source directory not found: ${src}`); return; } ensureDirExists(dest); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { copyDirectory(srcPath, destPath); } else { fs.copyFileSync(srcPath, destPath); } } } /** * Ensures a directory exists before writing or copying. */ function ensureDirExists(dir: string) { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } function formatAstroIslandAttributes(html: string): string { // For each , put attributes on new lines return html.replace(/]*)>/gi, (match, attrs) => { // ๐Ÿ”ฅ Convert HTML-encoded quotes to actual quotes const decoded = attrs.replace(/"/g, '""'); // Split attributes by whitespace const formattedAttrs = decoded .trim() .split(/\s+/) .filter(Boolean) .map((attr: string) => ` ${attr}`) // indent each attr .join("\n"); return ``; }); } /** * Recursively collects files with a given extension in a directory. */ function getAllFilesWithExtension(dir: string, ext: string): string[] { let results: string[] = []; if (!fs.existsSync(dir)) return results; for (const file of fs.readdirSync(dir)) { const filePath = path.join(dir, file); const stat = fs.statSync(filePath); if (stat.isDirectory()) { results = results.concat(getAllFilesWithExtension(filePath, ext)); } else { // Check if it ends with the extension (e.g., .js) // AND does not contain the string ".astro" const isTargetExtension = filePath.endsWith(ext); const isAstroInternal = file.includes(".astro"); if (isTargetExtension && !isAstroInternal) { results.push(filePath); } else if (isTargetExtension && isAstroInternal) { // Optional: Log what we are filtering out for peace of mind console.log(`๐Ÿšซ Filtering out internal Astro script: ${file}`); } } } return results; } /** * Converts dist/foo/index.html โ†’ output/foo.html * Keeps dist/index.html as output/index.html */ function getFlattenedHtmlOutputPath( inputFile: string, inputDir: string, outputDir: string, ): string { const relative = path.relative(inputDir, inputFile); const parts = relative.split(path.sep); if (parts.length > 1 && parts[parts.length - 1] === "index.html") { const folderName = parts[parts.length - 2]; return path.join(outputDir, `${folderName}.html`); } return path.join(outputDir, relative); } /** * Extracts all ... blocks from HTML. */ function keepAstroIslands(html: string): string { const astroIslandRegex = /]*>[\s\S]*?<\/astro-island>/gi; const islands = html.match(astroIslandRegex) || []; return islands.join("\n"); } function prefixAssetPathsInFile(filePath: string) { const ASSET_PATH = process.env.ASSET_PATH || ""; let content = fs.readFileSync(filePath, "utf-8"); // Replace "/assets/..." inside string literals with prefixed path // Match quotes + /assets/ at start of string content = content.replace(/(["'`])\/assets\//g, `$1/${ASSET_PATH}/`); fs.writeFileSync(filePath, content, "utf-8"); console.log(`Prefixed asset paths in ${filePath}`); } /** * Processes and flattens HTML files (expanded output) */ function processAllHTML(inputDir: string, outputDir: string): void { const htmlFiles = getAllFilesWithExtension(inputDir, ".html"); if (htmlFiles.length === 0) { console.warn(`โš ๏ธ No HTML files found in ${inputDir}`); return; } for (const inputFile of htmlFiles) { const outputFile = getFlattenedHtmlOutputPath( inputFile, inputDir, outputDir, ); const html = fs.readFileSync(inputFile, "utf-8"); // Filter to only keep astro-islands const filtered = keepAstroIslands(html); // Beautify / expand the HTML before writing const beautified = beautifyHtml(filtered, { indent_size: 2, max_preserve_newlines: 1, preserve_newlines: true, unformatted: ["code", "pre", "em", "strong"], wrap_line_length: 120, }); const prettyHtml = formatAstroIslandAttributes(beautified); const innerStringifiedHtml = stringifyAstroIslandContents(prettyHtml); ensureDirExists(path.dirname(outputFile)); fs.writeFileSync(outputFile, innerStringifiedHtml, "utf-8"); console.log( `โœ… Processed HTML: ${path.relative(inputDir, inputFile)} โ†’ ${path.relative(outputDir, outputFile)}`, ); } } function processAllJsFiles(dir: string) { const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); if (entry.isDirectory()) { processAllJsFiles(fullPath); } else if (entry.name.endsWith(".js")) { prefixAssetPathsInFile(fullPath); } } } /** * Main function */ function processOutput() { const inputDir = path.resolve("dist"); const outputDir = path.resolve("output"); const assetsSrc = path.join(inputDir, "assets"); const assetsDest = path.join(outputDir, "assets"); if (!fs.existsSync(inputDir)) { console.error(`โŒ Input directory not found: ${inputDir}`); process.exit(1); } // ๐Ÿงน Clear output directory before writing if (fs.existsSync(outputDir)) { console.log("๐Ÿงน Clearing output directory..."); clearDirectory(outputDir); } else { ensureDirExists(outputDir); } console.log("โš™๏ธ Processing HTML files..."); processAllHTML(inputDir, outputDir); console.log("๐Ÿ“ฆ Copying assets..."); copyDirectory(assetsSrc, assetsDest); console.log("๐Ÿ“œ Copying JavaScript files..."); copyAllJSFiles(inputDir, outputDir); console.log("๐Ÿ“œ Processing asset paths..."); processAllJsFiles("output"); console.log("โœ… Done!"); } /** * Stringifies the inner contents of astro-islands for exporting to OutSystems. * @param html * @returns */ function stringifyAstroIslandContents(html: string): string { return html.replace( /(]*>)([\s\S]*?)(<\/astro-island>)/gi, (_match, openTag, inner, closeTag) => { const cleanedInner = inner.replace(//gi, ""); // Trim leading/trailing newlines const trimmed = cleanedInner.replace(/^\s+|\s+$/g, ""); const lines = trimmed.split(/\r?\n/); const stringified = lines .map((line: string, index: number) => { const escaped = line .replace(/\\/g, "\\\\") // escape backslashes first .replace(/"/g, '""'); // escape quotes // No trailing + on the last line return index === lines.length - 1 ? `"${escaped}"` : `"${escaped}" +`; }) .join("\n"); return `${openTag}\n${stringified}\n${closeTag}`; }, ); } processOutput();