Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -129,6 +129,8 @@ Recursively collects HTML files from `dir`. Returns `Promise<string[]>`.
* `extensions`: `Set<string>` of file extensions without dots (default: `html`, `htm`, `shtml`, `shtm`)
* `excludedDirs`: `Set<string>` 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<ResultCode>` with `validation` (HTML-validate result) and `deprecation` (ObsoHTML result) properties.
Expand Down Expand Up @@ -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 |

Expand Down
4 changes: 3 additions & 1 deletion bin/hihtml.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
j9t marked this conversation as resolved.
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} */
Expand Down
67 changes: 67 additions & 0 deletions bin/hihtml.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'), '<p>Real</p>');
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'), '<p>Outside</p>');
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`
Expand Down Expand Up @@ -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 });
});
});
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,5 @@
},
"type": "module",
"types": "src/index.d.ts",
"version": "1.3.1-beta"
"version": "1.3.2"
}
53 changes: 53 additions & 0 deletions src/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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<string, unknown>} */ (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]
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
34 changes: 30 additions & 4 deletions src/lib/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -58,8 +64,9 @@ export async function read(filePaths, { concurrency = DEFAULT_CONCURRENCY, onPro
* @param {Set<string>} extensions
* @param {Set<string>} 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 });
Expand All @@ -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);
Comment thread
j9t marked this conversation as resolved.
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
}
Comment thread
j9t marked this conversation as resolved.
} 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();
Expand Down
Loading