diff --git a/README.md b/README.md index 17291842..fd6dc1a9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ file like the following and adapt to your needs: "version": "1.0.0", "license": "MIT", "scripts": { + "dev": "techradar dev", "build": "techradar build", "serve": "techradar serve" }, @@ -214,6 +215,10 @@ Run `npm run build` to build the radar and upload the files of the `./build` fol You can view a development version of the radar by running `npm run serve` and open the radar in your browser at `http://localhost:3000/techradar` or the path you specified via `basePath`. +As an alternative to `npm run build`, you can start the dev server via `npm run dev`, which will watch for changes in +the `radar` and `public` folder and rebuild the radar automatically. The dev server serves on +`http://localhost:3000/techradar` or the path you specified via `basePath`. + ## Advanced styling with `custom.css` If you need to customize the radar's styles, you can add custom CSS rules to the `custom.css` file. diff --git a/bin/techradar.js b/bin/techradar.js index 1406b0dd..309dc593 100644 --- a/bin/techradar.js +++ b/bin/techradar.js @@ -2,15 +2,55 @@ const fs = require("fs"); const path = require("path"); -const { execSync } = require("child_process"); +const { execSync, spawn } = require("child_process"); const crypto = require("crypto"); +const chokidar = require("chokidar"); const CWD = process.cwd(); const BUILDER_DIR = path.join(CWD, ".techradar"); const SOURCE_DIR = path.join(CWD, "node_modules", "aoe_technology_radar"); const HASH_FILE = path.join(BUILDER_DIR, "hash"); -const PARAMETER = process.argv[2]; // "build" or "serve" +const PARAMETER = process.argv[2]; // "build", "serve" or "dev" + +// We need this mapping on several places, so let's maintain it centrally +const fileMapping = { + "radar-dir": { + isDirectory: true, + bootstrappingSource: path.join(SOURCE_DIR, "data", "radar"), + buildSource: path.join(CWD, "radar"), + buildTarget: path.join(BUILDER_DIR, "data", "radar"), + rebuildOnChange: true, + }, + "public-dir": { + isDirectory: true, + bootstrappingSource: path.join(SOURCE_DIR, "public"), + buildSource: path.join(CWD, "public"), + buildTarget: path.join(BUILDER_DIR, "public"), + rebuildOnChange: true, + }, + "config-file": { + isDirectory: false, + bootstrappingSource: path.join(SOURCE_DIR, "data", "config.default.json"), + buildSource: path.join(CWD, "config.json"), + buildTarget: path.join(BUILDER_DIR, "data", "config.json"), + rebuildOnChange: true, + }, + "about-file": { + isDirectory: false, + bootstrappingSource: path.join(SOURCE_DIR, "data", "about.md"), + buildSource: path.join(CWD, "about.md"), + buildTarget: path.join(BUILDER_DIR, "data", "about.md"), + rebuildOnChange: true, + }, + "customcss-file": { + isDirectory: false, + bootstrappingSource: path.join(SOURCE_DIR, "src", "styles", "custom.css"), + buildSource: path.join(CWD, "custom.css"), + buildTarget: path.join(BUILDER_DIR, "src", "styles", "custom.css"), + rebuildOnChange: true, + }, +}; function info(message) { console.log(`\x1b[32m${message}\x1b[0m`); @@ -25,51 +65,35 @@ function error(message) { process.exit(1); } -function bootstrap() { - if (!fs.existsSync(path.join(CWD, "radar"))) { - warn( - "Could not find radar directory. Created a bootstrap radar directory in your current working directory. Feel free to customize it.", - ); - fs.cpSync(path.join(SOURCE_DIR, "data", "radar"), path.join(CWD, "radar"), { - recursive: true, - }); - } - - if (!fs.existsSync(path.join(CWD, "public"))) { - warn( - "Could not find public directory. Created a public radar directory in your current working directory.", - ); - fs.cpSync(path.join(SOURCE_DIR, "public"), path.join(CWD, "public"), { - recursive: true, - }); - } - - if (!fs.existsSync(path.join(CWD, "config.json"))) { - warn( - "Could not find a config.json. Created a bootstrap config.json in your current working directory. Customize it to your needs.", - ); - fs.copyFileSync( - path.join(SOURCE_DIR, "data", "config.default.json"), - path.join(CWD, "config.json"), - ); - } +function debounce(func, wait) { + let timeout; + return function (...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} - if (!fs.existsSync(path.join(CWD, "about.md"))) { - warn( - "Could not find a about.md. Created a bootstrap about.md in your current working directory. Customize it to your needs.", - ); - fs.copyFileSync( - path.join(SOURCE_DIR, "data", "about.md"), - path.join(CWD, "about.md"), - ); - } +function bootstrap() { + for (const file of Object.values(fileMapping)) { + const relativeBuildPath = `./${path.relative(CWD, file.buildSource)}`; - if (!fs.existsSync(path.join(CWD, "custom.css"))) { - warn("Created a bootstrap custom.css in your current working directory."); - fs.copyFileSync( - path.join(SOURCE_DIR, "src", "styles", "custom.css"), - path.join(CWD, "custom.css"), - ); + if (!fs.existsSync(file.buildSource) && file.isDirectory) { + warn( + `Could not find the directory ${relativeBuildPath}. Created a bootstrap directory in your current working directory. Feel free to customize it.`, + ); + fs.cpSync(file.bootstrappingSource, file.buildSource, { + recursive: true, + }); + } else if (!fs.existsSync(file.buildSource) && !file.isDirectory) { + warn( + `Could not find ${relativeBuildPath}. Created a bootstrap file in your current working directory. Feel free to customize it. Customize it to your needs.`, + ); + fs.copyFileSync(file.bootstrappingSource, file.buildSource); + } } } @@ -119,27 +143,20 @@ if (RECREATE_DIR) { bootstrap(); try { - if (fs.existsSync(path.join(BUILDER_DIR, "data", "radar"))) { - fs.rmSync(path.join(BUILDER_DIR, "data", "radar"), { recursive: true }); + for (const file of Object.values(fileMapping)) { + // Clean up a directory if it exists + if (fs.existsSync(file.buildTarget) && file.isDirectory) { + fs.rmSync(file.buildTarget, { recursive: true }); + } + + if (file.isDirectory) { + fs.cpSync(file.buildSource, file.buildTarget, { + recursive: true, + }); + } else if (!file.isDirectory) { + fs.copyFileSync(file.buildSource, file.buildTarget); + } } - fs.cpSync(path.join(CWD, "radar"), path.join(BUILDER_DIR, "data", "radar"), { - recursive: true, - }); - fs.cpSync(path.join(CWD, "public"), path.join(BUILDER_DIR, "public"), { - recursive: true, - }); - fs.copyFileSync( - path.join(CWD, "about.md"), - path.join(BUILDER_DIR, "data", "about.md"), - ); - fs.copyFileSync( - path.join(CWD, "custom.css"), - path.join(BUILDER_DIR, "src", "styles", "custom.css"), - ); - fs.copyFileSync( - path.join(CWD, "config.json"), - path.join(BUILDER_DIR, "data", "config.json"), - ); process.chdir(BUILDER_DIR); } catch (e) { error(e.message); @@ -162,3 +179,89 @@ if (PARAMETER === "build") { info(`Copying techradar to ${path.join(CWD, "build")}`); fs.renameSync(path.join(BUILDER_DIR, "out"), path.join(CWD, "build")); } + +if (PARAMETER === "dev") { + info("Developing techradar"); + + // Let's spawn a child process to run the dev server, so that it doesn't block our main thread + // The process has to be killed whenever the main thread is killed + const NODEDEV_CHILD_PROCESS = spawn("npm", ["run", "dev"], { + stdio: "inherit", + detached: false, + }); + process.on("exit", () => { + NODEDEV_CHILD_PROCESS.kill(); + }); + // Initialize watching of source directory and files with chokidar + const filesToWatch = Object.values(fileMapping).filter( + (fileConfig) => fileConfig.rebuildOnChange && !fileConfig.isDirectory, + ); + const dirsToWatch = Object.values(fileMapping).filter( + (fileConfig) => fileConfig.rebuildOnChange && fileConfig.isDirectory, + ); + + const srcFilesToWatch = [...filesToWatch, ...dirsToWatch].map( + (fileConfig) => fileConfig.buildSource, + ); + const watcher = chokidar.watch(srcFilesToWatch, { + ignored: /^\./, // Ignore dotfiles + persistent: true, + ignoreInitial: true, + depth: 5, + }); + + // Rebuild the data when a change is detected + // Debounce the function to avoid multiple rebuilds at the same time + const rebuildData = debounce(({ path: changedPath }) => { + try { + const fileConfig = Object.values(fileMapping).find( + (fileConfig) => + fileConfig.buildSource === changedPath && !fileConfig.isDirectory, + ); + const dirConfig = Object.values(fileMapping).find( + (fileConfig) => + changedPath.startsWith(fileConfig.buildSource) && + fileConfig.isDirectory, + ); + + // First copy over the changed file to the build directory + + // Is it a file that has been changed? + if (fileConfig) { + const relativeBuildSrc = `./${path.relative(CWD, changedPath)}`; + + info(`${relativeBuildSrc} changed`); + fs.copyFileSync(fileConfig.buildSource, fileConfig.buildTarget); + } else if (dirConfig) { + // Is it a directory that has been changed? + const relativeBuildSrc = `./${path.relative(CWD, changedPath)}`; + const relativeTargetPath = `./${path.relative(CWD, dirConfig.buildTarget)}`; + info( + `${relativeBuildSrc} changed, going to update all files in ${relativeTargetPath}.`, + ); + if (fs.existsSync(dirConfig.buildTarget)) { + fs.rmSync(dirConfig.buildTarget, { recursive: true }); + } + fs.cpSync(dirConfig.buildSource, dirConfig.buildTarget, { + recursive: true, + }); + } else { + info( + "Unknown file changed. Won't be copied.", + `./${path.relative(CWD, changedPath)}`, + ); + } + + // Then rebuild the json files, so that the next.js dev-server picks up the changes + execSync("npm run build:data", { stdio: "inherit" }); + } catch (e) { + error("Unable to reload updated data. Please restart the server.", e); + } + }, 1000); + + // Event handlers + watcher + .on("add", (path) => rebuildData({ path })) + .on("change", (path) => rebuildData({ path })) + .on("unlink", (path) => rebuildData({ path })); +} diff --git a/package-lock.json b/package-lock.json index dbe8be40..2e1acc9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aoe_technology_radar", - "version": "4.4.0-rc.1", + "version": "4.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aoe_technology_radar", - "version": "4.4.0-rc.1", + "version": "4.4.0", "hasInstallScript": true, "bin": { "techradar": "bin/techradar.js" @@ -19,6 +19,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "chokidar": "4.0.1", "clsx": "^2.1.1", "eslint": "^8.57.0", "eslint-config-next": "14.2.4", @@ -3479,6 +3480,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cli-cursor": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", @@ -8253,6 +8269,19 @@ } } }, + "node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "dev": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", diff --git a/package.json b/package.json index a542a7bf..328a7204 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "chokidar": "4.0.1", "clsx": "^2.1.1", "eslint": "^8.57.0", "eslint-config-next": "14.2.4",