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
89 changes: 1 addition & 88 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
30 changes: 15 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
# npm-template
# `@aegisjsproject/escape`

A template repo for npm packages
String escaping utilities for HTML and DOM attributes.

[![CodeQL](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/shgysk8zer0/npm-template/actions/workflows/codeql-analysis.yml)
![Node CI](https://github.com/shgysk8zer0/npm-template/workflows/Node%20CI/badge.svg)
![Lint Code Base](https://github.com/shgysk8zer0/npm-template/workflows/Lint%20Code%20Base/badge.svg)
[![CodeQL](https://github.com/AegisJSProject/escape/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/AegisJSProject/escape/actions/workflows/codeql-analysis.yml)
![Node CI](https://github.com/AegisJSProject/escape/workflows/Node%20CI/badge.svg)
![Lint Code Base](https://github.com/AegisJSProject/escape/workflows/Lint%20Code%20Base/badge.svg)

[![GitHub license](https://img.shields.io/github/license/shgysk8zer0/npm-template.svg)](https://github.com/shgysk8zer0/npm-template/blob/master/LICENSE)
[![GitHub last commit](https://img.shields.io/github/last-commit/shgysk8zer0/npm-template.svg)](https://github.com/shgysk8zer0/npm-template/commits/master)
[![GitHub release](https://img.shields.io/github/release/shgysk8zer0/npm-template?logo=github)](https://github.com/shgysk8zer0/npm-template/releases)
[![GitHub license](https://img.shields.io/github/license/AegisJSProject/escape.svg)](https://github.com/AegisJSProject/escape/blob/master/LICENSE)
[![GitHub last commit](https://img.shields.io/github/last-commit/AegisJSProject/escape.svg)](https://github.com/AegisJSProject/escape/commits/master)
[![GitHub release](https://img.shields.io/github/release/AegisJSProject/escape?logo=github)](https://github.com/AegisJSProject/escape/releases)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/shgysk8zer0?logo=github)](https://github.com/sponsors/shgysk8zer0)

[![npm](https://img.shields.io/npm/v/@shgysk8zer0/npm-template)](https://www.npmjs.com/package/@shgysk8zer0/npm-template)
![node-current](https://img.shields.io/node/v/@shgysk8zer0/npm-template)
![npm bundle size gzipped](https://img.shields.io/bundlephobia/minzip/@shgysk8zer0/npm-template)
[![npm](https://img.shields.io/npm/dw/@shgysk8zer0/npm-template?logo=npm)](https://www.npmjs.com/package/@shgysk8zer0/npm-template)
[![npm](https://img.shields.io/npm/v/@aegisjsproject/escape)](https://www.npmjs.com/package/@aegisjsproject/escape)
![node-current](https://img.shields.io/node/v/@aegisjsproject/escape)
![npm bundle size gzipped](https://img.shields.io/bundlephobia/minzip/@aegisjsproject/escape)
[![npm](https://img.shields.io/npm/dw/@aegisjsproject/escape?logo=npm)](https://www.npmjs.com/package/@aegisjsproject/escape)

[![GitHub followers](https://img.shields.io/github/followers/shgysk8zer0.svg?style=social)](https://github.com/shgysk8zer0)
![GitHub forks](https://img.shields.io/github/forks/shgysk8zer0/npm-template.svg?style=social)
![GitHub stars](https://img.shields.io/github/stars/shgysk8zer0/npm-template.svg?style=social)
[![GitHub followers](https://img.shields.io/github/followers/AegisJSProject.svg?style=social)](https://github.com/AegisJSProject)
![GitHub forks](https://img.shields.io/github/forks/AegisJSProject/escape.svg?style=social)
![GitHub stars](https://img.shields.io/github/stars/AegisJSProject/escape.svg?style=social)
[![Twitter Follow](https://img.shields.io/twitter/follow/shgysk8zer0.svg?style=social)](https://twitter.com/shgysk8zer0)

[![Donate using Liberapay](https://img.shields.io/liberapay/receives/shgysk8zer0.svg?logo=liberapay)](https://liberapay.com/shgysk8zer0/donate "Donate using Liberapay")
Expand Down
1 change: 0 additions & 1 deletion consts.js

This file was deleted.

71 changes: 71 additions & 0 deletions css.js
Original file line number Diff line number Diff line change
@@ -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;
};
48 changes: 48 additions & 0 deletions html.js
Original file line number Diff line number Diff line change
@@ -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({
'&': '&amp;',
'"': '&quot;',
'\'': '&apos;',
'<': '&lt;',
'>': '&gt;',
});

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)}"`
: '';
116 changes: 116 additions & 0 deletions html.test.js
Original file line number Diff line number Diff line change
@@ -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 = '&lt; &gt; &amp; &quot; &apos;';
assert.strictEqual(escapeHTML(input), expected);
});

test('handles mixed content correctly', () => {
const input = '<div title="test">User & Co</div>';
const expected = '&lt;div title=&quot;test&quot;&gt;User &amp; Co&lt;/div&gt;';
assert.strictEqual(escapeHTML(input), expected);
});

test('preserves existing entities (idempotency)', () => {
// Your lookahead prevents double-escaping
const input = '&amp; &lt; &copy; &#123;';
assert.strictEqual(escapeHTML(input), input);
});

test('escapes bare ampersands but leaves entities alone', () => {
const input = 'Ben & Jerry vs Ben &amp; Jerry';
const expected = 'Ben &amp; Jerry vs Ben &amp; Jerry';
assert.strictEqual(escapeHTML(input), expected);
});

test('handles null/undefined/non-string', () => {
assert.strictEqual(escapeHTML(null), '');
assert.strictEqual(escapeHTML(undefined), '');
assert.strictEqual(escapeHTML(123), '123');
assert.strictEqual(escapeHTML({ toString: () => '<br>' }), '&lt;br&gt;');
});
});

describe('escapeAttrName', () => {
test('passes valid names through unchanged', () => {
assert.strictEqual(escapeAttrName('data-id'), 'data-id');
assert.strictEqual(escapeAttrName('xml:lang'), 'xml:lang');
});

test('hex-encodes space characters', () => {
const input = 'on click';
const expected = 'on_0020_click';
assert.strictEqual(escapeAttrName(input), expected);
});

test('hex-encodes control characters', () => {
const input = 'data\x00key'; // Null byte
const expected = 'data_0000_key';
assert.strictEqual(escapeAttrName(input), expected);
});

test('hex-encodes delimiters', () => {
const input = 'user"name';
const expected = 'user_0022_name';
assert.strictEqual(escapeAttrName(input), expected);
});

test('hex-encodes injection attempts', () => {
const input = '><script>';
const expected = '_003e__003c_script_003e_';
assert.strictEqual(escapeAttrName(input), expected);
});
});

describe('stringifyAttr', () => {
test('serializes a valid Attr object', () => {
const attr = new MockAttr('id', 'main-content');
assert.strictEqual(stringifyAttr(attr), 'id="main-content"');
});

test('escapes unsafe values', () => {
const attr = new MockAttr('data-user', 'User "Name" <Admin>');
// Value should use entities
const expected = 'data-user="User &quot;Name&quot; &lt;Admin&gt;"';
assert.strictEqual(stringifyAttr(attr), expected);
});

test('escapes unsafe names (Hex)', () => {
const attr = new MockAttr('data key', 'value');
// Name should use hex encoding
const expected = 'data_0020_key="value"';
assert.strictEqual(stringifyAttr(attr), expected);
});

test('returns empty string for non-Attr objects', () => {
const pojo = { name: 'id', value: 'test' };
assert.strictEqual(stringifyAttr(pojo), '');
assert.strictEqual(stringifyAttr(null), '');
});

test('handles mixed injection attempt', () => {
const attr = new MockAttr('><img src=x', '"><script>');
// Name hex encoded, Value entity escaped
const expected = '_003e__003c_img_0020_src_003d_x="&quot;&gt;&lt;script&gt;"';
assert.strictEqual(stringifyAttr(attr), expected);
});
});
});
5 changes: 2 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
import { MESSAGE } from 'npm-template/consts.js';
console.log(import.meta);
console.info(MESSAGE);
export { HTML_UNSAFE_PATTERN, ATTR_NAME_UNSAFE_PATTERN, HTML_REPLACEMENTS, escapeHTML, escapeAttrName, stringifyAttr } from './html.js';
export { escapeCSS } from './css.js';
8 changes: 0 additions & 8 deletions index.test.js

This file was deleted.

Loading
Loading