Skip to content

Commit 0eda787

Browse files
committed
Add new utils indentHTML and indentLines
1 parent 1294795 commit 0eda787

File tree

5 files changed

+358
-0
lines changed

5 files changed

+358
-0
lines changed

packages/utils/src/html.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { isString } from './types.js';
2+
3+
/*-------------------
4+
HTML
5+
--------------------*/
6+
7+
export const indentLines = (text, spaces = 2) => {
8+
if (!isString(text)) {
9+
return '';
10+
}
11+
const indent = ' '.repeat(spaces);
12+
return text.split('\n').map(line => `${indent}${line}`).join('\n');
13+
};
14+
15+
export const indentHTML = (html, options = {}) => {
16+
const {
17+
indent = ' ',
18+
startLevel = 0,
19+
trimEmptyLines = true,
20+
} = options;
21+
22+
if (!isString(html)) {
23+
return '';
24+
}
25+
26+
// Void elements that don't have closing tags
27+
const voidElements = new Set([
28+
'area',
29+
'base',
30+
'br',
31+
'col',
32+
'embed',
33+
'hr',
34+
'img',
35+
'input',
36+
'link',
37+
'meta',
38+
'param',
39+
'source',
40+
'track',
41+
'wbr',
42+
]);
43+
44+
let depth = startLevel;
45+
const lines = html
46+
.split('\n')
47+
.map(line => line.trim())
48+
.filter(line => !trimEmptyLines || line.length > 0);
49+
50+
return lines
51+
.map(line => {
52+
// Handle closing tags - decrease depth before indenting
53+
if (line.startsWith('</')) {
54+
depth = Math.max(0, depth - 1);
55+
}
56+
57+
const indented = indent.repeat(depth) + line;
58+
59+
// Handle opening tags - increase depth after indenting
60+
if (line.startsWith('<') && !line.startsWith('</') && !line.startsWith('<!--')) {
61+
const isSelfClosing = line.endsWith('/>');
62+
const tagMatch = line.match(/<(\w+)/);
63+
const tag = tagMatch?.[1];
64+
const isVoid = tag && voidElements.has(tag);
65+
const hasClosingTag = tag && line.includes(`</${tag}>`);
66+
67+
if (!isSelfClosing && !isVoid && !hasClosingTag) {
68+
depth++;
69+
}
70+
}
71+
72+
return indented;
73+
})
74+
.join('\n');
75+
};

packages/utils/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './debug.js';
99
export * from './environment.js';
1010
export * from './equality.js';
1111
export * from './functions.js';
12+
export * from './html.js';
1213
export * from './loops.js';
1314
export * from './numbers.js';
1415
export * from './objects.js';

packages/utils/test/html.test.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { indentHTML, indentLines } from '@semantic-ui/utils';
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
describe('HTML Utilities', () => {
6+
describe('indentLines', () => {
7+
it('should add 2 spaces of indentation by default', () => {
8+
const input = 'line 1\nline 2\nline 3';
9+
const expected = ' line 1\n line 2\n line 3';
10+
expect(indentLines(input)).toBe(expected);
11+
});
12+
13+
it('should add custom number of spaces', () => {
14+
const input = 'line 1\nline 2';
15+
const expected = ' line 1\n line 2';
16+
expect(indentLines(input, 4)).toBe(expected);
17+
});
18+
19+
it('should handle single line text', () => {
20+
expect(indentLines('single line')).toBe(' single line');
21+
expect(indentLines('single line', 4)).toBe(' single line');
22+
});
23+
24+
it('should handle empty string', () => {
25+
expect(indentLines('')).toBe(' ');
26+
expect(indentLines('', 4)).toBe(' ');
27+
});
28+
29+
it('should handle non-string input', () => {
30+
expect(indentLines(null)).toBe('');
31+
expect(indentLines(undefined)).toBe('');
32+
expect(indentLines(123)).toBe('');
33+
});
34+
35+
it('should preserve existing indentation', () => {
36+
const input = ' already indented\n line 2';
37+
const expected = ' already indented\n line 2';
38+
expect(indentLines(input)).toBe(expected);
39+
});
40+
41+
it('should work with tabs', () => {
42+
const input = 'line 1\nline 2';
43+
expect(indentLines(input, 0)).toBe('line 1\nline 2');
44+
});
45+
});
46+
47+
describe('indentHTML', () => {
48+
it('should properly indent nested HTML with default 2 spaces', () => {
49+
const input = '<div>\n<p>Content</p>\n</div>';
50+
const expected = '<div>\n <p>Content</p>\n</div>';
51+
expect(indentHTML(input)).toBe(expected);
52+
});
53+
54+
it('should handle multiple levels of nesting', () => {
55+
const input = '<div>\n<div>\n<p>Content</p>\n</div>\n</div>';
56+
const expected = '<div>\n <div>\n <p>Content</p>\n </div>\n</div>';
57+
expect(indentHTML(input)).toBe(expected);
58+
});
59+
60+
it('should handle void elements without increasing depth', () => {
61+
const input = '<div>\n<img src="test.jpg">\n<br>\n<input type="text">\n</div>';
62+
const expected = '<div>\n <img src="test.jpg">\n <br>\n <input type="text">\n</div>';
63+
expect(indentHTML(input)).toBe(expected);
64+
});
65+
66+
it('should handle self-closing tags', () => {
67+
const input = '<div>\n<img src="test.jpg" />\n<component />\n</div>';
68+
const expected = '<div>\n <img src="test.jpg" />\n <component />\n</div>';
69+
expect(indentHTML(input)).toBe(expected);
70+
});
71+
72+
it('should handle elements with opening and closing tags on same line', () => {
73+
const input = '<div>\n<p>Title</p>\n<span>Text</span>\n</div>';
74+
const expected = '<div>\n <p>Title</p>\n <span>Text</span>\n</div>';
75+
expect(indentHTML(input)).toBe(expected);
76+
});
77+
78+
it('should handle HTML comments', () => {
79+
const input = '<div>\n<!-- Comment -->\n<p>Content</p>\n</div>';
80+
const expected = '<div>\n <!-- Comment -->\n <p>Content</p>\n</div>';
81+
expect(indentHTML(input)).toBe(expected);
82+
});
83+
84+
it('should use custom indent string', () => {
85+
const input = '<div>\n<p>Content</p>\n</div>';
86+
const expected = '<div>\n <p>Content</p>\n</div>';
87+
expect(indentHTML(input, { indent: ' ' })).toBe(expected);
88+
});
89+
90+
it('should use custom indent with tabs', () => {
91+
const input = '<div>\n<p>Content</p>\n</div>';
92+
const expected = '<div>\n\t<p>Content</p>\n</div>';
93+
expect(indentHTML(input, { indent: '\t' })).toBe(expected);
94+
});
95+
96+
it('should respect startLevel option', () => {
97+
const input = '<div>\n<p>Content</p>\n</div>';
98+
const expected = ' <div>\n <p>Content</p>\n </div>';
99+
expect(indentHTML(input, { startLevel: 1 })).toBe(expected);
100+
});
101+
102+
it('should remove empty lines by default', () => {
103+
const input = '<div>\n\n<p>Content</p>\n\n</div>';
104+
const expected = '<div>\n <p>Content</p>\n</div>';
105+
expect(indentHTML(input)).toBe(expected);
106+
});
107+
108+
it('should preserve empty lines when trimEmptyLines is false', () => {
109+
const input = '<div>\n\n<p>Content</p>\n\n</div>';
110+
const expected = '<div>\n \n <p>Content</p>\n \n</div>';
111+
expect(indentHTML(input, { trimEmptyLines: false })).toBe(expected);
112+
});
113+
114+
it('should handle messy indentation from template literals', () => {
115+
const input = `<div class="ui segment">
116+
<div class="ui header">Title</div>
117+
<p>Content here</p>
118+
<div class="ui list">
119+
<div class="item">
120+
<img src="image.jpg" />
121+
<div class="content">Item 1</div>
122+
</div>
123+
</div>
124+
</div>`;
125+
126+
const expected = `<div class="ui segment">
127+
<div class="ui header">Title</div>
128+
<p>Content here</p>
129+
<div class="ui list">
130+
<div class="item">
131+
<img src="image.jpg" />
132+
<div class="content">Item 1</div>
133+
</div>
134+
</div>
135+
</div>`;
136+
137+
expect(indentHTML(input)).toBe(expected);
138+
});
139+
140+
it('should handle all void elements', () => {
141+
const voidTags = [
142+
'area',
143+
'base',
144+
'br',
145+
'col',
146+
'embed',
147+
'hr',
148+
'img',
149+
'input',
150+
'link',
151+
'meta',
152+
'param',
153+
'source',
154+
'track',
155+
'wbr',
156+
];
157+
158+
voidTags.forEach(tag => {
159+
const input = `<div>\n<${tag}>\n</div>`;
160+
const expected = `<div>\n <${tag}>\n</div>`;
161+
expect(indentHTML(input)).toBe(expected);
162+
});
163+
});
164+
165+
it('should handle complex real-world HTML', () => {
166+
const input = `<div class="ui segment">
167+
<div class="ui header">
168+
Product List
169+
</div>
170+
<div class="ui list">
171+
<div class="item">
172+
<img src="product1.jpg" />
173+
<div class="content">
174+
<div class="header">Product 1</div>
175+
<div class="description">Description here</div>
176+
</div>
177+
</div>
178+
<div class="item">
179+
<img src="product2.jpg" />
180+
<div class="content">
181+
<div class="header">Product 2</div>
182+
</div>
183+
</div>
184+
</div>
185+
</div>`;
186+
187+
const result = indentHTML(input);
188+
189+
// Verify structure by checking key lines
190+
expect(result).toContain(' <div class="ui header">');
191+
expect(result).toContain(' <div class="item">');
192+
expect(result).toContain(' <img src="product1.jpg" />');
193+
expect(result).toContain(' <div class="header">Product 1</div>');
194+
});
195+
196+
it('should handle non-string input', () => {
197+
expect(indentHTML(null)).toBe('');
198+
expect(indentHTML(undefined)).toBe('');
199+
expect(indentHTML(123)).toBe('');
200+
});
201+
202+
it('should handle empty string', () => {
203+
expect(indentHTML('')).toBe('');
204+
});
205+
206+
it('should not break on attributes with angle brackets in values', () => {
207+
const input = '<div>\n<input placeholder="Enter <value>">\n</div>';
208+
const expected = '<div>\n <input placeholder="Enter <value>">\n</div>';
209+
expect(indentHTML(input)).toBe(expected);
210+
});
211+
});
212+
});

packages/utils/types/html.d.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* HTML and text formatting utility functions
3+
* @see {@link https://next.semantic-ui.com/docs/api/utils/html HTML Utilities Documentation}
4+
*/
5+
6+
/**
7+
* Adds consistent indentation to every line of text
8+
* @see {@link https://next.semantic-ui.com/docs/api/utils/html#indentlines indentLines}
9+
* @see {@link https://next.semantic-ui.com/examples/utils-indentlines Example}
10+
*
11+
* @param text - The text to indent
12+
* @param spaces - Number of spaces to indent each line
13+
* @returns The indented text. Returns empty string for non-string input
14+
*
15+
* @example
16+
* ```ts
17+
* indentLines('line 1\nline 2', 2) // returns ' line 1\n line 2'
18+
* indentLines('line 1\nline 2', 4) // returns ' line 1\n line 2'
19+
* indentLines('code', 0) // returns 'code'
20+
* ```
21+
*/
22+
export function indentLines(text: string, spaces?: number): string;
23+
24+
/**
25+
* Options for the `indentHTML` function.
26+
*/
27+
interface IndentHTMLOptions {
28+
/**
29+
* String to use for each level of indentation.
30+
* @default ' ' (two spaces)
31+
*/
32+
indent?: string;
33+
/**
34+
* Initial nesting level to start indentation from.
35+
* @default 0
36+
*/
37+
startLevel?: number;
38+
/**
39+
* Whether to remove empty lines from the output.
40+
* @default true
41+
*/
42+
trimEmptyLines?: boolean;
43+
}
44+
45+
/**
46+
* Intelligently indents HTML markup with proper nesting
47+
* @see {@link https://next.semantic-ui.com/docs/api/utils/html#indenthtml indentHTML}
48+
* @see {@link https://next.semantic-ui.com/examples/utils-indenthtml Example}
49+
*
50+
* @param html - The HTML string to indent
51+
* @param options - Options for indentation behavior
52+
* @returns The properly indented HTML. Returns empty string for non-string input
53+
*
54+
* @example
55+
* ```ts
56+
* indentHTML('<div>\n<p>Content</p>\n</div>')
57+
* // returns '<div>\n <p>Content</p>\n</div>'
58+
*
59+
* indentHTML('<div>\n<p>Content</p>\n</div>', { indent: '\t' })
60+
* // returns '<div>\n\t<p>Content</p>\n</div>'
61+
*
62+
* indentHTML('<div>\n<p>Content</p>\n</div>', { startLevel: 1 })
63+
* // returns ' <div>\n <p>Content</p>\n </div>'
64+
*
65+
* indentHTML('<div>\n<img src="test.jpg">\n<br>\n</div>')
66+
* // returns '<div>\n <img src="test.jpg">\n <br>\n</div>'
67+
* ```
68+
*/
69+
export function indentHTML(html: string, options?: IndentHTMLOptions): string;

packages/utils/types/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export * from './environment';
99
export * from './equality';
1010
export * from './errors';
1111
export * from './functions';
12+
export * from './html';
1213
export * from './loops';
1314
export * from './numbers';
1415
export * from './objects';

0 commit comments

Comments
 (0)