@zulaica/site-bundler 0.6.3 → 0.7.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,13 +1,14 @@
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([
@@ -34,6 +35,12 @@ export const OPTIONS = Object.freeze({
34
35
  configFile: false,
35
36
  minified: true
36
37
  },
38
+ rollup: {
39
+ onwarn(warning, warn) {
40
+ if (warning.code === 'EMPTY_BUNDLE') return;
41
+ warn(warning);
42
+ }
43
+ },
37
44
  postcss: {
38
45
  cssnano: {
39
46
  preset: [
@@ -72,6 +79,9 @@ export const OPTIONS = Object.freeze({
72
79
  },
73
80
  posthtml: {
74
81
  htmlnano: {
82
+ collapseBooleanAttributes: {
83
+ amphtml: false
84
+ },
75
85
  collapseWhitespace: 'aggressive',
76
86
  minifyJs: false,
77
87
  removeComments: 'all',
@@ -79,3 +89,4 @@ export const OPTIONS = Object.freeze({
79
89
  }
80
90
  }
81
91
  });
92
+ /* node:coverage enable */
@@ -0,0 +1,21 @@
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
+ cursorTo(stdout, 0);
16
+ clearLine(stdout, 0);
17
+ }
18
+ stdout.write(message);
19
+ }
20
+ };
21
+ }
@@ -0,0 +1,103 @@
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
+ <link rel="stylesheet" href="style.hash.css" />
37
+ <script src="script.hash.js"></script>
38
+ </head>
39
+ <body>
40
+ <header>
41
+ <h1> Test Site </h1>
42
+ <nav>
43
+ <ul>
44
+ <li><a href="#home">Home</a></li>
45
+ <li><a href="#about">About</a></li>
46
+ </ul>
47
+ </nav>
48
+ </header>
49
+ <main>
50
+ <section>
51
+ <article>
52
+ <h2>Article Title</h2>
53
+ <p>Article content goes here.</p>
54
+ <p aria-hidden="true">This is hidden.</p>
55
+ </article>
56
+ </section>
57
+ <aside>
58
+ <input type="text" disabled="disabled" placeholder="Enter text" />
59
+ </aside>
60
+ </main>
61
+ <footer>
62
+ <p>&copy; Year</p>
63
+ </footer>
64
+ </body>
65
+ </html>
66
+ `;
67
+
68
+ export const processedHTML =
69
+ '<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><link rel="stylesheet" href="style.hash.css"><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>&copy; Year</p></footer></body></html>';
70
+
71
+ export const inputJS = `
72
+ // This is a comment
73
+
74
+ /**
75
+ * This is a comment
76
+ */
77
+
78
+ const sum = (a, b) => {
79
+ return a + b;
80
+ };
81
+
82
+ console.log('Site loaded');
83
+
84
+ document.addEventListener('DOMContentLoaded', () => {
85
+ console.log('DOM loaded');
86
+ });
87
+
88
+ export default sum;
89
+ `;
90
+
91
+ export const processedJS =
92
+ 'const sum=(a,b)=>{return a+b};console.log("Site loaded");document.addEventListener("DOMContentLoaded",()=>{console.log("DOM loaded")});export{sum as default};';
93
+
94
+ export const setUpTestHooks = () => {
95
+ beforeEach(async () => {
96
+ await mkdir(inputDir, { recursive: true });
97
+ await mkdir(outputDir, { recursive: true });
98
+ });
99
+
100
+ afterEach(async () => {
101
+ await rm(baseDir, { recursive: true, force: true });
102
+ });
103
+ };
package/lib/index.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { program } from 'commander';
4
- import { readFile } from 'fs/promises';
5
- import { EMOJI, logError, logMessage } from './helpers/index.mjs';
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,
@@ -11,28 +12,35 @@ import {
11
12
  processJS
12
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}`, {
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), {
22
21
  recursive: true
23
22
  });
24
- }
25
- logMessage('\n');
26
- }
23
+ })
24
+ );
25
+
26
+ Logger.message(
27
+ `${EMOJI.cardIndexDividers} Copied ${filteredAssets.length} assets\n`,
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,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\n`,
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
21
  if (error.code === 'ENOENT') {
25
- logMessage(`${EMOJI.fileFolder} Creating ${outputDir} directory\n`);
22
+ Logger.message(`${EMOJI.fileFolder} Creating ${outputDir} directory…`);
23
+
26
24
  await mkdir(outputDir);
25
+
26
+ Logger.message(
27
+ `${EMOJI.fileFolder} Created ${outputDir} directory\n`,
28
+ true
29
+ );
27
30
  } else {
28
- logError(`${error} \n`);
31
+ Logger.error(`${error} \n`);
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
1
  import cssnanoPlugin from 'cssnano';
2
- import { readFile, writeFile } from 'fs/promises';
2
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
3
4
  import postcss from 'postcss';
4
5
  import atImport from 'postcss-import';
5
6
  import postcssPresetEnv from 'postcss-preset-env';
6
7
  import url from 'postcss-url';
7
- import { EMOJI, OPTIONS, logMessage } from '../helpers/index.mjs';
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`);
20
+ const assets = await readdir(inputDir);
11
21
  const file = 'style.hash.css';
12
- const input = `${inputDir}/${file}`;
13
- const output = `${outputDir}/${file}`;
14
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);
15
31
  const data = await readFile(input, { encoding: 'utf8' });
16
32
  const code = await _processData(input, data);
33
+
17
34
  await writeFile(output, code);
18
- logMessage('\n');
35
+
36
+ Logger.message(`${EMOJI.artistPalette} Processed styles\n`, true);
19
37
  };
20
38
 
21
39
  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 });
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,26 +1,42 @@
1
- import { readFile, writeFile } from 'fs/promises';
2
1
  import htmlnano from 'htmlnano';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
3
4
  import posthtml from 'posthtml';
4
5
  import { hash } from 'posthtml-hash';
5
- import { EMOJI, OPTIONS, logMessage } from '../helpers/index.mjs';
6
+ import { EMOJI, OPTIONS } from '../helpers/constants.mjs';
7
+ import Logger from '../helpers/logger.mjs';
8
+
9
+ const { htmlnano: htmlnanoOptions } = OPTIONS.posthtml;
6
10
 
7
11
  const processHTML = async ({ inputDir, outputDir }) => {
8
- logMessage(`${EMOJI.fileCabinet} Processing HTML`);
12
+ Logger.message(`${EMOJI.fileCabinet} Processing HTML…`);
9
13
  const file = 'index.html';
10
- const input = `${inputDir}/${file}`;
11
- const output = `${outputDir}/${file}`;
14
+ const input = join(inputDir, file);
15
+ const output = join(outputDir, file);
12
16
 
13
17
  const data = await readFile(input, { encoding: 'utf8' });
14
18
  const code = await _processData(outputDir, data);
19
+
15
20
  await writeFile(output, code);
16
- logMessage('\n');
21
+
22
+ Logger.message(`${EMOJI.fileCabinet} Processed HTML\n`, true);
17
23
  };
18
24
 
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);
25
+ async function _processData(path, data) {
26
+ let posthtmlInstance = posthtml().use(
27
+ htmlnano(htmlnanoOptions, htmlnano.presets.safe)
28
+ );
29
+
30
+ /* node:coverage disable */
31
+ // Skip hash replacement during tests
32
+ if (process.env.NODE_ENV !== 'test') {
33
+ posthtmlInstance = posthtmlInstance.use(
34
+ hash({ path, pattern: new RegExp(/hash/) })
35
+ );
36
+ }
37
+ /* node:coverage enable */
38
+
39
+ const { html } = await posthtmlInstance.process(data);
24
40
 
25
41
  return html;
26
42
  }
@@ -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
1
  import { transformFromAstAsync } from '@babel/core';
2
2
  import { parse } from '@babel/parser';
3
- import { readFile, writeFile } from 'fs/promises';
3
+ import { readdir, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
4
5
  import { rollup } from 'rollup';
5
- import { EMOJI, OPTIONS, logMessage } from '../helpers/index.mjs';
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`);
12
+ const assets = await readdir(inputDir);
9
13
  const file = 'script.hash.js';
10
- const input = `${inputDir}/${file}`;
11
- const output = `${outputDir}/${file}`;
12
14
 
13
- await _bundleJS(input, output);
14
- const data = await readFile(output, { encoding: 'utf8' });
15
- const code = await _processData(data);
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 _processData(bundledCode);
25
+
16
26
  await writeFile(output, code);
17
- logMessage('\n');
27
+
28
+ Logger.message(`${EMOJI.barChart} Processed scripts\n`, 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
43
  async function _processData(data) {
27
44
  const ast = parse(data, { sourceType: 'module' });
28
- const { code } = await transformFromAstAsync(ast, data, OPTIONS.babel);
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.3",
4
+ "version": "0.7.0",
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": ">=18.20.5"
7
+ "node": ">=24.12.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"
24
26
  },
25
27
  "repository": {
26
28
  "type": "git",
@@ -31,30 +33,29 @@
31
33
  },
32
34
  "homepage": "https://github.com/zulaica/site-bundler#readme",
33
35
  "dependencies": {
34
- "@babel/core": "7.26.0",
35
- "@babel/parser": "7.26.2",
36
- "@babel/preset-env": "7.26.0",
37
- "commander": "12.1.0",
38
- "cssnano": "7.0.6",
39
- "cssnano-preset-advanced": "7.0.6",
40
- "htmlnano": "2.1.1",
41
- "postcss": "8.4.49",
42
- "postcss-import": "16.1.0",
43
- "postcss-preset-env": "10.1.1",
36
+ "@babel/core": "7.28.5",
37
+ "@babel/parser": "7.28.5",
38
+ "@babel/preset-env": "7.28.5",
39
+ "commander": "14.0.2",
40
+ "cssnano": "7.1.2",
41
+ "cssnano-preset-advanced": "7.0.10",
42
+ "htmlnano": "3.0.0",
43
+ "postcss": "8.5.6",
44
+ "postcss-import": "16.1.1",
45
+ "postcss-preset-env": "10.5.0",
44
46
  "postcss-url": "10.1.3",
45
- "posthtml": "0.16.6",
47
+ "posthtml": "0.16.7",
46
48
  "posthtml-hash": "1.2.2",
47
- "rollup": "4.27.3"
49
+ "rollup": "4.54.0"
48
50
  },
49
51
  "devDependencies": {
50
- "eslint": "9.15.0",
51
- "eslint-config-prettier": "9.1.0",
52
- "eslint-plugin-prettier": "5.2.1",
53
- "globals": "15.12.0",
54
- "prettier": "3.3.3"
52
+ "eslint": "9.39.2",
53
+ "eslint-config-prettier": "10.1.8",
54
+ "eslint-plugin-prettier": "5.5.4",
55
+ "globals": "16.5.0",
56
+ "prettier": "3.7.4"
55
57
  },
56
58
  "volta": {
57
- "node": "18.20.5",
58
- "npm": "10.8.2"
59
+ "node": "24.12.0"
59
60
  }
60
61
  }
@@ -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
- };