stack/scripts/generate-from-template.ts
2025-02-18 21:38:43 -08:00

509 lines
15 KiB
TypeScript

import fs from "fs";
import path from "path";
const COMMENT_LINE = "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY";
const allEnvs = ["next", "react-like", "js", "template"];
const ignoredFiles = ['node_modules', 'dist', '.turbo', '.gitignore'];
const baseDir = path.resolve(__dirname, "..", "packages");
const srcDir = path.resolve(baseDir, "template");
/**
* Recursively remove empty folders in the given directory.
*/
function removeEmptyFolders(dir: string) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
let isEmpty = true;
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Recursively remove empty subdirectories
removeEmptyFolders(fullPath);
// Check if the folder is now empty
if (fs.existsSync(fullPath) && fs.readdirSync(fullPath).length === 0) {
fs.rmSync(fullPath, { recursive: true, force: true });
} else {
isEmpty = false;
}
} else {
// Directory contains at least one file
isEmpty = false;
}
}
// Remove the directory if it is empty
if (isEmpty) {
fs.rmSync(dir, { recursive: true, force: true });
}
}
/**
* Copy all files/directories from srcDir to destDir (recursively).
* - Skips copying `ignoredFiles`.
* - Skips `package.json` in src.
* - Renames `package-template.json` in src to `package.json` in dest.
* - Applies `editFn` if provided.
*/
function copyFromSrcToDest(
srcDir: string,
destDir: string,
editFn?: (relativePath: string, content: string) => string | null,
currentDir = process.cwd()
) {
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(srcDir, entry.name);
const relativePath = path.relative(currentDir, srcPath);
// If this entry is ignored, skip
if (ignoredFiles.includes(entry.name) || ignoredFiles.includes(relativePath)) {
continue;
}
// If this is package.json, skip copying from source
if (!entry.isDirectory() && entry.name === "package.json") {
continue;
}
// Calculate the destination path
let destPath = path.join(destDir, entry.name);
// Rename package-template.json -> package.json
if (!entry.isDirectory() && entry.name === "package-template.json") {
destPath = path.join(destDir, "package.json");
}
// Ensure the parent directory in dest exists
const destParent = path.dirname(destPath);
if (!fs.existsSync(destParent)) {
fs.mkdirSync(destParent, { recursive: true });
}
if (entry.isDirectory()) {
// Recursively copy directory
copyFromSrcToDest(srcPath, destPath, editFn, currentDir);
} else {
// Copy and optionally edit file
const content = fs.readFileSync(srcPath, "utf-8");
const editedContent = editFn?.(relativePath, content);
// If editFn returns null, skip writing the file
if (editFn && editedContent === null) {
continue;
}
let newContent = editedContent ?? content;
// If the file is a .tsx, .ts, or .js file, add a comment line to the top of the file
if (destPath.endsWith('.tsx') || destPath.endsWith('.ts') || destPath.endsWith('.js')) {
const hasShebang = newContent.startsWith('#') || newContent.startsWith('"') || newContent.startsWith("'");
const shebangLine = hasShebang ? newContent.split('\n')[0] + '\n\n' : '';
const contentWithoutShebang = hasShebang ? newContent.split('\n').slice(1).join('\n') : newContent;
newContent = shebangLine +
'//===========================================\n' +
'// ' + COMMENT_LINE + '\n' +
'//===========================================\n\n' +
contentWithoutShebang;
}
if (destPath.endsWith('package.json')) {
// For package.json files, add a comment field
const json = JSON.parse(newContent);
const orderedJson = {
"//": COMMENT_LINE,
...json
};
newContent = JSON.stringify(orderedJson, null, 2);
}
writeFileSyncIfChanged(destPath, newContent);
}
}
}
/**
* Remove any files/directories in destDir that do not exist in srcDir
* (except those in ignoredFiles).
*
* The logic also accounts for the `package-template.json` -> `package.json` rename:
* If there's a `package.json` in dest but not in src, we do not immediately remove it
* if src has `package-template.json`.
*/
function removeExtraneousDestItems(
srcDir: string,
destDir: string,
currentDir = process.cwd()
) {
if (!fs.existsSync(destDir)) return;
const destEntries = fs.readdirSync(destDir, { withFileTypes: true });
for (const entry of destEntries) {
const destPath = path.join(destDir, entry.name);
// If it's ignored, skip removing
if (ignoredFiles.includes(entry.name)) {
continue;
}
// Figure out the corresponding srcPath (for normal entries):
let correspondingSrc = path.join(srcDir, entry.name);
// Special case: if the dest item is `package.json`, we treat it
// as if it corresponds to `package-template.json` in src
// (since we renamed on copy).
if (entry.name === "package.json") {
const altSrc = path.join(srcDir, "package-template.json");
if (fs.existsSync(altSrc)) {
correspondingSrc = altSrc;
}
}
// Check if corresponding src item exists
if (!fs.existsSync(correspondingSrc)) {
// Remove from dest if src item does not exist
fs.rmSync(destPath, { recursive: true, force: true });
} else if (entry.isDirectory()) {
// Recursively check inside directories
removeExtraneousDestItems(correspondingSrc, destPath, currentDir);
}
}
}
/**
* Main function to generate from template:
* 1. Ensures dest exists
* 2. Copies from src to dest (with optional editFn)
* 3. Removes any items in dest that aren't in src (except ignored)
* 4. Cleans up empty folders
*/
function generateFromTemplate(options: {
src: string;
dest: string;
editFn?: (relativePath: string, content: string) => string | null;
topLevel?: boolean;
}) {
const { src, dest, editFn, topLevel = true } = options;
// Step 1: Ensure the destination directory exists
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
// Step 2: Copy everything from src to dest
copyFromSrcToDest(src, dest, editFn);
// Step 3: Remove anything in dest that isn't in src (except ignored)
removeExtraneousDestItems(src, dest);
// Step 4: Optionally clean up empty folders at the top level
if (topLevel) {
removeEmptyFolders(dest);
}
}
export { generateFromTemplate };
function processMacros(content: string, envs: string[]): string {
const lines = content.split('\n');
const result: string[] = [];
// Each element in skipStack can be either:
//
// 1) A string like "NEXT_LINE", meaning skip exactly the next line.
//
// 2) An object of the form:
// {
// type: 'IF_BLOCK',
// parentActive: boolean,
// hasMatched: boolean,
// isActive: boolean
// }
//
// - parentActive = whether the block's parent is active. If false, this block can never produce output.
// - hasMatched = if any branch in this block has matched so far (IF_PLATFORM or ELSE_IF).
// - isActive = if the *current branch* in this block is active right now.
//
interface IFBlockState {
type: 'IF_BLOCK';
parentActive: boolean;
hasMatched: boolean;
isActive: boolean;
}
const skipStack: Array<string | IFBlockState> = [];
/**
* Returns the top IF_BLOCK on the stack or null if none.
*/
function getCurrentIFBlock(): IFBlockState | null {
for (let i = skipStack.length - 1; i >= 0; i--) {
const top = skipStack[i];
if (typeof top !== 'string' && top.type === 'IF_BLOCK') {
return top;
}
}
return null;
}
/**
* Check if we should output the current line (based on skipStack).
*/
function shouldOutputLine(): boolean {
// If there's a "NEXT_LINE" on top, we skip this line.
// (We'll remove that NEXT_LINE after we handle this line.)
for (let i = skipStack.length - 1; i >= 0; i--) {
if (skipStack[i] === 'NEXT_LINE') {
return false;
}
}
// If any IF_BLOCK up the stack is not active, or its parent is not active, we skip.
for (let i = skipStack.length - 1; i >= 0; i--) {
const top = skipStack[i];
if (typeof top !== 'string' && top.type === 'IF_BLOCK') {
if (!top.parentActive || !top.isActive) {
return false;
}
}
}
return true;
}
/**
* Consume a single-use "NEXT_LINE" skip once we've decided about output.
*/
function consumeNextLineIfPresent() {
const top = skipStack[skipStack.length - 1];
if (top === 'NEXT_LINE') {
skipStack.pop();
}
}
/**
* Parse environment tokens from a directive substring (the part after IF_PLATFORM, ELSE_IF_PLATFORM, etc.).
* We do a basic split on whitespace, then remove punctuation except for letters/numbers/hyphens.
*/
function parseEnvList(envString: string): string[] {
return envString
.split(/\s+/)
.map((e) => e.trim().toLowerCase().replace(/[^a-z0-9-]/g, ''))
.filter(Boolean);
}
/**
* We define flexible regexes that look for these directives *anywhere* in the line:
* - IF_PLATFORM
* - ELSE_IF_PLATFORM
* - ELSE_PLATFORM
* - END_PLATFORM
* - NEXT_LINE_PLATFORM
*
* And then capture everything after that keyword up to the end of the line.
*
* Examples:
* "blah blah IF_PLATFORM env1 env2 ???" => captures "env1 env2 ???"
* "adsfasdf ELSE_PLATFORM blabla" => captures "blabla"
*/
const reBeginOnly = /\bIF_PLATFORM\s+(.+)/i;
const reElseIf = /\bELSE_IF_PLATFORM\s+(.+)/i;
const reElse = /\bELSE_PLATFORM\b/i;
const reEndOnly = /\bEND_PLATFORM\b/i;
const reNextLine = /\bNEXT_LINE_PLATFORM\s+(.+)/i;
for (const line of lines) {
// 1) Try detecting IF_PLATFORM ...
const beginMatch = line.match(reBeginOnly);
if (beginMatch) {
const parentBlock = getCurrentIFBlock();
// If parentActive = false or isActive = false => entire sub-block is inactive
const parentIsFullyActive =
!parentBlock ? true : (parentBlock.parentActive && parentBlock.isActive);
if (!parentIsFullyActive) {
// Just push an inactive block so we handle nested macros correctly
skipStack.push({
type: 'IF_BLOCK',
parentActive: false,
hasMatched: false,
isActive: false
});
} else {
const envList = parseEnvList(beginMatch[1]); // e.g. "env1 env2 ???"
const matched = envList.some((e) => envs.includes(e));
skipStack.push({
type: 'IF_BLOCK',
parentActive: true,
hasMatched: matched,
isActive: matched
});
}
// Skip output of the directive line
continue;
}
// 2) Try detecting ELSE_IF_PLATFORM ...
const elseIfMatch = line.match(reElseIf);
if (elseIfMatch) {
const block = getCurrentIFBlock();
if (block) {
if (!block.parentActive) {
// Parent block is inactive => do nothing
} else {
// If block.hasMatched is true, we've already used an if/else if
// If not, we check if the environment matches
if (block.hasMatched) {
block.isActive = false;
} else {
const envList = parseEnvList(elseIfMatch[1]);
const matched = envList.some((e) => envs.includes(e));
if (matched) {
block.hasMatched = true;
block.isActive = true;
} else {
block.isActive = false;
}
}
}
}
// Skip output
continue;
}
// 3) Try detecting ELSE_PLATFORM ...
const elseMatch = line.match(reElse);
if (elseMatch) {
const block = getCurrentIFBlock();
if (block) {
if (!block.parentActive) {
// Still nothing
} else {
// If we haven't matched anything yet, now we become active
if (!block.hasMatched) {
block.hasMatched = true;
block.isActive = true;
} else {
// Already matched something, so skip
block.isActive = false;
}
}
}
// Skip line
continue;
}
// 4) Try detecting END_PLATFORM ...
const endMatch = line.match(reEndOnly);
if (endMatch) {
// Pop the top IF_BLOCK
const top = skipStack[skipStack.length - 1];
if (typeof top !== 'string' && top.type === 'IF_BLOCK') {
skipStack.pop();
}
// Skip line
continue;
}
// 5) Try detecting NEXT_LINE_PLATFORM ...
const nextLineMatch = line.match(reNextLine);
if (nextLineMatch) {
const envList = parseEnvList(nextLineMatch[1]);
const matched = envList.some((e) => envs.includes(e));
if (!matched) {
skipStack.push('NEXT_LINE');
}
// Skip line
continue;
}
// If it's a normal line:
if (shouldOutputLine()) {
result.push(line);
}
// If the top of the stack is NEXT_LINE, consume it once
consumeNextLineIfPresent();
}
return result.join('\n');
}
function writeFileSyncIfChanged(path: string, content: string): void {
if (fs.existsSync(path)) {
const existingContent = fs.readFileSync(path, "utf-8");
if (existingContent === content) {
return;
}
}
fs.writeFileSync(path, content);
}
// Copy package-template.json to package.json and apply macros
const packageTemplateContent = fs.readFileSync(path.join(srcDir, 'package-template.json'), 'utf-8');
const processedPackageJson = processMacros(packageTemplateContent, allEnvs);
const packageJson = JSON.parse(processedPackageJson);
const packageJsonWithComment = {
"//": COMMENT_LINE,
...packageJson
};
writeFileSyncIfChanged(path.join(srcDir, 'package.json'), JSON.stringify(packageJsonWithComment, null, 2));
generateFromTemplate({
src: srcDir,
dest: path.resolve(baseDir, "js"),
editFn: (path, content) => {
const ignores = [
"postcss.config.js",
"tailwind.config.js",
"quetzal.config.json",
"components.json",
".env",
".env.local",
"scripts/",
"quetzal-translations/",
"src/components/",
"src/components-page/",
"src/generated/",
"src/providers/",
"src/global.css",
"src/global.d.ts",
];
if (ignores.some(ignorePath => path.startsWith(ignorePath)) || path.endsWith('.tsx')) {
return null;
}
content = processMacros(content, ["js"]);
return content;
},
});
generateFromTemplate({
src: srcDir,
dest: path.resolve(baseDir, "stack"),
editFn: (path, content) => {
// ignore the generated folder as the files are big and not needed
if (path.startsWith('src/generated')) {
return null;
}
content = processMacros(content, ["next", "react-like"]);
return content;
},
});