Skip to content

Instantly share code, notes, and snippets.

@lightningspirit
Created September 11, 2025 13:19
Show Gist options
  • Select an option

  • Save lightningspirit/afea34721827b4102a045aee33ee6e31 to your computer and use it in GitHub Desktop.

Select an option

Save lightningspirit/afea34721827b4102a045aee33ee6e31 to your computer and use it in GitHub Desktop.
TypeScript String Utils
{
"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"
}
}
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 &lt; 7 &amp; &quot;ok&quot;"');
});
test('handles mixed safe and unsafe values', () => {
assert.equal(
renderAttrs({
class: 'text',
title: 'He said "Hello" & left <now>'
}),
' class="text" title="He said &quot;Hello&quot; &amp; left &lt;now&gt;"'
);
});
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 &amp; Jerry');
});
test('should escape less-than signs', () => {
assert.equal(escapeHtml('<div>'), '&lt;div&gt;');
});
test('should escape greater-than signs', () => {
assert.equal(escapeHtml('x > y'), 'x &gt; y');
});
test('should escape mixed characters', () => {
assert.equal(escapeHtml('<a & b>'), '&lt;a &amp; b&gt;');
});
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 &quot;Hello&quot; &amp; left'
);
});
test('should handle empty string', () => {
assert.equal(escapeHtml(''), '');
});
});
/**
* Escapes special HTML characters in a string.
*
* Converts the following characters into their corresponding HTML entities:
* - `&` → `&amp;`
* - `<` → `&lt;`
* - `>` → `&gt;`
*
* @param str - The input string to escape.
* @returns The escaped string safe for HTML contexts.
*
* @example
* ```ts
* escapeHtml("<div>Tom & Jerry</div>");
* // "&lt;div&gt;Tom &amp; Jerry&lt;/div&gt;"
* ```
*/
export function escapeHtml(str: string, { escapeQuotes = false }: { escapeQuotes?: boolean } = {}): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, escapeQuotes ? '&quot;' : '"');
}
/**
* 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:
* - `&` → `&amp;`
* - `<` → `&lt;`
* - `>` → `&gt;`
* - `"` → `&quot;`
*
* 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 &quot;Hello&quot; &amp; 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