From ef181bbc813700d546f0d7f485cb3a3ae01ae704 Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Mon, 18 May 2026 13:08:28 +0200 Subject: [PATCH 1/4] fix: improve config validation and symlink handling Added explicit error messages for invalid config values (e.g., `links.timeout`, `links.warnOnPermanentRedirects`). Updated directory traversal logic to follow symlinked files while skipping symlinked directories to prevent cycles. Adjusted quiet mode message handling to correctly display report suggestions. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- CHANGELOG.md | 12 +++++++++++ README.md | 6 ++++-- bin/hihtml.js | 4 +++- bin/hihtml.test.js | 32 ++++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- src/lib/config.js | 53 ++++++++++++++++++++++++++++++++++++++++++++++ src/lib/files.js | 13 +++++++++++- 8 files changed, 119 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 081edd1..0c0de59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to hihtml are documented in this file, which is (mostly) AI- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.3.2] - 2026-05-18 + +### Fixed + +* Fixed `--quiet` and `--report` hint, no longer suggesting `-r` when already in use +* Fixed symlinked HTML files being silently skipped during directory traversal; symlinked files are now followed (symlinked directories remain skipped to prevent cycles) +* Fixed malformed config values (e.g., non-numeric `links.timeout`, non-boolean `links.warnOnPermanentRedirects`) now producing clear error messages instead of silent undefined behavior + +### Changed + +* Clarified in documentation that ObsoHTML warnings are informational (and exit `0`) + ## [1.3.1-beta] - 2026-05-14 ### Changed diff --git a/README.md b/README.md index 0320fc3..68b64d3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# hihtml, the HTML Processing Supertool (Beta) +# hihtml, the HTML Processing Supertool [![npm version](https://img.shields.io/npm/v/hihtml.svg)](https://www.npmjs.com/package/hihtml) [![Build status](https://github.com/j9t/hihtml/workflows/Tests/badge.svg)](https://github.com/j9t/hihtml/actions) [![Socket](https://badge.socket.dev/npm/package/hihtml)](https://socket.dev/npm/package/hihtml) [![GitHub Sponsors](https://badgen.net/static/Support/Open%20Source/cyan)](https://github.com/j9t/hihtml?sponsor=1) @@ -129,6 +129,8 @@ Recursively collects HTML files from `dir`. Returns `Promise`. * `extensions`: `Set` of file extensions without dots (default: `html`, `htm`, `shtml`, `shtm`) * `excludedDirs`: `Set` of directory names to skip (default: `node_modules`, `.git`) +Symlinked files are followed; symlinked directories are skipped to prevent cycles. + #### `checkCode(filePaths, options?)` Validates HTML files and checks for deprecated markup. Returns `Promise` with `validation` (HTML-validate result) and `deprecation` (ObsoHTML result) properties. @@ -220,7 +222,7 @@ Create a .hihtml.json file in your project root, or add a `"hihtml"` key to pack | Code | Meaning | |---|---| -| `0` | No issues found | +| `0` | No issues found (ObsoHTML warnings on deprecated markup are informational) | | `1` | Validation errors, broken links, or minification errors found | | `2` | Tool or runtime error | diff --git a/bin/hihtml.js b/bin/hihtml.js index 8fe810a..f3224e9 100755 --- a/bin/hihtml.js +++ b/bin/hihtml.js @@ -144,7 +144,9 @@ function makeProgress(label, total, { leadingNewline = false } = {}) { }); }; const quietHint = () => { - if (opts.quiet && sections.length > 0) console.log('\n(Use `-r` for a full report, or run without `-q` for inline output)'); + if (!opts.quiet || sections.length === 0) return; + const reportSuggestion = opts.report === undefined ? 'Use `-r` for a full report, or run' : 'Run'; + console.log(`\n(${reportSuggestion} without \`-q\` for inline output)`); }; /** @type {import('../src/adapters/check-code.js').CheckResult | undefined} */ diff --git a/bin/hihtml.test.js b/bin/hihtml.test.js index 17b7d49..460eed8 100644 --- a/bin/hihtml.test.js +++ b/bin/hihtml.test.js @@ -1280,4 +1280,36 @@ describe('Load config', () => { const missing = path.join(tempDir, 'nonexistent-settings.json'); await assert.rejects(() => loadConfig(undefined, missing), /Error reading settings file/); }); + + test('Throws on invalid `links.timeout` type', async () => { + const configDir = path.join(tempDir, 'badtype-timeout'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.hihtml.json'), JSON.stringify({ links: { timeout: 'ten seconds' } })); + await assert.rejects(() => loadConfig(configDir), /links\.timeout.*must be a positive number/); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + test('Throws on non-integer `links.concurrency`', async () => { + const configDir = path.join(tempDir, 'badtype-concurrency'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.hihtml.json'), JSON.stringify({ links: { concurrency: 0 } })); + await assert.rejects(() => loadConfig(configDir), /links\.concurrency.*must be a positive integer/); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + test('Throws on invalid `extensions` type', async () => { + const configDir = path.join(tempDir, 'badtype-extensions'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.hihtml.json'), JSON.stringify({ extensions: 'html' })); + await assert.rejects(() => loadConfig(configDir), /extensions.*must be an array of strings/); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + test('Throws on invalid `validation.preset` type', async () => { + const configDir = path.join(tempDir, 'badtype-preset'); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(path.join(configDir, '.hihtml.json'), JSON.stringify({ validation: { preset: 42 } })); + await assert.rejects(() => loadConfig(configDir), /validation\.preset.*must be a string/); + fs.rmSync(configDir, { recursive: true, force: true }); + }); }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d4b1f2f..b50e45b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hihtml", - "version": "1.3.1-beta", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hihtml", - "version": "1.3.1-beta", + "version": "1.3.2", "license": "MIT", "dependencies": { "commander": "^14.0.3", diff --git a/package.json b/package.json index 218898a..71215ae 100644 --- a/package.json +++ b/package.json @@ -55,5 +55,5 @@ }, "type": "module", "types": "src/index.d.ts", - "version": "1.3.1-beta" + "version": "1.3.2" } diff --git a/src/lib/config.js b/src/lib/config.js index 32a02df..3dcbce5 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -3,6 +3,55 @@ import path from 'node:path'; const CONFIG_FILE = '.hihtml.json'; +/** + * @param {unknown} config + * @param {string} source + * @returns {asserts config is import('./config.js').HihtmlConfig} + */ +function validateConfig(config, source) { + const c = /** @type {Record} */ (config); + const isStringArray = (/** @type {unknown} */ v) => Array.isArray(v) && v.every(e => typeof e === 'string'); + + if (c.extensions !== undefined && !isStringArray(c.extensions)) + throw new Error(`${source}: \`extensions\` must be an array of strings`); + if (c.ignore !== undefined && !isStringArray(c.ignore)) + throw new Error(`${source}: \`ignore\` must be an array of strings`); + + if (c.validation !== undefined) { + if (typeof c.validation !== 'object' || c.validation === null || Array.isArray(c.validation)) + throw new Error(`${source}: \`validation\` must be an object`); + const v = /** @type {Record} */ (c.validation); + if (v.preset !== undefined && typeof v.preset !== 'string') + throw new Error(`${source}: \`validation.preset\` must be a string`); + if (v.ignore !== undefined && !isStringArray(v.ignore)) + throw new Error(`${source}: \`validation.ignore\` must be an array of strings`); + } + + if (c.links !== undefined) { + if (typeof c.links !== 'object' || c.links === null || Array.isArray(c.links)) + throw new Error(`${source}: \`links\` must be an object`); + const l = /** @type {Record} */ (c.links); + if (l.timeout !== undefined && (typeof l.timeout !== 'number' || l.timeout <= 0 || !Number.isFinite(l.timeout))) + throw new Error(`${source}: \`links.timeout\` must be a positive number`); + if (l.concurrency !== undefined && (!Number.isInteger(l.concurrency) || /** @type {number} */ (l.concurrency) < 1)) + throw new Error(`${source}: \`links.concurrency\` must be a positive integer`); + if (l.warnOnPermanentRedirects !== undefined && typeof l.warnOnPermanentRedirects !== 'boolean') + throw new Error(`${source}: \`links.warnOnPermanentRedirects\` must be a boolean`); + if (l.ignore !== undefined && !isStringArray(l.ignore)) + throw new Error(`${source}: \`links.ignore\` must be an array of strings`); + } + + if (c.minification !== undefined) { + if (typeof c.minification !== 'object' || c.minification === null || Array.isArray(c.minification)) + throw new Error(`${source}: \`minification\` must be an object`); + const m = /** @type {Record} */ (c.minification); + if (m.preset !== undefined && typeof m.preset !== 'string') + throw new Error(`${source}: \`minification.preset\` must be a string`); + if (m.options !== undefined && (typeof m.options !== 'object' || m.options === null || Array.isArray(m.options))) + throw new Error(`${source}: \`minification.options\` must be an object`); + } +} + /** * @typedef {Object} HihtmlConfig * @property {string[]} [extensions] @@ -37,8 +86,10 @@ export async function loadConfig(cwd = process.cwd(), filePath = undefined) { if (typeof parsed.hihtml !== 'object' || parsed.hihtml === null || Array.isArray(parsed.hihtml)) { throw new Error(`\`hihtml\` key in ${resolved} must be a JSON object`); } + validateConfig(parsed.hihtml, resolved); return parsed.hihtml; } + validateConfig(parsed, resolved); return parsed; } @@ -55,6 +106,7 @@ export async function loadConfig(cwd = process.cwd(), filePath = undefined) { if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`${CONFIG_FILE} must contain a JSON object`); } + validateConfig(parsed, CONFIG_FILE); return parsed; } @@ -71,6 +123,7 @@ export async function loadConfig(cwd = process.cwd(), filePath = undefined) { if (typeof pkg.hihtml !== 'object' || pkg.hihtml === null || Array.isArray(pkg.hihtml)) { throw new Error('`hihtml` in package.json must be a JSON object'); } + validateConfig(pkg.hihtml, 'package.json'); return pkg.hihtml; } diff --git a/src/lib/files.js b/src/lib/files.js index 257ad62..a523aa9 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -72,7 +72,18 @@ async function walk(dir, extensions, excludedDirs, results) { const subdirs = []; for (const entry of entries) { const fullPath = path.join(dir, entry.name); - if (entry.isSymbolicLink()) continue; + + if (entry.isSymbolicLink()) { + try { + const resolved = await fs.promises.stat(fullPath); + if (resolved.isFile()) { + const ext = path.extname(entry.name).slice(1).toLowerCase(); + if (extensions.has(ext)) results.push(fullPath); + } + // Symlinked directories are skipped to prevent cycles + } catch { /* broken symlink—skip */ } + continue; + } if (entry.isDirectory()) { if (!excludedDirs.has(entry.name)) { From 8b42479b2cd797ed8f059acb37779b29dd58c702 Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Mon, 18 May 2026 13:17:32 +0200 Subject: [PATCH 2/4] fix: refine error handling in directory traversal Improved error handling to exclude specific error codes (`ENOENT`, `ELOOP`, `EACCES`, `EPERM`) while preserving existing behavior for broken symlinks. Ensures unexpected errors are rethrown to avoid masking critical issues. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- src/lib/files.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/files.js b/src/lib/files.js index a523aa9..f03d87f 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -81,7 +81,10 @@ async function walk(dir, extensions, excludedDirs, results) { if (extensions.has(ext)) results.push(fullPath); } // Symlinked directories are skipped to prevent cycles - } catch { /* broken symlink—skip */ } + } catch (err) { + const code = /** @type {NodeJS.ErrnoException} */ (err).code; + if (code !== 'ENOENT' && code !== 'ELOOP' && code !== 'EACCES' && code !== 'EPERM') throw err; + } continue; } From 64b23fa40e6316261211b66cf8434c5b23831bc0 Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Mon, 18 May 2026 13:41:57 +0200 Subject: [PATCH 3/4] fix: improve symlink handling in directory traversal Updated traversal logic to follow symlinked files only when their targets resolve within the scanned root, skipping symlinks pointing outside the root or to directories. Added corresponding unit tests to ensure correct behavior and prevent regressions. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- CHANGELOG.md | 2 +- README.md | 2 +- bin/hihtml.test.js | 35 +++++++++++++++++++++++++++++++++++ src/lib/files.js | 19 ++++++++++++------- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c0de59..a38de2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Fixed * Fixed `--quiet` and `--report` hint, no longer suggesting `-r` when already in use -* Fixed symlinked HTML files being silently skipped during directory traversal; symlinked files are now followed (symlinked directories remain skipped to prevent cycles) +* Fixed symlinked HTML files being silently skipped during directory traversal; symlinked files whose target resolves within the scanned root are now followed (symlinks pointing outside the root or to directories are skipped) * Fixed malformed config values (e.g., non-numeric `links.timeout`, non-boolean `links.warnOnPermanentRedirects`) now producing clear error messages instead of silent undefined behavior ### Changed diff --git a/README.md b/README.md index 68b64d3..a719154 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Recursively collects HTML files from `dir`. Returns `Promise`. * `extensions`: `Set` of file extensions without dots (default: `html`, `htm`, `shtml`, `shtm`) * `excludedDirs`: `Set` of directory names to skip (default: `node_modules`, `.git`) -Symlinked files are followed; symlinked directories are skipped to prevent cycles. +Symlinked files whose target resolves within the scanned root are followed; symlinks pointing outside the root or to directories are skipped. #### `checkCode(filePaths, options?)` diff --git a/bin/hihtml.test.js b/bin/hihtml.test.js index 460eed8..8365f98 100644 --- a/bin/hihtml.test.js +++ b/bin/hihtml.test.js @@ -1193,6 +1193,41 @@ describe('Collect files', () => { fs.rmSync(exclDir, { recursive: true, force: true }); }); + + test('Follows symlinked HTML files whose target is within the scanned root', async () => { + const symlinkDir = path.join(tempDir, 'symlink_in_root'); + fs.mkdirSync(symlinkDir, { recursive: true }); + fs.writeFileSync(path.join(symlinkDir, 'real.html'), '

Real

'); + try { + fs.symlinkSync(path.join(symlinkDir, 'real.html'), path.join(symlinkDir, 'link.html')); + } catch { + fs.rmSync(symlinkDir, { recursive: true, force: true }); + return; // Symlinks not supported on this platform/environment + } + const files = await collect(symlinkDir); + assert.ok(files.some(f => f.endsWith('real.html'))); + assert.ok(files.some(f => f.endsWith('link.html'))); + fs.rmSync(symlinkDir, { recursive: true, force: true }); + }); + + test('Does not follow symlinked HTML files whose target is outside the scanned root', async () => { + const outerDir = path.join(tempDir, 'symlink_outer'); + const innerDir = path.join(tempDir, 'symlink_inner'); + fs.mkdirSync(outerDir, { recursive: true }); + fs.mkdirSync(innerDir, { recursive: true }); + fs.writeFileSync(path.join(outerDir, 'outside.html'), '

Outside

'); + try { + fs.symlinkSync(path.join(outerDir, 'outside.html'), path.join(innerDir, 'link.html')); + } catch { + fs.rmSync(outerDir, { recursive: true, force: true }); + fs.rmSync(innerDir, { recursive: true, force: true }); + return; + } + const files = await collect(innerDir); + assert.strictEqual(files.length, 0); + fs.rmSync(outerDir, { recursive: true, force: true }); + fs.rmSync(innerDir, { recursive: true, force: true }); + }); }); // Programmatic API: `loadConfig` diff --git a/src/lib/files.js b/src/lib/files.js index f03d87f..f8f7618 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -58,8 +58,9 @@ export async function read(filePaths, { concurrency = DEFAULT_CONCURRENCY, onPro * @param {Set} extensions * @param {Set} excludedDirs * @param {string[]} results + * @param {string} [dirRoot] */ -async function walk(dir, extensions, excludedDirs, results) { +async function walk(dir, extensions, excludedDirs, results, dirRoot = dir) { let entries; try { entries = await fs.promises.readdir(dir, { withFileTypes: true }); @@ -75,12 +76,16 @@ async function walk(dir, extensions, excludedDirs, results) { if (entry.isSymbolicLink()) { try { - const resolved = await fs.promises.stat(fullPath); - if (resolved.isFile()) { - const ext = path.extname(entry.name).slice(1).toLowerCase(); - if (extensions.has(ext)) results.push(fullPath); + const real = await fs.promises.realpath(fullPath); + const inRoot = real === dirRoot || real.startsWith(dirRoot + path.sep); + if (inRoot) { + const st = await fs.promises.stat(real); + if (st.isFile()) { + const ext = path.extname(entry.name).slice(1).toLowerCase(); + if (extensions.has(ext)) results.push(fullPath); + } + // Symlinked directories are skipped even when in root, to prevent cycles } - // Symlinked directories are skipped to prevent cycles } catch (err) { const code = /** @type {NodeJS.ErrnoException} */ (err).code; if (code !== 'ENOENT' && code !== 'ELOOP' && code !== 'EACCES' && code !== 'EPERM') throw err; @@ -90,7 +95,7 @@ async function walk(dir, extensions, excludedDirs, results) { if (entry.isDirectory()) { if (!excludedDirs.has(entry.name)) { - subdirs.push(walk(fullPath, extensions, excludedDirs, results)); + subdirs.push(walk(fullPath, extensions, excludedDirs, results, dirRoot)); } } else if (entry.isFile()) { const ext = path.extname(entry.name).slice(1).toLowerCase(); From ef1fa8994fd9f40c61e49751440665ff6dbf64e6 Mon Sep 17 00:00:00 2001 From: Jens Oliver Meiert Date: Mon, 18 May 2026 14:33:05 +0200 Subject: [PATCH 4/4] fix: enhance symlink and realpath resolution logic Updated symlink handling to ensure `realpath` calculations respect the scanned root directory. Improved logic to handle relative paths correctly and skip invalid symlinks pointing outside the root, preventing unintended traversal. (This commit message was AI-generated.) Signed-off-by: Jens Oliver Meiert --- src/lib/files.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/lib/files.js b/src/lib/files.js index f8f7618..a1f6a73 100644 --- a/src/lib/files.js +++ b/src/lib/files.js @@ -27,7 +27,13 @@ export async function collect(dir, extensions = HTML_EXTENSIONS, excludedDirs = } /** @type {string[]} */ const results = []; - await walk(resolved, extensions, excludedDirs, results); + let rootCanonical; + try { + rootCanonical = await fs.promises.realpath(resolved); + } catch { + rootCanonical = resolved; + } + await walk(resolved, extensions, excludedDirs, results, rootCanonical); return results; } @@ -77,7 +83,8 @@ async function walk(dir, extensions, excludedDirs, results, dirRoot = dir) { if (entry.isSymbolicLink()) { try { const real = await fs.promises.realpath(fullPath); - const inRoot = real === dirRoot || real.startsWith(dirRoot + path.sep); + const rel = path.relative(dirRoot, real); + const inRoot = rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); if (inRoot) { const st = await fs.promises.stat(real); if (st.isFile()) {