diff --git a/CHANGELOG.md b/CHANGELOG.md index 2296da8..d7e983a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,93 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- Add `@aegisjsproject/dev-server` - -### Changed -- Update CSP, template, etc - -## [v1.1.3] - 2025-11-21 - -### Added -- Add `npm start` script - -### Changed -- Update npm publishing - -## [v1.1.2] - 2025-05-01 - -### Changed -- Use `eslint` & `rollup` directly instead of by other packages -- Update node version via `.npmrc` -- Update Node CI workflow -- Install & use `@shgysk8zer0/eslint-config` -- Add support for `node --test`, including ignoring tests for publishing -- Update ESLint & super-linter -- Switch to more basic Rollup config -- Update `exports` and `main` accordingly - -### Fixed -- Fix missed renaming in README - -## Removed -- Remove old ESLint config files - -## [v1.1.1] - 2023-09-24 - -### Added -- Add `unpkg` to `package.json` -- Add badges in README - -### Changed -- Update `exports` to `package.json` to handle wider variety - -### Fixed -- Fix typo in `fix:js` script - -### [v1.1.0] - 2023-07-03 - -### Changed -- Update to node 20 -- Update npm publishing GH Action - -## [v1.0.5] - 2023-07-02 - -### Added -- Add `funding` - -### Changed -- Updated GitHub Actions workflows -- Update versioning & lock-file scripts -- Update `.npmignore` & `.gitignore` - -## [v1.0.4] - 2023-06-08 - -### Added -- Install `@shgysk8zer0/npm-utils` -- Add `exports` to package config - -### Removed -- Uninstall `rollup`, `eslint` - -### Changed -- Use `getConfig()` from `@shgysk8zer0/js-utils/rollup` for rollup config - -## [v1.0.3] - 2023-06-01 - -### Fixed -- Revert to old Release Action, now with permissions & link to changelog - -## [v1.0.2] - 2023-06-01 - -### Fixed -- Fix `changelog-entry` to match `[$version]` instead of `$version` - -## [v1.0.1] - 2023-05-31 - -### Fixed -- Update GitHub Release workflow to use [Auto Release](https://github.com/marketplace/actions/auto-release) - -## [v1.0.0] - 2023-05-31 +## [v1.0.0] - 2026-01-05 Initial Release diff --git a/README.md b/README.md index d7fbe2f..9999ac2 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ -# npm-template +# `@aegisjsproject/escape` -A template repo for npm packages +String escaping utilities for HTML and DOM attributes. -[](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml) - - +[](https://github.com/AegisJSProject/escape/actions/workflows/codeql-analysis.yml) + + -[](https://github.com/shgysk8zer0/npm-template/blob/master/LICENSE) -[](https://github.com/shgysk8zer0/npm-template/commits/master) -[](https://github.com/shgysk8zer0/npm-template/releases) +[](https://github.com/AegisJSProject/escape/blob/master/LICENSE) +[](https://github.com/AegisJSProject/escape/commits/master) +[](https://github.com/AegisJSProject/escape/releases) [](https://github.com/sponsors/shgysk8zer0) -[](https://www.npmjs.com/package/@shgysk8zer0/npm-template) - - -[](https://www.npmjs.com/package/@shgysk8zer0/npm-template) +[](https://www.npmjs.com/package/@aegisjsproject/escape) + + +[](https://www.npmjs.com/package/@aegisjsproject/escape) -[](https://github.com/shgysk8zer0) - - +[](https://github.com/AegisJSProject) + + [](https://twitter.com/shgysk8zer0) [](https://liberapay.com/shgysk8zer0/donate "Donate using Liberapay") diff --git a/consts.js b/consts.js deleted file mode 100644 index fc3a79b..0000000 --- a/consts.js +++ /dev/null @@ -1 +0,0 @@ -export const MESSAGE = 'This is a template for npm projects.'; diff --git a/css.js b/css.js new file mode 100644 index 0000000..864e42c --- /dev/null +++ b/css.js @@ -0,0 +1,71 @@ +/** + * Escapes a string for use in CSS selectors and identifiers. + * * Wraps native `CSS.escape()` if available. + * * Polyfills the CSSOM spec behavior for server-side or legacy environments. + * * Correctly handles leading digits, control characters, and syntax delimiters. + * + * @param {string} value - The string to be escaped. + * @returns {string} A CSS-safe string suitable for use in `querySelector` or stylesheets. + * + * @example + * escapeCSS('123'); // "\\31 23" (Valid ID selector) + * escapeCSS('foo.bar'); // "foo\\.bar" (Escaped class/ID syntax) + */ +export const escapeCSS = typeof globalThis?.CSS?.escape === 'function' ? globalThis.CSS.escape : (value) => { + const string = String(value); + const length = string.length; + let index = -1; + let codeUnit; + let result = ''; + const firstCodeUnit = string.charCodeAt(0); + + while (++index < length) { + codeUnit = string.charCodeAt(index); + + // 1. Handle NULL (U+0000) -> Replacement Character + if (codeUnit === 0x0000) { + result += '\uFFFD'; + continue; + } + + // 2. Handle Control Characters (0x01-0x1F, 0x7F) -> Hex Escape + Space + if ((codeUnit >= 0x0001 && codeUnit <= 0x001f) || codeUnit === 0x007f) { + result += '\\' + codeUnit.toString(16) + ' '; + continue; + } + + // 3. Handle Leading Digits + // If digit is first char, OR digit is second char and first char was hyphen + if (index === 0 || (index === 1 && firstCodeUnit === 0x002d)) { + if (codeUnit >= 0x0030 && codeUnit <= 0x0039) { + result += '\\' + codeUnit.toString(16) + ' '; + continue; + } + } + + // 4. Handle Single Hyphen (valid ident requires 2 chars if starts with hyphen?) + // Actually, a single hyphen must be escaped to be an ident, not a keyword/syntax + if (index === 0 && length === 1 && codeUnit === 0x002d) { + result += '\\' + string.charAt(index); + continue; + } + + // 5. Allowed Characters (No escaping needed) + // High ASCII (>= 0x80), Hyphen (0x2d), Underscore (0x5f), 0-9, A-Z, a-z + if ( + codeUnit >= 0x0080 || + codeUnit === 0x002d || + codeUnit === 0x005f || + (codeUnit >= 0x0030 && codeUnit <= 0x0039) || + (codeUnit >= 0x0041 && codeUnit <= 0x005a) || + (codeUnit >= 0x0061 && codeUnit <= 0x007a) + ) { + result += string.charAt(index); + continue; + } + + // 6. Default: Backslash Escape (Syntax chars like . : # [ ]) + result += '\\' + string.charAt(index); + } + return result; +}; diff --git a/html.js b/html.js new file mode 100644 index 0000000..c73ecc1 --- /dev/null +++ b/html.js @@ -0,0 +1,48 @@ +export const HTML_UNSAFE_PATTERN = /[<>"']|&(?![a-zA-Z\d]{2,5};|#\d{1,3};)/g; +/* eslint no-control-regex: "off" */ +export const ATTR_NAME_UNSAFE_PATTERN = /[\u0000-\u001f\u007f-\u009f\s"'\\/=><&]/g; +export const HTML_REPLACEMENTS = Object.freeze({ + '&': '&', + '"': '"', + '\'': ''', + '<': '<', + '>': '>', +}); + +const _str = (str, fallback = '') => (str?.toString?.() ?? fallback); + +/** + * Escapes characters that are unsafe for use in HTML text content or quoted attribute values. + * * Replaces `&`, `<`, `>`, `"`, and `'` with their corresponding named entities. + * Returns an empty string if the input is null or undefined. + * + * @param {string} str The input string to escape. + * @returns {string} The escaped string safe for HTML text nodes and quoted attributes. + */ +export const escapeHTML = str => _str(str) + .replaceAll(HTML_UNSAFE_PATTERN, char => HTML_REPLACEMENTS[char]); + + +/** + * Escapes characters that are unsafe or prohibited in HTML attribute names. + * * Uses a hex-encoding strategy (e.g., `_0020_`) to preserve uniqueness and prevent + * attribute injection or name collisions, as attribute names do not support standard entity escaping. + * + * @param {string} str The string to use as an attribute name. + * @returns {string} A sanitized string safe for use as an attribute name. + */ +export const escapeAttrName = str => _str(str) + .replace(ATTR_NAME_UNSAFE_PATTERN, char => '_' + char.charCodeAt(0).toString(16).padStart(4, '0') + '_'); + + +/** + * Serializes a DOM Attr node into a safe HTML attribute string (name="value"). + * * Automatically escapes the attribute name (hex-encoded) and the attribute value (entity-escaped). + * Returns an empty string if the provided argument is not a valid DOM Attr instance. + * + * @param {Attr} attr The DOM Attribute node to stringify. + * @returns {string} The formatted attribute string (e.g., `data-id="123"`) or an empty string. + */ +export const stringifyAttr = attr => attr instanceof globalThis.Attr + ? `${escapeAttrName(attr.name)}="${escapeHTML(attr.value)}"` + : ''; diff --git a/html.test.js b/html.test.js new file mode 100644 index 0000000..bfda068 --- /dev/null +++ b/html.test.js @@ -0,0 +1,116 @@ +import assert from 'node:assert'; +import { describe, test, before } from 'node:test'; +import { escapeHTML, escapeAttrName, stringifyAttr } from './html.js'; + +// Mock DOM Attr class for Node environment +class MockAttr { + constructor(name, value) { + this.name = name; + this.value = value; + } +} + +// Polyfill global Attr for the instanceof check +before(() => { + globalThis.Attr = MockAttr; +}); + +describe('Security Escaping Utils', () => { + describe('escapeHTML', () => { + test('escapes the "Big 5" special characters', () => { + const input = '< > & " \''; + const expected = '< > & " ''; + assert.strictEqual(escapeHTML(input), expected); + }); + + test('handles mixed content correctly', () => { + const input = '