@zulaica/site-bundler 0.6.4 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,81 +1,93 @@
1
+ /* node:coverage disable */
1
2
  export const EMOJI = Object.freeze({
2
- artistPalette: '\uD83C\uDFA8',
3
- barChart: '\uD83D\uDCCA',
4
- cardIndexDividers: '\uD83D\uDDC2',
5
- constructionWorker: '\uD83D\uDC77',
6
- fileCabinet: '\uD83D\uDDC4 ',
7
- fileFolder: '\uD83D\uDCC1',
8
- noEntry: '\u26D4\uFE0F',
9
- package: '\uD83D\uDCE6',
10
- wastebasket: '\ud83d\uddd1\ufe0f '
3
+ artistPalette: "\u{1f3a8}",
4
+ barChart: "\u{1f4ca}",
5
+ cardIndexDividers: "\u{1f5c2}",
6
+ constructionWorker: "\u{1f477}",
7
+ fileCabinet: "\u{1f5c4} ",
8
+ fileFolder: "\u{1f4c1}",
9
+ noEntry: "\u{26D4}",
10
+ package: "\u{1f4e6}",
11
+ wastebasket: "\u{1f5d1} ",
11
12
  });
12
13
 
13
14
  export const EXCLUSIONS = Object.freeze([
14
- 'index.html',
15
- 'script.hash.js',
16
- 'scripts',
17
- 'style.hash.css',
18
- 'styles'
15
+ "index.html",
16
+ "script.hash.js",
17
+ "scripts",
18
+ "style.hash.css",
19
+ "styles",
19
20
  ]);
20
21
 
21
22
  export const OPTIONS = Object.freeze({
22
23
  babel: {
23
24
  presets: [
24
25
  [
25
- '@babel/preset-env',
26
+ "@babel/preset-env",
26
27
  {
27
- targets: 'defaults',
28
- modules: false
29
- }
30
- ]
28
+ targets: "defaults",
29
+ modules: false,
30
+ },
31
+ ],
31
32
  ],
32
33
  babelrc: false,
33
34
  comments: false,
34
35
  configFile: false,
35
- minified: true
36
+ minified: true,
37
+ },
38
+ rollup: {
39
+ onwarn(warning, warn) {
40
+ if (warning.code === "EMPTY_BUNDLE") return;
41
+ warn(warning);
42
+ },
36
43
  },
37
44
  postcss: {
38
45
  cssnano: {
39
46
  preset: [
40
- 'advanced',
47
+ "advanced",
41
48
  {
42
49
  minifyFontValues: false,
43
50
  normalizeUrl: false,
44
51
  normalizeString: {
45
- preferredQuote: 'single'
46
- }
47
- }
48
- ]
52
+ preferredQuote: "single",
53
+ },
54
+ },
55
+ ],
49
56
  },
50
57
  url: [
51
58
  {
52
- filter: '**/*.woff2',
53
- url: 'rebase'
59
+ filter: "**/*.woff2",
60
+ url: "rebase",
54
61
  },
55
62
  {
56
- filter: '**/*.woff',
57
- url: 'rebase'
63
+ filter: "**/*.woff",
64
+ url: "rebase",
58
65
  },
59
66
  {
60
- filter: '**/*.ttf',
61
- url: 'rebase'
67
+ filter: "**/*.ttf",
68
+ url: "rebase",
62
69
  },
63
70
  {
64
- filter: '**/*.webp',
65
- url: (asset) => asset.relativePath
71
+ filter: "**/*.webp",
72
+ url: (asset) => asset.relativePath,
66
73
  },
67
74
  {
68
- filter: '**/*.jpg',
69
- url: (asset) => asset.relativePath
70
- }
71
- ]
75
+ filter: "**/*.jpg",
76
+ url: (asset) => asset.relativePath,
77
+ },
78
+ ],
72
79
  },
73
80
  posthtml: {
74
81
  htmlnano: {
75
- collapseWhitespace: 'aggressive',
82
+ collapseBooleanAttributes: {
83
+ amphtml: false,
84
+ },
85
+ collapseWhitespace: "aggressive",
86
+ minifyCss: false,
76
87
  minifyJs: false,
77
- removeComments: 'all',
78
- removeEmptyAttributes: false
79
- }
80
- }
88
+ removeComments: "all",
89
+ removeEmptyAttributes: false,
90
+ },
91
+ },
81
92
  });
93
+ /* node:coverage enable */
@@ -0,0 +1,22 @@
1
+ import { exit, stderr, stdout } from "node:process";
2
+ import { clearLine, cursorTo } from "node:readline";
3
+ import { EMOJI } from "./constants.mjs";
4
+
5
+ export default class Logger {
6
+ static error = (error) => {
7
+ stderr.write(`${EMOJI.noEntry} An error has occurred\n`);
8
+ stderr.write(`${error.toString()}\n\n`);
9
+ stderr.write("", () => exit(1));
10
+ };
11
+
12
+ static message = (message, update = false) => {
13
+ if (process.env.NODE_ENV !== "test") {
14
+ if (update) {
15
+ message += "\n";
16
+ cursorTo(stdout, 0);
17
+ clearLine(stdout, 0);
18
+ }
19
+ stdout.write(message);
20
+ }
21
+ };
22
+ }
@@ -0,0 +1,112 @@
1
+ import { mkdir, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach } from "node:test";
5
+
6
+ const baseDir = join(tmpdir(), `site-bundler-test-${Date.now()}`);
7
+
8
+ export const inputDir = join(baseDir, "input");
9
+ export const outputDir = join(baseDir, "output");
10
+
11
+ export const inputCSS = `
12
+ /**
13
+ * This is a comment
14
+ */
15
+
16
+ body {
17
+ margin: 0;
18
+ padding: 20px;
19
+ font-family: Arial, sans-serif;
20
+ }
21
+
22
+ h1 {
23
+ color: #333;
24
+ }
25
+ `;
26
+
27
+ export const processedCSS =
28
+ "body{font-family:Arial,sans-serif;margin:0;padding:20px}h1{color:#333}";
29
+
30
+ export const inputHTML = `
31
+ <!DOCTYPE html>
32
+ <html lang="en">
33
+ <head>
34
+ <!-- This is a comment -->
35
+ <meta charset="utf-8" />
36
+ <style>
37
+ body {
38
+ margin: 0;
39
+ padding: 20px;
40
+ }
41
+ </style>
42
+ <link rel="stylesheet" href="style.hash.css" />
43
+ <script>
44
+ document.documentElement.classList.add("js");
45
+ </script>
46
+ <script src="script.hash.js"></script>
47
+ </head>
48
+ <body>
49
+ <header>
50
+ <h1> Test Site </h1>
51
+ <nav>
52
+ <ul>
53
+ <li><a href="#home">Home</a></li>
54
+ <li><a href="#about">About</a></li>
55
+ </ul>
56
+ </nav>
57
+ </header>
58
+ <main>
59
+ <section>
60
+ <article>
61
+ <h2>Article Title</h2>
62
+ <p>Article content goes here.</p>
63
+ <p aria-hidden="true">This is hidden.</p>
64
+ </article>
65
+ </section>
66
+ <aside>
67
+ <input type="text" disabled="disabled" placeholder="Enter text" />
68
+ </aside>
69
+ </main>
70
+ <footer>
71
+ <p>&copy; Year</p>
72
+ </footer>
73
+ </body>
74
+ </html>
75
+ `;
76
+
77
+ export const processedHTML =
78
+ '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><style>body{margin:0;padding:20px}</style><link rel="stylesheet" href="style.hash.css"><script>document.documentElement.classList.add("js");</script><script src="script.hash.js"></script></head><body><header><h1>Test Site</h1><nav><ul><li><a href="#home">Home</a></li><li><a href="#about">About</a></li></ul></nav></header><main><section><article><h2>Article Title</h2><p>Article content goes here.</p><p aria-hidden="true">This is hidden.</p></article></section><aside><input type="text" disabled placeholder="Enter text"></aside></main><footer><p>© Year</p></footer></body></html>';
79
+
80
+ export const inputJS = `
81
+ // This is a comment
82
+
83
+ /**
84
+ * This is a comment
85
+ */
86
+
87
+ const sum = (a, b) => {
88
+ return a + b;
89
+ };
90
+
91
+ console.log('Site loaded');
92
+
93
+ document.addEventListener('DOMContentLoaded', () => {
94
+ console.log('DOM loaded');
95
+ });
96
+
97
+ export default sum;
98
+ `;
99
+
100
+ export const processedJS =
101
+ 'const sum=(a,b)=>{return a+b};console.log("Site loaded");document.addEventListener("DOMContentLoaded",()=>{console.log("DOM loaded")});export{sum as default};';
102
+
103
+ export const setUpTestHooks = () => {
104
+ beforeEach(async () => {
105
+ await mkdir(inputDir, { recursive: true });
106
+ await mkdir(outputDir, { recursive: true });
107
+ });
108
+
109
+ afterEach(async () => {
110
+ await rm(baseDir, { recursive: true, force: true });
111
+ });
112
+ };
package/lib/index.mjs CHANGED
@@ -1,38 +1,46 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { program } from 'commander';
4
- import { readFile } from 'fs/promises';
5
- import { EMOJI, logError, logMessage } from './helpers/index.mjs';
3
+ import { program } from "commander";
4
+ import { access, readFile } from "node:fs/promises";
5
+ import { EMOJI } from "./helpers/constants.mjs";
6
+ import Logger from "./helpers/logger.mjs";
6
7
  import {
7
8
  copyAssets,
8
9
  preflight,
9
10
  processCSS,
10
11
  processHTML,
11
- processJS
12
- } from './modules/index.mjs';
12
+ processJS,
13
+ } from "./modules/index.mjs";
13
14
 
14
- (async () => {
15
- const { version } = JSON.parse(
16
- await readFile(new URL('../package.json', import.meta.url))
17
- );
18
- program
19
- .version(version)
20
- .requiredOption('-i, --input-dir <input>', 'The input directory')
21
- .requiredOption('-o, --output-dir <output>', 'The output directory')
22
- .parse();
23
- const opts = program.opts();
15
+ const { version } = JSON.parse(
16
+ await readFile(new URL("../package.json", import.meta.url)),
17
+ );
18
+ program
19
+ .version(version)
20
+ .requiredOption("-i, --input-dir <input>", "The input directory")
21
+ .requiredOption("-o, --output-dir <output>", "The output directory")
22
+ .parse();
23
+ const opts = program.opts();
24
24
 
25
+ async function validateInput() {
25
26
  try {
26
- logMessage(`${EMOJI.constructionWorker} Site Bundler v${version}\n\n`);
27
- await preflight(opts.outputDir);
28
- await copyAssets(opts);
29
- await processCSS(opts);
30
- await processJS(opts);
31
- await processHTML(opts);
32
- logMessage(`${EMOJI.package} Finished!`);
33
- } catch (error) {
34
- logError(error);
35
- } finally {
36
- logMessage('\n\n');
27
+ await access(opts.inputDir);
28
+ } catch {
29
+ Logger.error(`Input directory does not exist: ${opts.inputDir}`);
37
30
  }
38
- })();
31
+ }
32
+
33
+ try {
34
+ Logger.message(`${EMOJI.constructionWorker} Site Bundler v${version}\n\n`);
35
+
36
+ await validateInput();
37
+ await preflight(opts.outputDir);
38
+ await copyAssets(opts);
39
+ await processCSS(opts);
40
+ await processJS(opts);
41
+ await processHTML(opts);
42
+
43
+ Logger.message(`${EMOJI.package} Finished!\n\n`);
44
+ } catch (error) {
45
+ Logger.error(error);
46
+ }
@@ -0,0 +1,89 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFile } from "node:child_process";
3
+ import { readdir, readFile, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { describe, it } from "node:test";
6
+ import { promisify } from "node:util";
7
+ import { EMOJI } from "./helpers/constants.mjs";
8
+ import {
9
+ inputCSS,
10
+ inputDir,
11
+ inputHTML,
12
+ inputJS,
13
+ outputDir,
14
+ setUpTestHooks,
15
+ } from "./helpers/tests.mjs";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+ const { version } = JSON.parse(
19
+ await readFile(new URL("../package.json", import.meta.url), "utf8"),
20
+ );
21
+
22
+ describe(`${EMOJI.package} CLI Integration`, () => {
23
+ setUpTestHooks();
24
+
25
+ it("should display version", async () => {
26
+ const { stdout } = await execFileAsync("node", [
27
+ join(process.cwd(), "lib", "index.mjs"),
28
+ "--version",
29
+ ]);
30
+
31
+ assert.ok(stdout.includes(version));
32
+ });
33
+
34
+ it("should process complete site", async () => {
35
+ await writeFile(join(inputDir, "index.html"), inputHTML);
36
+ await writeFile(join(inputDir, "style.hash.css"), inputCSS);
37
+ await writeFile(join(inputDir, "script.hash.js"), inputJS);
38
+ await writeFile(join(inputDir, "favicon.ico"), "");
39
+
40
+ await assert.doesNotReject(() =>
41
+ execFileAsync("node", [
42
+ join(process.cwd(), "lib", "index.mjs"),
43
+ "-i",
44
+ inputDir,
45
+ "-o",
46
+ outputDir,
47
+ ]),
48
+ );
49
+
50
+ const outputFiles = await readdir(outputDir);
51
+
52
+ assert.deepEqual(outputFiles.sort(), [
53
+ "favicon.ico",
54
+ "index.html",
55
+ "script.hash.js",
56
+ "style.hash.css",
57
+ ]);
58
+ });
59
+
60
+ it("should handle non-existent input directory", async () => {
61
+ const nonExistentDir = join(inputDir, "non-existent");
62
+
63
+ await assert.rejects(
64
+ () =>
65
+ execFileAsync("node", [
66
+ join(process.cwd(), "lib", "index.mjs"),
67
+ "-i",
68
+ nonExistentDir,
69
+ "-o",
70
+ outputDir,
71
+ ]),
72
+ /Input directory does not exist/,
73
+ );
74
+ });
75
+
76
+ it("should handle empty input directory", async () => {
77
+ await assert.rejects(
78
+ () =>
79
+ execFileAsync("node", [
80
+ join(process.cwd(), "lib", "index.mjs"),
81
+ "-i",
82
+ inputDir,
83
+ "-o",
84
+ outputDir,
85
+ ]),
86
+ /An error has occurred/,
87
+ );
88
+ });
89
+ });
@@ -1,29 +1,32 @@
1
- import { cp, readdir } from 'fs/promises';
2
- import { EMOJI, EXCLUSIONS, logMessage } from '../helpers/index.mjs';
1
+ import { cp, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { EMOJI, EXCLUSIONS } from "../helpers/constants.mjs";
4
+ import Logger from "../helpers/logger.mjs";
3
5
 
4
6
  const copyAssets = async ({ inputDir, outputDir }) => {
5
7
  const assets = await readdir(inputDir);
6
- for (const exclusion in EXCLUSIONS) {
7
- assets.splice(assets.indexOf(exclusion), 1);
8
+ const filteredAssets = assets.filter((asset) => !EXCLUSIONS.includes(asset));
9
+
10
+ if (!filteredAssets.length) {
11
+ return;
8
12
  }
9
13
 
10
- if (assets.length) {
11
- logMessage(
12
- `${EMOJI.cardIndexDividers} Copying assets (0/${assets.length})`
13
- );
14
- for (const [index, asset] of assets.entries()) {
15
- logMessage(
16
- `${EMOJI.cardIndexDividers} Copying assets (${index + 1}/${
17
- assets.length
18
- })`,
19
- true
20
- );
21
- await cp(`${inputDir}/${asset}`, `${outputDir}/${asset}`, {
22
- recursive: true
14
+ Logger.message(
15
+ `${EMOJI.cardIndexDividers} Copying ${filteredAssets.length} assets…`,
16
+ );
17
+
18
+ await Promise.all(
19
+ filteredAssets.map(async (asset) => {
20
+ await cp(join(inputDir, asset), join(outputDir, asset), {
21
+ recursive: true,
23
22
  });
24
- }
25
- logMessage('\n');
26
- }
23
+ }),
24
+ );
25
+
26
+ Logger.message(
27
+ `${EMOJI.cardIndexDividers} Copied ${filteredAssets.length} assets`,
28
+ true,
29
+ );
27
30
  };
28
31
 
29
32
  export default copyAssets;
@@ -0,0 +1,86 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { EMOJI } from "../helpers/constants.mjs";
6
+ import {
7
+ inputCSS,
8
+ inputDir,
9
+ inputHTML,
10
+ inputJS,
11
+ outputDir,
12
+ setUpTestHooks,
13
+ } from "../helpers/tests.mjs";
14
+ import copyAssets from "./copyAssets.mjs";
15
+
16
+ describe(`${EMOJI.cardIndexDividers} copyAssets()`, () => {
17
+ setUpTestHooks();
18
+
19
+ it("should copy all non-excluded assets", async () => {
20
+ await writeFile(join(inputDir, "image.png"), "");
21
+ await writeFile(join(inputDir, "document.pdf"), "");
22
+ await writeFile(join(inputDir, "script.js"), inputJS);
23
+
24
+ await assert.doesNotReject(() => copyAssets({ inputDir, outputDir }));
25
+
26
+ const copiedFiles = await readdir(outputDir);
27
+
28
+ assert.deepEqual(copiedFiles.sort(), [
29
+ "document.pdf",
30
+ "image.png",
31
+ "script.js",
32
+ ]);
33
+ });
34
+
35
+ it("should exclude files listed in EXCLUSIONS", async () => {
36
+ await mkdir(join(inputDir, "scripts"), { recursive: true });
37
+ await mkdir(join(inputDir, "styles"), { recursive: true });
38
+ await writeFile(join(inputDir, "image.png"), "");
39
+ await writeFile(join(inputDir, "index.html"), inputHTML);
40
+ await writeFile(join(inputDir, "script.hash.js"), inputJS);
41
+ await writeFile(join(inputDir, "style.hash.css"), inputCSS);
42
+ await writeFile(join(inputDir, "scripts", "app.js"), inputJS);
43
+ await writeFile(join(inputDir, "styles", "main.css"), inputCSS);
44
+
45
+ await assert.doesNotReject(() => copyAssets({ inputDir, outputDir }));
46
+
47
+ const copiedFiles = await readdir(outputDir);
48
+
49
+ assert.deepStrictEqual(copiedFiles, ["image.png"]);
50
+ });
51
+
52
+ it("should handle empty input directory", async () => {
53
+ await assert.doesNotReject(() => copyAssets({ inputDir, outputDir }));
54
+
55
+ const copiedFiles = await readdir(outputDir);
56
+
57
+ assert.deepStrictEqual(copiedFiles, []);
58
+ });
59
+
60
+ it("should copy directories recursively", async () => {
61
+ await mkdir(join(inputDir, "assets"), { recursive: true });
62
+ await mkdir(join(inputDir, "assets", "images"), { recursive: true });
63
+ await writeFile(join(inputDir, "assets", "file.txt"), "");
64
+ await writeFile(join(inputDir, "assets", "images", "logo.png"), "");
65
+
66
+ await assert.doesNotReject(() => copyAssets({ inputDir, outputDir }));
67
+
68
+ const copiedFiles = await readdir(outputDir, { withFileTypes: true });
69
+ const assetsDir = copiedFiles.find((f) => f.name === "assets");
70
+ const subFiles = await readdir(join(outputDir, "assets"));
71
+
72
+ assert.ok(assetsDir.isDirectory());
73
+ assert.deepStrictEqual(subFiles.sort(), ["file.txt", "images"]);
74
+ });
75
+
76
+ it("should handle only excluded files", async () => {
77
+ await writeFile(join(inputDir, "index.html"), inputHTML);
78
+ await writeFile(join(inputDir, "script.hash.js"), inputCSS);
79
+
80
+ await assert.doesNotReject(() => copyAssets({ inputDir, outputDir }));
81
+
82
+ const copiedFiles = await readdir(outputDir);
83
+
84
+ assert.deepStrictEqual(copiedFiles, []);
85
+ });
86
+ });
@@ -1,7 +1,7 @@
1
- import copyAssets from './copyAssets.mjs';
2
- import preflight from './preflight.mjs';
3
- import processCSS from './processCSS.mjs';
4
- import processHTML from './processHTML.mjs';
5
- import processJS from './processJS.mjs';
1
+ import copyAssets from "./copyAssets.mjs";
2
+ import preflight from "./preflight.mjs";
3
+ import processCSS from "./processCSS.mjs";
4
+ import processHTML from "./processHTML.mjs";
5
+ import processJS from "./processJS.mjs";
6
6
 
7
7
  export { copyAssets, preflight, processCSS, processHTML, processJS };
@@ -1,31 +1,34 @@
1
- import { mkdir, readdir, rm } from 'fs/promises';
2
- import { EMOJI, logError, logMessage } from '../helpers/index.mjs';
1
+ import { mkdir, readdir, rm } from "node:fs/promises";
2
+ import { EMOJI } from "../helpers/constants.mjs";
3
+ import Logger from "../helpers/logger.mjs";
3
4
 
4
5
  const preflight = async (outputDir) => {
5
6
  try {
6
7
  const files = await readdir(outputDir);
7
8
 
8
9
  if (files.length) {
9
- logMessage(
10
- `${EMOJI.wastebasket} Removing old ${outputDir} files (0/${files.length})`
10
+ Logger.message(`${EMOJI.wastebasket} Removing old ${outputDir} files…`);
11
+
12
+ await rm(outputDir, { recursive: true, force: true });
13
+ await mkdir(outputDir);
14
+
15
+ Logger.message(
16
+ `${EMOJI.wastebasket} Removed old ${outputDir} files`,
17
+ true,
11
18
  );
12
- for (const [index, file] of files.entries()) {
13
- logMessage(
14
- `${EMOJI.wastebasket} Removing old ${outputDir} files (${index + 1}/${
15
- files.length
16
- })`,
17
- true
18
- );
19
- await rm(`${outputDir}/${file}`, { recursive: true, force: true });
20
- }
21
- logMessage('\n');
22
19
  }
23
20
  } catch (error) {
24
- if (error.code === 'ENOENT') {
25
- logMessage(`${EMOJI.fileFolder} Creating ${outputDir} directory\n`);
21
+ if (error.code === "ENOENT") {
22
+ Logger.message(`${EMOJI.fileFolder} Creating ${outputDir} directory…`);
23
+
26
24
  await mkdir(outputDir);
25
+
26
+ Logger.message(
27
+ `${EMOJI.fileFolder} Created ${outputDir} directory`,
28
+ true,
29
+ );
27
30
  } else {
28
- logError(`${error} \n`);
31
+ Logger.error(error);
29
32
  }
30
33
  }
31
34
  };
@@ -0,0 +1,37 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, readdir, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { EMOJI } from "../helpers/constants.mjs";
6
+ import { outputDir, setUpTestHooks } from "../helpers/tests.mjs";
7
+ import preflight from "./preflight.mjs";
8
+
9
+ describe(`${EMOJI.fileFolder} preflight()`, () => {
10
+ setUpTestHooks();
11
+
12
+ it("should create output directory if it does not exist", async () => {
13
+ const nonExistentDir = join(outputDir, "non-existent");
14
+ await assert.doesNotReject(() => preflight(nonExistentDir));
15
+
16
+ const files = await readdir(nonExistentDir);
17
+
18
+ assert.ok(files !== undefined);
19
+ assert.deepEqual(files, []);
20
+ });
21
+
22
+ it("should remove all files and recreate output directory", async () => {
23
+ await mkdir(join(outputDir, "subdir"), { recursive: true });
24
+ await writeFile(join(outputDir, "file1.txt"), "");
25
+ await writeFile(join(outputDir, "file2.txt"), "");
26
+ await writeFile(join(outputDir, "subdir", "file3.txt"), "");
27
+
28
+ const initialFiles = await readdir(outputDir, { withFileTypes: true });
29
+
30
+ assert.ok(initialFiles.length > 0);
31
+ await assert.doesNotReject(() => preflight(outputDir));
32
+
33
+ const finalFiles = await readdir(outputDir);
34
+
35
+ assert.deepEqual(finalFiles, []);
36
+ });
37
+ });
@@ -1,30 +1,43 @@
1
- import cssnanoPlugin from 'cssnano';
2
- import { readFile, writeFile } from 'fs/promises';
3
- import postcss from 'postcss';
4
- import atImport from 'postcss-import';
5
- import postcssPresetEnv from 'postcss-preset-env';
6
- import url from 'postcss-url';
7
- import { EMOJI, OPTIONS, logMessage } from '../helpers/index.mjs';
1
+ import cssnanoPlugin from "cssnano";
2
+ import { readdir, readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import postcss from "postcss";
5
+ import atImport from "postcss-import";
6
+ import postcssPresetEnv from "postcss-preset-env";
7
+ import url from "postcss-url";
8
+ import { EMOJI, OPTIONS } from "../helpers/constants.mjs";
9
+ import Logger from "../helpers/logger.mjs";
10
+
11
+ const { cssnano, url: cssUrl } = OPTIONS.postcss;
12
+
13
+ const postcssProcessor = postcss()
14
+ .use(atImport())
15
+ .use(postcssPresetEnv())
16
+ .use(url(cssUrl))
17
+ .use(cssnanoPlugin(cssnano));
8
18
 
9
19
  const processCSS = async ({ inputDir, outputDir }) => {
10
- logMessage(`${EMOJI.artistPalette} Processing styles`);
11
- const file = 'style.hash.css';
12
- const input = `${inputDir}/${file}`;
13
- const output = `${outputDir}/${file}`;
20
+ const assets = await readdir(inputDir);
21
+ const file = "style.hash.css";
22
+
23
+ if (!assets.includes(file)) {
24
+ return;
25
+ }
26
+
27
+ Logger.message(`${EMOJI.artistPalette} Processing styles…`);
28
+
29
+ const input = join(inputDir, file);
30
+ const output = join(outputDir, file);
31
+ const data = await readFile(input, { encoding: "utf8" });
32
+ const code = await processCSSData(input, data);
14
33
 
15
- const data = await readFile(input, { encoding: 'utf8' });
16
- const code = await _processData(input, data);
17
34
  await writeFile(output, code);
18
- logMessage('\n');
35
+
36
+ Logger.message(`${EMOJI.artistPalette} Processed styles`, true);
19
37
  };
20
38
 
21
- async function _processData(input, data) {
22
- const { css } = await postcss()
23
- .use(atImport())
24
- .use(postcssPresetEnv())
25
- .use(url(OPTIONS.postcss.url))
26
- .use(cssnanoPlugin(OPTIONS.postcss.cssnano))
27
- .process(data, { from: input });
39
+ export async function processCSSData(input, data) {
40
+ const { css } = await postcssProcessor.process(data, { from: input });
28
41
 
29
42
  return css;
30
43
  }
@@ -0,0 +1,60 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { EMOJI } from "../helpers/constants.mjs";
6
+ import {
7
+ inputCSS,
8
+ inputDir,
9
+ outputDir,
10
+ processedCSS,
11
+ setUpTestHooks,
12
+ } from "../helpers/tests.mjs";
13
+ import processCSS from "./processCSS.mjs";
14
+
15
+ describe(`${EMOJI.artistPalette} processCSS()`, () => {
16
+ setUpTestHooks();
17
+
18
+ it("should process CSS file and write output", async (sub) => {
19
+ await writeFile(join(inputDir, "style.hash.css"), inputCSS);
20
+
21
+ await assert.doesNotReject(() => processCSS({ inputDir, outputDir }));
22
+
23
+ const output = await readFile(join(outputDir, "style.hash.css"), "utf8");
24
+
25
+ sub.test("minimized as a single line", () => {
26
+ assert.equal(output, processedCSS);
27
+ });
28
+ sub.test("with whitespace trimmed", () => {
29
+ assert.ok(output.trim().length < inputCSS.trim().length);
30
+ });
31
+ sub.test("with comments removed", () => {
32
+ assert.doesNotMatch(output, /\/\*\*/);
33
+ });
34
+ });
35
+
36
+ it("should handle CSS imports", async () => {
37
+ await writeFile(join(inputDir, "style.hash.css"), '@import "base.css";');
38
+ await writeFile(join(inputDir, "base.css"), inputCSS);
39
+
40
+ await assert.doesNotReject(() => processCSS({ inputDir, outputDir }));
41
+
42
+ const output = await readFile(join(outputDir, "style.hash.css"), "utf8");
43
+
44
+ assert.ok(output.includes(processedCSS));
45
+ });
46
+
47
+ it("should handle empty CSS file", async () => {
48
+ await writeFile(join(inputDir, "style.hash.css"), "");
49
+
50
+ await assert.doesNotReject(() => processCSS({ inputDir, outputDir }));
51
+
52
+ const output = await readFile(join(outputDir, "style.hash.css"), "utf8");
53
+
54
+ assert.equal(output, "");
55
+ });
56
+
57
+ it("should skip processing if CSS file does not exist", async () => {
58
+ await assert.doesNotReject(() => processCSS({ inputDir, outputDir }));
59
+ });
60
+ });
@@ -1,28 +1,91 @@
1
- import { readFile, writeFile } from 'fs/promises';
2
- import htmlnano from 'htmlnano';
3
- import posthtml from 'posthtml';
4
- import { hash } from 'posthtml-hash';
5
- import { EMOJI, OPTIONS, logMessage } from '../helpers/index.mjs';
1
+ import htmlnano from "htmlnano";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import posthtml from "posthtml";
5
+ import { hash } from "posthtml-hash";
6
+ import { EMOJI, OPTIONS } from "../helpers/constants.mjs";
7
+ import Logger from "../helpers/logger.mjs";
8
+ import { processCSSData } from "./processCSS.mjs";
9
+ import { processJSData } from "./processJS.mjs";
10
+
11
+ const { htmlnano: htmlnanoOptions } = OPTIONS.posthtml;
6
12
 
7
13
  const processHTML = async ({ inputDir, outputDir }) => {
8
- logMessage(`${EMOJI.fileCabinet} Processing HTML`);
9
- const file = 'index.html';
10
- const input = `${inputDir}/${file}`;
11
- const output = `${outputDir}/${file}`;
14
+ Logger.message(`${EMOJI.fileCabinet} Processing HTML…`);
15
+ const file = "index.html";
16
+ const input = join(inputDir, file);
17
+ const output = join(outputDir, file);
18
+
19
+ const data = await readFile(input, { encoding: "utf8" });
20
+ const code = await processHTMLData(outputDir, data);
12
21
 
13
- const data = await readFile(input, { encoding: 'utf8' });
14
- const code = await _processData(outputDir, data);
15
22
  await writeFile(output, code);
16
- logMessage('\n');
23
+
24
+ Logger.message(`${EMOJI.fileCabinet} Processed HTML`, true);
17
25
  };
18
26
 
19
- async function _processData(outputDir, data) {
20
- const { html } = await posthtml()
21
- .use(htmlnano(OPTIONS.posthtml.htmlnano, htmlnano.presets.safe))
22
- .use(hash({ path: outputDir, pattern: new RegExp(/hash/) }))
23
- .process(data);
27
+ async function processHTMLData(path, data) {
28
+ let posthtmlInstance = posthtml()
29
+ .use(processInlineStyles())
30
+ .use(processInlineScripts())
31
+ .use(htmlnano(htmlnanoOptions, htmlnano.presets.safe));
32
+
33
+ /* node:coverage disable */
34
+ // Do not perform hash replacement when running tests
35
+ if (process.env.NODE_ENV !== "test") {
36
+ posthtmlInstance = posthtmlInstance.use(
37
+ hash({ path, pattern: new RegExp(/hash/) }),
38
+ );
39
+ }
40
+ /* node:coverage enable */
41
+
42
+ const { html } = await posthtmlInstance.process(data);
24
43
 
25
44
  return html;
26
45
  }
27
46
 
47
+ /* node:coverage disable */
48
+ function processInlineContent(tagName, shouldProcess, processFn) {
49
+ return function () {
50
+ return async function (tree) {
51
+ const itemsToProcess = [];
52
+
53
+ tree.walk((node) => {
54
+ if (node.tag === tagName && shouldProcess(node)) {
55
+ const content = Array.isArray(node.content)
56
+ ? node.content.join("")
57
+ : node.content;
58
+
59
+ itemsToProcess.push({ node, content });
60
+ }
61
+
62
+ return node;
63
+ });
64
+
65
+ await Promise.all(
66
+ itemsToProcess.map(async ({ node, content }) => {
67
+ node.content = await processFn(content);
68
+ }),
69
+ );
70
+ };
71
+ };
72
+ }
73
+ /* node:coverage enable */
74
+
75
+ /* node:coverage disable */
76
+ const processInlineScripts = processInlineContent(
77
+ "script",
78
+ (node) => node.content && !node.attrs?.src,
79
+ processJSData,
80
+ );
81
+ /* node:coverage enable */
82
+
83
+ /* node:coverage disable */
84
+ const processInlineStyles = processInlineContent(
85
+ "style",
86
+ (node) => node.content,
87
+ (content) => processCSSData(null, content),
88
+ );
89
+ /* node:coverage enable */
90
+
28
91
  export default processHTML;
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { EMOJI } from "../helpers/constants.mjs";
6
+ import {
7
+ inputDir,
8
+ inputHTML,
9
+ outputDir,
10
+ processedHTML,
11
+ setUpTestHooks,
12
+ } from "../helpers/tests.mjs";
13
+ import processHTML from "./processHTML.mjs";
14
+
15
+ describe(`${EMOJI.fileCabinet} processHTML()`, () => {
16
+ setUpTestHooks();
17
+
18
+ it("should process HTML file and write output", async (sub) => {
19
+ await writeFile(join(inputDir, "index.html"), inputHTML);
20
+
21
+ await assert.doesNotReject(() => processHTML({ inputDir, outputDir }));
22
+
23
+ const output = await readFile(join(outputDir, "index.html"), "utf8");
24
+
25
+ sub.test("minimized as a single line", () => {
26
+ assert.equal(output, processedHTML);
27
+ });
28
+ sub.test("with whitespace trimmed", () => {
29
+ assert.ok(output.trim().length < inputHTML.trim().length);
30
+ assert.ok(output.includes("Test Site"));
31
+ assert.ok(output.includes("Article Title"));
32
+ });
33
+ sub.test("with comments removed", () => {
34
+ assert.doesNotMatch(output, /<!--/);
35
+ });
36
+ sub.test("with boolean attributes handled correctly", () => {
37
+ assert.ok(output.includes("disabled"));
38
+ assert.doesNotMatch(output, /disabled="disabled"/);
39
+ });
40
+ });
41
+
42
+ it("should handle empty HTML file", async () => {
43
+ await writeFile(join(inputDir, "index.html"), "");
44
+ await processHTML({ inputDir, outputDir });
45
+
46
+ const output = await readFile(join(outputDir, "index.html"), "utf8");
47
+
48
+ assert.equal(output, "");
49
+ });
50
+ });
@@ -1,31 +1,48 @@
1
- import { transformFromAstAsync } from '@babel/core';
2
- import { parse } from '@babel/parser';
3
- import { readFile, writeFile } from 'fs/promises';
4
- import { rollup } from 'rollup';
5
- import { EMOJI, OPTIONS, logMessage } from '../helpers/index.mjs';
1
+ import { transformFromAstAsync } from "@babel/core";
2
+ import { parse } from "@babel/parser";
3
+ import { readdir, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { rollup } from "rollup";
6
+ import { EMOJI, OPTIONS } from "../helpers/constants.mjs";
7
+ import Logger from "../helpers/logger.mjs";
8
+
9
+ const { babel: babelOptions, rollup: rollupOptions } = OPTIONS;
6
10
 
7
11
  const processJS = async ({ inputDir, outputDir }) => {
8
- logMessage(`${EMOJI.barChart} Processing scripts`);
9
- const file = 'script.hash.js';
10
- const input = `${inputDir}/${file}`;
11
- const output = `${outputDir}/${file}`;
12
-
13
- await _bundleJS(input, output);
14
- const data = await readFile(output, { encoding: 'utf8' });
15
- const code = await _processData(data);
12
+ const assets = await readdir(inputDir);
13
+ const file = "script.hash.js";
14
+
15
+ if (!assets.includes(file)) {
16
+ return;
17
+ }
18
+
19
+ Logger.message(`${EMOJI.barChart} Processing scripts…`);
20
+
21
+ const input = join(inputDir, file);
22
+ const output = join(outputDir, file);
23
+ const bundledCode = await bundleJS(input);
24
+ const code = await processJSData(bundledCode);
25
+
16
26
  await writeFile(output, code);
17
- logMessage('\n');
27
+
28
+ Logger.message(`${EMOJI.barChart} Processed scripts`, true);
18
29
  };
19
30
 
20
- async function _bundleJS(input, output) {
21
- const bundle = await rollup({ input });
22
- await bundle.write({ file: output });
31
+ async function bundleJS(input) {
32
+ const bundle = await rollup({
33
+ input,
34
+ ...rollupOptions,
35
+ });
36
+ const { output } = await bundle.generate({ format: "es" });
37
+
23
38
  await bundle.close();
39
+
40
+ return output[0].code;
24
41
  }
25
42
 
26
- async function _processData(data) {
27
- const ast = parse(data, { sourceType: 'module' });
28
- const { code } = await transformFromAstAsync(ast, data, OPTIONS.babel);
43
+ export async function processJSData(data) {
44
+ const ast = parse(data, { sourceType: "module" });
45
+ const { code } = await transformFromAstAsync(ast, data, babelOptions);
29
46
 
30
47
  return code;
31
48
  }
@@ -0,0 +1,67 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { EMOJI } from "../helpers/constants.mjs";
6
+ import {
7
+ inputDir,
8
+ inputJS,
9
+ outputDir,
10
+ processedJS,
11
+ setUpTestHooks,
12
+ } from "../helpers/tests.mjs";
13
+ import processJS from "./processJS.mjs";
14
+
15
+ describe(`${EMOJI.barChart} processJS()`, () => {
16
+ setUpTestHooks();
17
+
18
+ it("should process JavaScript file and write output", async (sub) => {
19
+ await writeFile(join(inputDir, "script.hash.js"), inputJS);
20
+
21
+ await assert.doesNotReject(() => processJS({ inputDir, outputDir }));
22
+
23
+ const output = await readFile(join(outputDir, "script.hash.js"), "utf8");
24
+
25
+ sub.test("minimized as a single line", () => {
26
+ assert.equal(output, processedJS);
27
+ });
28
+ sub.test("with whitespace trimmed", () => {
29
+ assert.ok(output.trim().length < inputJS.trim().length);
30
+ });
31
+ sub.test("with comments removed", () => {
32
+ assert.doesNotMatch(output, /\/\/ This is a comment/);
33
+ assert.doesNotMatch(output, /\/\*\*/);
34
+ });
35
+ });
36
+
37
+ it("should bundle ES modules", async () => {
38
+ await writeFile(
39
+ join(inputDir, "script.hash.js"),
40
+ `
41
+ import sum from './input.js';
42
+ console.log(sum(2, 3));
43
+ `,
44
+ );
45
+ await writeFile(join(inputDir, "input.js"), inputJS);
46
+
47
+ await assert.doesNotReject(() => processJS({ inputDir, outputDir }));
48
+
49
+ const output = await readFile(join(outputDir, "script.hash.js"), "utf8");
50
+
51
+ assert.ok(output.includes("const sum=(a,b)=>{return a+b}"));
52
+ });
53
+
54
+ it("should handle empty JavaScript file", async () => {
55
+ await writeFile(join(inputDir, "script.hash.js"), "");
56
+
57
+ await assert.doesNotReject(() => processJS({ inputDir, outputDir }));
58
+
59
+ const output = await readFile(join(outputDir, "script.hash.js"), "utf8");
60
+
61
+ assert.equal(output, "");
62
+ });
63
+
64
+ it("should skip processing if JavaScript file does not exist", async () => {
65
+ await assert.doesNotReject(() => processJS({ inputDir, outputDir }));
66
+ });
67
+ });
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@zulaica/site-bundler",
3
3
  "author": "David Zulaica",
4
- "version": "0.6.4",
4
+ "version": "0.8.0",
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=24.12.0"
7
+ "node": ">=24.18.0"
8
8
  },
9
9
  "license": "UNLICENSED",
10
10
  "bin": {
@@ -20,7 +20,9 @@
20
20
  ],
21
21
  "scripts": {
22
22
  "lint": "eslint \"lib/**\"",
23
- "test": "echo \"Error: no test specified\" && exit 1"
23
+ "test": "NODE_ENV=test node --test --test-concurrency=1 --require \"./lib/helpers/tests.mjs\"",
24
+ "test:watch": "npm run test -- --watch",
25
+ "test:coverage": "npm run test -- --experimental-test-coverage --test-coverage-exclude=\"**/helpers/**\" --test-coverage-exclude=\"**/*.test.mjs\""
24
26
  },
25
27
  "repository": {
26
28
  "type": "git",
@@ -31,29 +33,33 @@
31
33
  },
32
34
  "homepage": "https://github.com/zulaica/site-bundler#readme",
33
35
  "dependencies": {
34
- "@babel/core": "7.28.5",
35
- "@babel/parser": "7.28.5",
36
- "@babel/preset-env": "7.28.5",
37
- "commander": "14.0.2",
38
- "cssnano": "7.1.2",
39
- "cssnano-preset-advanced": "7.0.10",
40
- "htmlnano": "3.0.0",
41
- "postcss": "8.5.6",
36
+ "@babel/core": "8.0.1",
37
+ "@babel/parser": "8.0.4",
38
+ "@babel/preset-env": "8.0.2",
39
+ "commander": "15.0.0",
40
+ "cssnano": "8.0.2",
41
+ "cssnano-preset-advanced": "8.0.2",
42
+ "htmlnano": "3.4.0",
43
+ "postcss": "8.5.20",
42
44
  "postcss-import": "16.1.1",
43
- "postcss-preset-env": "10.5.0",
44
- "postcss-url": "10.1.3",
45
+ "postcss-preset-env": "11.3.2",
46
+ "postcss-url": "10.1.4",
45
47
  "posthtml": "0.16.7",
46
48
  "posthtml-hash": "1.2.2",
47
- "rollup": "4.54.0"
49
+ "rollup": "4.62.2"
48
50
  },
49
51
  "devDependencies": {
50
- "eslint": "9.39.2",
52
+ "@eslint/js": "10.0.1",
53
+ "eslint": "10.7.0",
51
54
  "eslint-config-prettier": "10.1.8",
52
- "eslint-plugin-prettier": "5.5.4",
53
- "globals": "16.5.0",
54
- "prettier": "3.7.4"
55
+ "eslint-plugin-prettier": "5.5.6",
56
+ "globals": "17.7.0",
57
+ "prettier": "3.9.5"
55
58
  },
56
59
  "volta": {
57
- "node": "24.12.0"
60
+ "node": "24.18.0"
61
+ },
62
+ "allowScripts": {
63
+ "fsevents@2.3.3": true
58
64
  }
59
65
  }
@@ -1,4 +0,0 @@
1
- import { EMOJI, EXCLUSIONS, OPTIONS } from './constants.mjs';
2
- import { logError, logMessage } from './loggers.mjs';
3
-
4
- export { EMOJI, EXCLUSIONS, logError, logMessage, OPTIONS };
@@ -1,16 +0,0 @@
1
- import { exit, stderr, stdout } from 'process';
2
- import { cursorTo } from 'readline';
3
- import { EMOJI } from './index.mjs';
4
-
5
- export const logError = (error) => {
6
- stderr.write(`\n${EMOJI.noEntry} An error has occurred\n`);
7
- stderr.write(error.toString());
8
- exit(1);
9
- };
10
-
11
- export const logMessage = (message, update = false) => {
12
- if (update) {
13
- cursorTo(stdout, 0, null);
14
- }
15
- stdout.write(message);
16
- };