From 8805194471c21230323605f4284364e1444a1bad Mon Sep 17 00:00:00 2001 From: fraxken Date: Tue, 28 Jan 2025 23:52:50 +0100 Subject: [PATCH] feat: implement HTML template components --- bin/index.js | 2 + docs/cli/auto.md | 1 + docs/cli/open.md | 1 + i18n/english.js | 3 +- i18n/french.js | 3 +- package.json | 3 +- public/components/package/package.html | 216 +++++++++ public/components/popup/popup.html | 3 + public/components/searchbar/searchbar.html | 98 ++++ public/components/views/home/home.html | 81 ++++ .../views/home/maintainers/maintainers.html | 19 + .../components/views/home/report/report.html | 26 + public/components/wiki/wiki.html | 6 + src/commands/http.js | 3 + src/commands/scanner.js | 6 +- src/http-server/ViewBuilder.class.js | 97 ++++ src/http-server/endpoints/root.js | 28 +- src/http-server/index.js | 5 +- src/http-server/middleware.js | 12 +- test/httpServer.test.js | 17 +- views/index.html | 456 ------------------ 21 files changed, 591 insertions(+), 495 deletions(-) create mode 100644 public/components/package/package.html create mode 100644 public/components/popup/popup.html create mode 100644 public/components/searchbar/searchbar.html create mode 100644 public/components/views/home/home.html create mode 100644 public/components/views/home/maintainers/maintainers.html create mode 100644 public/components/views/home/report/report.html create mode 100644 public/components/wiki/wiki.html create mode 100644 src/http-server/ViewBuilder.class.js diff --git a/bin/index.js b/bin/index.js index 8d2d8d59..17ffceab 100755 --- a/bin/index.js +++ b/bin/index.js @@ -61,6 +61,7 @@ defaultScannerCommand("from ") defaultScannerCommand("auto [spec]", { includeOutput: false, strategy: vulnera.strategies.GITHUB_ADVISORY }) .describe(i18n.getTokenSync("cli.commands.auto.desc")) .option("-k, --keep", i18n.getTokenSync("cli.commands.auto.option_keep"), false) + .option("-d, --developer", i18n.getTokenSync("cli.commands.open.option_developer"), false) .action(async(spec, options) => { checkNodeSecureToken(); await commands.scanner.auto(spec, options); @@ -71,6 +72,7 @@ prog .describe(i18n.getTokenSync("cli.commands.open.desc")) .option("-p, --port", i18n.getTokenSync("cli.commands.open.option_port"), process.env.PORT) .option("-f, --fresh-start", i18n.getTokenSync("cli.commands.open.option_fresh_start"), process.env.PORT) + .option("-d, --developer", i18n.getTokenSync("cli.commands.open.option_developer"), false) .action(commands.http.start); prog diff --git a/docs/cli/auto.md b/docs/cli/auto.md index 524f0d5a..bb85ff36 100644 --- a/docs/cli/auto.md +++ b/docs/cli/auto.md @@ -27,3 +27,4 @@ $ nsecure auto --keep | `--output` | `-o` | `nsecure-result` | Specify the output file for the results. | | `--vulnerabilityStrategy` | `-s` | github-advisory | Strategy used to fetch package vulnerabilities (see Vulnera [available strategy](https://github.com/NodeSecure/vulnera?tab=readme-ov-file#available-strategy)). | | `--keep` | `-k` | `false` | Preserve JSON payload after execution. | +| `--developer` | `-d` | `false` | Launch the server in developer mode, enabling automatic HTML component refresh. | diff --git a/docs/cli/open.md b/docs/cli/open.md index d2e4ddb1..e3c6a2d8 100644 --- a/docs/cli/open.md +++ b/docs/cli/open.md @@ -18,3 +18,4 @@ $ nsecure open [json] |---|---|---|---| | `--port` | `-p` | `process.env.PORT` | Specify the port on which the HTTP server should run. | | `--fresh-start` | `-f` | `false` | Open the UI with no initial package. Also, the app will use a dedicated cache. | +| `--developer` | `-d` | `false` | Launch the server in developer mode, enabling automatic HTML component refresh. | diff --git a/i18n/english.js b/i18n/english.js index 8860e591..604c7313 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -32,7 +32,8 @@ const cli = { open: { desc: "Run an HTTP Server with a given nsecure JSON file", option_port: "Define the running port", - option_fresh_start: "Launch the server from scratch, ignoring any existing payload file" + option_fresh_start: "Launch the server from scratch, ignoring any existing payload file", + option_developer: "Launch the server in developer mode, enabling automatic HTML component refresh" }, verify: { desc: "Run a complete advanced analysis for a given npm package", diff --git a/i18n/french.js b/i18n/french.js index fa5d7312..6bafa6f2 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -32,7 +32,8 @@ const cli = { open: { desc: "Démarre un serveur HTTP avec un fichier .json nsecure donné", option_port: "Port à utiliser", - option_fresh_start: "Lance le serveur à partir de zéro, en ignorant tout fichier de payload existant" + option_fresh_start: "Lance le serveur à partir de zéro, en ignorant tout fichier de payload existant", + option_developer: "Lance le serveur en mode développeur, permettant le rafraîchissement automatique des composants HTML" }, verify: { desc: "Démarre une analyse AST avancée pour un package npm donné", diff --git a/package.json b/package.json index cbd244c3..479d220c 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,6 @@ "esbuild": "^0.24.0", "eslint-plugin-jsdoc": "^50.6.2", "esmock": "^2.6.7", - "glob": "^11.0.0", "http-server": "^14.1.1", "pkg-ok": "^3.0.0", "pretty-bytes": "^6.1.1", @@ -98,8 +97,10 @@ "@topcli/prompts": "^2.0.0", "@topcli/spinner": "^2.1.2", "cacache": "^19.0.1", + "chokidar": "^4.0.3", "dotenv": "^16.4.5", "filenamify": "^6.0.0", + "glob": "^11.0.1", "highlightjs-line-numbers.js": "^2.8.0", "ini": "^5.0.0", "kleur": "^4.1.5", diff --git a/public/components/package/package.html b/public/components/package/package.html new file mode 100644 index 00000000..3d4d1f7c --- /dev/null +++ b/public/components/package/package.html @@ -0,0 +1,216 @@ + diff --git a/public/components/popup/popup.html b/public/components/popup/popup.html new file mode 100644 index 00000000..5ba86ab9 --- /dev/null +++ b/public/components/popup/popup.html @@ -0,0 +1,3 @@ + diff --git a/public/components/searchbar/searchbar.html b/public/components/searchbar/searchbar.html new file mode 100644 index 00000000..406cd3a4 --- /dev/null +++ b/public/components/searchbar/searchbar.html @@ -0,0 +1,98 @@ + + + + + diff --git a/public/components/views/home/home.html b/public/components/views/home/home.html new file mode 100644 index 00000000..f2763205 --- /dev/null +++ b/public/components/views/home/home.html @@ -0,0 +1,81 @@ + diff --git a/public/components/views/home/maintainers/maintainers.html b/public/components/views/home/maintainers/maintainers.html new file mode 100644 index 00000000..78b2a6f5 --- /dev/null +++ b/public/components/views/home/maintainers/maintainers.html @@ -0,0 +1,19 @@ + diff --git a/public/components/views/home/report/report.html b/public/components/views/home/report/report.html new file mode 100644 index 00000000..77765b29 --- /dev/null +++ b/public/components/views/home/report/report.html @@ -0,0 +1,26 @@ + diff --git a/public/components/wiki/wiki.html b/public/components/wiki/wiki.html new file mode 100644 index 00000000..4448b6d8 --- /dev/null +++ b/public/components/wiki/wiki.html @@ -0,0 +1,6 @@ +
+
+ +
+
+
diff --git a/src/commands/http.js b/src/commands/http.js index 66198571..f64fbb31 100644 --- a/src/commands/http.js +++ b/src/commands/http.js @@ -21,6 +21,8 @@ export async function start( ) { const port = Number(options.port); const freshStart = Boolean(options.f); + const enableDeveloperMode = Boolean(options.developer); + const fileExtension = path.extname(payloadFileBasename); if (fileExtension !== ".json" && fileExtension !== "") { throw new Error("You must provide a JSON file (scanner payload) to open"); @@ -41,6 +43,7 @@ export async function start( const httpServer = buildServer(dataFilePath, { port: Number.isNaN(port) ? 0 : port, + hotReload: enableDeveloperMode, runFromPayload }); diff --git a/src/commands/scanner.js b/src/commands/scanner.js index 08c4d113..195e53af 100644 --- a/src/commands/scanner.js +++ b/src/commands/scanner.js @@ -25,7 +25,11 @@ export async function auto(spec, options) { ); try { if (payloadFile !== null) { - await http.start(); + const developer = Boolean(commandOptions.developer); + + await http.start(void 0, { + developer + }); await events.once(process, "SIGINT"); } } diff --git a/src/http-server/ViewBuilder.class.js b/src/http-server/ViewBuilder.class.js new file mode 100644 index 00000000..a417cd2f --- /dev/null +++ b/src/http-server/ViewBuilder.class.js @@ -0,0 +1,97 @@ +// Import Node.js Dependencies +import path from "node:path"; +import fs from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +// Import Third-party Dependencies +import zup from "zup"; +import * as i18n from "@nodesecure/i18n"; +import chokidar from "chokidar"; +import { globStream } from "glob"; + +// Import Internal Dependencies +import { logger } from "./logger.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const kProjectRootDir = path.join(__dirname, "..", ".."); +const kComponentsDir = path.join(kProjectRootDir, "public", "components"); + +export class ViewBuilder { + #cached = null; + + constructor(options = {}) { + const { autoReload = false } = options; + + if (autoReload) { + this.#enableWatcher(); + } + } + + async #enableWatcher() { + logger.info(`[ViewBuilder] autoReload is enabled`); + + const watcher = chokidar.watch(kComponentsDir, { + persistent: false, + awaitWriteFinish: true, + ignored: (path, stats) => stats?.isFile() && !path.endsWith(".html") + }); + watcher.on("change", (filePath) => this.#freeCache(filePath)); + } + + async #freeCache( + filePath + ) { + logger.info(`[ViewBuilder] the cache has been released`); + logger.info(`[ViewBuilder](filePath: ${filePath})`); + + this.#cached = null; + } + + async #build() { + if (this.#cached) { + return this.#cached; + } + + let HTMLStr = await fs.readFile( + path.join(kProjectRootDir, "views", "index.html"), + "utf-8" + ); + + const componentsPromises = []; + for await ( + const htmlComponentPath of globStream("**/*.html", { cwd: kComponentsDir }) + ) { + componentsPromises.push( + fs.readFile( + path.join(kComponentsDir, htmlComponentPath), + "utf-8" + ) + ); + } + const components = await Promise.all( + componentsPromises + ); + HTMLStr += components.reduce((prev, curr) => prev + curr, ""); + + this.#cached = HTMLStr; + logger.info(`[ViewBuilder] the cache has been hydrated`); + + return HTMLStr; + } + + /** + * @returns {Promise} + */ + async render() { + const i18nLangName = await i18n.getLocalLang(); + + const HTMLStr = await this.#build(); + const templateStr = zup(HTMLStr)({ + lang: i18n.getTokenSync("lang"), + i18nLangName, + token: (tokenName) => i18n.getTokenSync(`ui.${tokenName}`) + }); + + return templateStr; + } +} diff --git a/src/http-server/endpoints/root.js b/src/http-server/endpoints/root.js index a720bc68..ef554117 100644 --- a/src/http-server/endpoints/root.js +++ b/src/http-server/endpoints/root.js @@ -1,28 +1,8 @@ -// Import Node.js Dependencies -import { join, dirname } from "node:path"; -import { readFile } from "node:fs/promises"; -import { fileURLToPath } from "node:url"; - // Import Third-party Dependencies -import zup from "zup"; import send from "@polka/send-type"; -import * as i18n from "@nodesecure/i18n"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const kProjectRootDir = join(__dirname, "..", "..", ".."); - -export async function buildHtml() { - const i18nLangName = await i18n.getLocalLang(); - const HTMLStr = await readFile(join(kProjectRootDir, "views", "index.html"), "utf-8"); - const templateStr = zup(HTMLStr)({ - lang: i18n.getTokenSync("lang"), - i18nLangName, - token: (tokenName) => i18n.getTokenSync(`ui.${tokenName}`) - }); - - return templateStr; -} +// Import Internal Dependencies +import { context } from "../context.js"; export async function get(_req, res) { try { @@ -30,7 +10,9 @@ export async function get(_req, res) { "Content-Type": "text/html" }); - const templateStr = await buildHtml(); + const { viewBuilder } = context.getStore(); + + const templateStr = await viewBuilder.render(); res.end(templateStr); } diff --git a/src/http-server/index.js b/src/http-server/index.js index 3d9d62ce..62200567 100644 --- a/src/http-server/index.js +++ b/src/http-server/index.js @@ -27,6 +27,7 @@ import { appCache } from "./cache.js"; export function buildServer(dataFilePath, options = {}) { const httpConfigPort = typeof options.port === "number" ? options.port : 0; const openLink = typeof options.openLink === "boolean" ? options.openLink : true; + const hotReload = typeof options.hotReload === "boolean" ? options.hotReload : true; const enableWS = options.enableWS ?? process.env.NODE_ENV !== "test"; const runFromPayload = options.runFromPayload ?? true; @@ -34,7 +35,9 @@ export function buildServer(dataFilePath, options = {}) { if (runFromPayload) { fs.accessSync(dataFilePath, fs.constants.R_OK | fs.constants.W_OK); - httpServer.use(middleware.buildContextMiddleware(dataFilePath)); + httpServer.use( + middleware.buildContextMiddleware(dataFilePath, hotReload) + ); } else { appCache.startFromZero = true; diff --git a/src/http-server/middleware.js b/src/http-server/middleware.js index 0de0bc5d..36935975 100644 --- a/src/http-server/middleware.js +++ b/src/http-server/middleware.js @@ -7,10 +7,18 @@ import sirv from "sirv"; // Import Internal Dependencies import { context } from "./context.js"; +import { ViewBuilder } from "./ViewBuilder.class.js"; + +export function buildContextMiddleware( + dataFilePath, + autoReload = false +) { + const viewBuilder = new ViewBuilder({ + autoReload + }); -export function buildContextMiddleware(dataFilePath) { return function addContext(_req, _res, next) { - const store = { dataFilePath }; + const store = { dataFilePath, viewBuilder }; context.run(store, next); }; } diff --git a/test/httpServer.test.js b/test/httpServer.test.js index 08ecc0a6..3c23e18e 100644 --- a/test/httpServer.test.js +++ b/test/httpServer.test.js @@ -8,7 +8,6 @@ import assert from "node:assert"; // Import Third-party Dependencies import { get, post, MockAgent, getGlobalDispatcher, setGlobalDispatcher } from "@myunisoft/httpie"; -import zup from "zup"; import * as i18n from "@nodesecure/i18n"; import * as flags from "@nodesecure/flags"; import enableDestroy from "server-destroy"; @@ -17,6 +16,7 @@ import cacache from "cacache"; // Require Internal Dependencies import { buildServer } from "../src/http-server/index.js"; +import { ViewBuilder } from "../src/http-server/ViewBuilder.class.js"; import { CACHE_PATH } from "../src/http-server/cache.js"; // CONSTANTS @@ -25,7 +25,6 @@ const HTTP_URL = new URL(`http://localhost:${HTTP_PORT}`); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const JSON_PATH = path.join(__dirname, "fixtures", "httpServer.json"); -const INDEX_HTML = fs.readFileSync(path.join(__dirname, "..", "views", "index.html"), "utf-8"); const kConfigKey = "___config"; const kGlobalDispatcher = getGlobalDispatcher(); @@ -65,17 +64,17 @@ describe("httpServer", { concurrency: 1 }, () => { }); test("'/' should return index.html content", async() => { - const i18nLangName = await i18n.getLocalLang(); - const result = await get(HTTP_URL); + const result = await get(HTTP_URL, { + mode: "raw" + }); assert.equal(result.statusCode, 200); assert.equal(result.headers["content-type"], "text/html"); - const templateStr = zup(INDEX_HTML)({ - lang: i18n.getTokenSync("lang"), - i18nLangName, - token: (tokenName) => i18n.getTokenSync(`ui.${tokenName}`) - }); + const templateStr = await ( + new ViewBuilder({ autoReload: false }) + ).render(); + assert.equal(result.data, templateStr); }); diff --git a/views/index.html b/views/index.html index c43afa67..d0fb26d5 100644 --- a/views/index.html +++ b/views/index.html @@ -238,459 +238,3 @@

[[=z.token('settings.shortcuts.title')]]

- - - -
-
- -
-
-
- - - - - - - - - - - - - -