Created
September 11, 2025 13:19
-
-
Save lightningspirit/afea34721827b4102a045aee33ee6e31 to your computer and use it in GitHub Desktop.
TypeScript String Utils
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "name": "@lightningspirit/string-utils", | |
| "license": "MIT", | |
| "type": "module", | |
| "exports": { | |
| ".": { | |
| "types": "./dist/string-utils.d.ts", | |
| "tsx": "./src/string-utils.ts", | |
| "import": "./dist/string-utils.mjs", | |
| "require": "./dist/string-utils.js" | |
| } | |
| }, | |
| "devDependencies": { | |
| "@types/node": "^23.5.0", | |
| "typescript": "5.8.3", | |
| "tsup": "^8.5.0", | |
| "tsx": "^4.19.2" | |
| }, | |
| "tsup": { | |
| "entry": [ | |
| "src/string-utils.ts" | |
| ], | |
| "format": [ | |
| "esm", | |
| "cjs" | |
| ], | |
| "platform": "node", | |
| "splitting": false, | |
| "sourcemap": true, | |
| "clean": true, | |
| "dts": true | |
| }, | |
| "scripts": { | |
| "build": "tsup", | |
| "test": "node --test --experimental-strip-types" | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import test from 'node:test'; | |
| import assert from 'node:assert/strict'; | |
| import { nl2p, renderAttrs, escapeHtml } from './your-module.js'; | |
| // --- nl2p --- | |
| test.describe('nl2p', () => { | |
| test('wraps a single line in <p>', () => { | |
| const html = nl2p('Hello', { attrs: {} }); | |
| assert.equal(html, '<p>Hello</p>'); | |
| }); | |
| test('converts single newlines to <br> inside a paragraph', () => { | |
| const html = nl2p('Hello\nWorld', { attrs: {} }); | |
| assert.equal(html, '<p>Hello<br>World</p>'); | |
| }); | |
| test('splits paragraphs on two or more \\n (LF)', () => { | |
| const html = nl2p('A\n\nB', { attrs: {} }); | |
| assert.equal(html, '<p>A</p>\n<p>B</p>'); | |
| }); | |
| test('handles three or more consecutive blank lines as a single split', () => { | |
| const html = nl2p('A\n\n\nB', { attrs: {} }); | |
| assert.equal(html, '<p>A</p>\n<p>B</p>'); | |
| }); | |
| test('trims leading and trailing whitespace before processing', () => { | |
| const html = nl2p('\n\n A \n\n', { attrs: {} }); | |
| assert.equal(html, '<p>A</p>'); | |
| }); | |
| test('adds provided attributes to each <p>', () => { | |
| const html = nl2p('A\n\nB', { attrs: { class: 'text', 'data-x': '1' } }); | |
| assert.equal(html, '<p class="text" data-x="1">A</p>\n<p class="text" data-x="1">B</p>'); | |
| }); | |
| test('supports empty attributes object without adding anything extra', () => { | |
| const html = nl2p('A\n\nB', { attrs: {} }); | |
| assert.equal(html, '<p>A</p>\n<p>B</p>'); | |
| }); | |
| test('returns a single empty paragraph for empty input', () => { | |
| const html = nl2p('', { attrs: {} }); | |
| assert.equal(html, '<p></p>'); | |
| }); | |
| test('does not split paragraphs on CRLF (\\r\\n) sequences with current regex', () => { | |
| const html = nl2p('A\r\n\r\nB', { attrs: {} }); | |
| assert.equal(html, '<p>A\r<br>\r<br>B</p>'); | |
| }); | |
| test('preserves literal angle brackets and quotes in text', () => { | |
| const html = nl2p('2 < 3\nsay "hi"', { attrs: {} }); | |
| assert.equal(html, '<p>2 < 3<br>say "hi"</p>'); | |
| }); | |
| }); | |
| // --- renderAttrs --- | |
| test.describe('renderAttrs', () => { | |
| test('returns empty string when attrs is undefined', () => { | |
| assert.equal(renderAttrs(undefined), ''); | |
| }); | |
| test('returns empty string for an empty object', () => { | |
| assert.equal(renderAttrs({}), ''); | |
| }); | |
| test('renders a single attribute with a leading space', () => { | |
| assert.equal(renderAttrs({ id: 'title' }), ' id="title"'); | |
| }); | |
| test('renders multiple attributes preserving insertion order', () => { | |
| const obj: Record<string, string> = {}; | |
| obj['data-first'] = '1'; | |
| obj['data-second'] = '2'; | |
| obj['data-third'] = '3'; | |
| assert.equal(renderAttrs(obj), ' data-first="1" data-second="2" data-third="3"'); | |
| }); | |
| test('escapes ampersands, angle brackets, and quotes', () => { | |
| assert.equal(renderAttrs({ title: '5 < 7 & "ok"' }), ' title="5 < 7 & "ok""'); | |
| }); | |
| test('handles mixed safe and unsafe values', () => { | |
| assert.equal( | |
| renderAttrs({ | |
| class: 'text', | |
| title: 'He said "Hello" & left <now>' | |
| }), | |
| ' class="text" title="He said "Hello" & left <now>"' | |
| ); | |
| }); | |
| test('supports data-* and aria-* attributes', () => { | |
| assert.equal( | |
| renderAttrs({ 'data-id': '42', 'aria-label': 'Close' }), | |
| ' data-id="42" aria-label="Close"' | |
| ); | |
| }); | |
| }); | |
| // --- escapeHtml --- | |
| test.describe('escapeHtml', () => { | |
| test('should escape ampersands', () => { | |
| assert.equal(escapeHtml('Tom & Jerry'), 'Tom & Jerry'); | |
| }); | |
| test('should escape less-than signs', () => { | |
| assert.equal(escapeHtml('<div>'), '<div>'); | |
| }); | |
| test('should escape greater-than signs', () => { | |
| assert.equal(escapeHtml('x > y'), 'x > y'); | |
| }); | |
| test('should escape mixed characters', () => { | |
| assert.equal(escapeHtml('<a & b>'), '<a & b>'); | |
| }); | |
| test('should return the same string if no special characters exist', () => { | |
| assert.equal(escapeHtml('Hello World'), 'Hello World'); | |
| }); | |
| test('should handle special quotation options', () => { | |
| assert.equal( | |
| escapeHtml('He said "Hello" & left', { escapeQuotes: true }), | |
| 'He said "Hello" & left' | |
| ); | |
| }); | |
| test('should handle empty string', () => { | |
| assert.equal(escapeHtml(''), ''); | |
| }); | |
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Escapes special HTML characters in a string. | |
| * | |
| * Converts the following characters into their corresponding HTML entities: | |
| * - `&` → `&` | |
| * - `<` → `<` | |
| * - `>` → `>` | |
| * | |
| * @param str - The input string to escape. | |
| * @returns The escaped string safe for HTML contexts. | |
| * | |
| * @example | |
| * ```ts | |
| * escapeHtml("<div>Tom & Jerry</div>"); | |
| * // "<div>Tom & Jerry</div>" | |
| * ``` | |
| */ | |
| export function escapeHtml(str: string, { escapeQuotes = false }: { escapeQuotes?: boolean } = {}): string { | |
| return str | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, escapeQuotes ? '"' : '"'); | |
| } | |
| /** | |
| * Renders an object of HTML attributes into a string suitable for | |
| * concatenation into a start tag. Attribute values are **always escaped**. | |
| * | |
| * Escaping rules applied to values: | |
| * - `&` → `&` | |
| * - `<` → `<` | |
| * - `>` → `>` | |
| * - `"` → `"` | |
| * | |
| * Returns an empty string if `attrs` is `undefined` or an empty object. | |
| * A leading space is included before each attribute so you can safely do: | |
| * `"<tag" + renderAttrs(attrs) + ">"` | |
| * | |
| * @param attrs - Record of attribute names to string values, or `undefined`. | |
| * @returns A string like ` key1="v1" key2="v2"` or `""`. | |
| * | |
| * @example | |
| * ```ts | |
| * renderAttrs({ class: 'text', title: 'He said "Hello" & left' }); | |
| * // ' class="text" title="He said "Hello" & left"' | |
| * ``` | |
| */ | |
| export function renderAttrs(attrs: Record<string, string> | undefined): string { | |
| if (!attrs) return ''; | |
| return Object.entries(attrs) | |
| .map(([key, value]) => ` ${key}="${escapeHtml(value, { escapeQuotes: true })}"`) | |
| .join(''); | |
| } | |
| /** | |
| * Converts new lines in text to HTML paragraphs. | |
| * @param text - Text with new lines | |
| * @param attrs - Optional attributes to add to the <p> tags, e.g. ' class="text"' | |
| * @returns HTML string with paragraphs | |
| */ | |
| export function nl2p(text: string, { attrs = {} }: { attrs?: Record<string, string> }): string { | |
| const attributes = Object.entries(attrs) | |
| .map(([key, value]) => ` ${key}="${value}"`) | |
| .join(''); | |
| return text | |
| .trim() | |
| .split(/\n{2,}/) | |
| .map((line) => `<p${attributes}>${line.replace(/\n/g, '<br>')}</p>`) | |
| .join('\n'); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment