diff --git a/CHANGELOG.md b/CHANGELOG.md index 081edd1..a38de2d 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 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 + +* 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..a719154 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 whose target resolves within the scanned root are followed; symlinks pointing outside the root or to directories are skipped. + #### `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..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` @@ -1280,4 +1315,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..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; } @@ -58,8 +64,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 }); @@ -72,11 +79,30 @@ 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 real = await fs.promises.realpath(fullPath); + 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()) { + 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 + } + } catch (err) { + const code = /** @type {NodeJS.ErrnoException} */ (err).code; + if (code !== 'ENOENT' && code !== 'ELOOP' && code !== 'EACCES' && code !== 'EPERM') throw err; + } + continue; + } 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();