feat(site-builder): add dependency-free formatHtml prettifier
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import { formatHtml } from './format-html';
|
||||
|
||||
describe('formatHtml', () => {
|
||||
test('indents nested block elements two spaces per level', () => {
|
||||
expect(formatHtml('<div><section><p>hi</p></section></div>')).toBe(
|
||||
'<div>\n <section>\n <p>hi</p>\n </section>\n</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves inline tags on the same line as their text', () => {
|
||||
expect(formatHtml('<p>hello <strong>world</strong> now</p>')).toBe(
|
||||
'<p>hello <strong>world</strong> now</p>',
|
||||
);
|
||||
});
|
||||
|
||||
test('void elements do not open an indent level', () => {
|
||||
expect(formatHtml('<div><img src="a.png"><br><p>x</p></div>')).toBe(
|
||||
'<div>\n <img src="a.png">\n <br>\n <p>x</p>\n</div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves <pre> contents verbatim', () => {
|
||||
const src = '<div><pre> keep\n this</pre></div>';
|
||||
expect(formatHtml(src)).toBe('<div>\n <pre> keep\n this</pre>\n</div>');
|
||||
});
|
||||
|
||||
test('is idempotent', () => {
|
||||
const once = formatHtml('<div><section><p>hi</p></section></div>');
|
||||
expect(formatHtml(once)).toBe(once);
|
||||
});
|
||||
|
||||
test('empty and whitespace-only input round-trip to an empty string', () => {
|
||||
expect(formatHtml('')).toBe('');
|
||||
expect(formatHtml(' \n ')).toBe('');
|
||||
});
|
||||
|
||||
test('unbalanced markup never produces negative indent', () => {
|
||||
expect(formatHtml('</div><p>x</p>')).toBe('</div>\n<p>x</p>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Indent-only HTML prettifier for the Edit HTML modal's Format button.
|
||||
*
|
||||
* Deliberately small and dependency-free: it re-indents BLOCK-level tags onto
|
||||
* their own lines and nests them two spaces per level. It does not reflow
|
||||
* text, reorder attributes, or normalise quoting -- a formatter that rewrites
|
||||
* user markup is a formatter people stop trusting.
|
||||
*
|
||||
* Inline tags (<strong>, <a>, <span>, ...) are left exactly where they sit,
|
||||
* and <pre> contents are copied through verbatim.
|
||||
*
|
||||
* Design note: a block-open tag is not committed to its own output line the
|
||||
* moment it is seen. It stays "pending" on a stack frame; if only inline
|
||||
* content follows before its matching close (e.g. `<p>hi</p>`), the open
|
||||
* tag, the inline content, and the close tag are merged onto a single line.
|
||||
* The pending open is only forced onto its own line ("committed") once a
|
||||
* nested block-level token (open/void/verbatim) proves the element spans
|
||||
* more than one line.
|
||||
*/
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'html', 'head', 'body', 'div', 'section', 'article', 'aside', 'header', 'footer',
|
||||
'main', 'nav', 'form', 'fieldset', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
|
||||
'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'blockquote', 'figure', 'figcaption', 'pre', 'hr', 'br', 'img', 'iframe', 'video',
|
||||
'audio', 'source', 'canvas', 'script', 'style', 'select', 'option', 'textarea',
|
||||
]);
|
||||
|
||||
const VOID_TAGS = new Set([
|
||||
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
||||
'link', 'meta', 'param', 'source', 'track', 'wbr',
|
||||
]);
|
||||
|
||||
const INDENT = ' ';
|
||||
|
||||
interface Token {
|
||||
/** Raw text of the token. Text runs have interior whitespace collapsed. */
|
||||
text: string;
|
||||
/** Lowercased tag name, or '' for a text run. */
|
||||
tag: string;
|
||||
kind: 'open' | 'close' | 'void' | 'text' | 'verbatim';
|
||||
}
|
||||
|
||||
/** Collapse a text run's whitespace to single spaces; drop it if it is all whitespace. */
|
||||
function pushTextToken(tokens: Token[], raw: string): void {
|
||||
const collapsed = raw.replace(/\s+/g, ' ');
|
||||
if (collapsed.trim()) tokens.push({ text: collapsed, tag: '', kind: 'text' });
|
||||
}
|
||||
|
||||
/** Split source into tags and text runs, treating <pre>...</pre> as one atom. */
|
||||
function tokenize(src: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < src.length) {
|
||||
const lt = src.indexOf('<', i);
|
||||
|
||||
if (lt === -1) {
|
||||
pushTextToken(tokens, src.slice(i));
|
||||
break;
|
||||
}
|
||||
|
||||
if (lt > i) {
|
||||
pushTextToken(tokens, src.slice(i, lt));
|
||||
}
|
||||
|
||||
const gt = src.indexOf('>', lt);
|
||||
if (gt === -1) {
|
||||
// Unterminated '<' -- emit the remainder as text rather than looping.
|
||||
pushTextToken(tokens, src.slice(lt));
|
||||
break;
|
||||
}
|
||||
|
||||
const raw = src.slice(lt, gt + 1);
|
||||
const nameMatch = /^<\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw);
|
||||
const tag = nameMatch ? nameMatch[1].toLowerCase() : '';
|
||||
|
||||
// <pre> swallows everything up to its closing tag, untouched.
|
||||
if (tag === 'pre' && !raw.startsWith('</')) {
|
||||
const closeIdx = src.toLowerCase().indexOf('</pre>', gt);
|
||||
if (closeIdx !== -1) {
|
||||
const end = closeIdx + '</pre>'.length;
|
||||
tokens.push({ text: src.slice(lt, end), tag: 'pre', kind: 'verbatim' });
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const isClose = raw.startsWith('</');
|
||||
const selfClosing = /\/>\s*$/.test(raw);
|
||||
const kind: Token['kind'] = isClose
|
||||
? 'close'
|
||||
: selfClosing || VOID_TAGS.has(tag)
|
||||
? 'void'
|
||||
: 'open';
|
||||
|
||||
tokens.push({ text: raw, tag, kind });
|
||||
i = gt + 1;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** A pending block-level element: may still merge onto a single line. */
|
||||
interface Frame {
|
||||
/** Raw text of the open tag. */
|
||||
text: string;
|
||||
/** Indent depth at which this element's tags render. */
|
||||
depth: number;
|
||||
/** Inline content accumulated directly under this element since it opened
|
||||
* (or since it was last committed). */
|
||||
inline: string;
|
||||
/** Whether the open tag has already been written to its own line. */
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
export function formatHtml(src: string): string {
|
||||
if (!src || !src.trim()) return '';
|
||||
|
||||
const tokens = tokenize(src);
|
||||
const lines: string[] = [];
|
||||
const stack: Frame[] = [];
|
||||
/** Inline/text content seen while no block frame is open. */
|
||||
let rootInline = '';
|
||||
|
||||
const flushRootInline = (): void => {
|
||||
if (!rootInline) return;
|
||||
lines.push(rootInline);
|
||||
rootInline = '';
|
||||
};
|
||||
|
||||
/** Force the innermost pending frame onto its own line, if not already. */
|
||||
const commitTop = (): void => {
|
||||
if (stack.length === 0) return;
|
||||
const top = stack[stack.length - 1];
|
||||
if (top.committed) return;
|
||||
lines.push(INDENT.repeat(top.depth) + top.text);
|
||||
if (top.inline) {
|
||||
lines.push(INDENT.repeat(top.depth + 1) + top.inline);
|
||||
top.inline = '';
|
||||
}
|
||||
top.committed = true;
|
||||
};
|
||||
|
||||
for (const token of tokens) {
|
||||
const isBlock = token.tag !== '' && BLOCK_TAGS.has(token.tag);
|
||||
|
||||
if (!isBlock) {
|
||||
// Inline tag or text -- accumulate against the innermost open element.
|
||||
if (stack.length) {
|
||||
stack[stack.length - 1].inline += token.text;
|
||||
} else {
|
||||
rootInline += token.text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.kind === 'close') {
|
||||
if (stack.length === 0) {
|
||||
// Unbalanced close with nothing open -- emit at depth 0.
|
||||
flushRootInline();
|
||||
lines.push(token.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const top = stack.pop()!;
|
||||
if (!top.committed) {
|
||||
// Nothing block-level ever interrupted this element: merge the
|
||||
// open tag, its inline content, and the close tag onto one line.
|
||||
lines.push(INDENT.repeat(top.depth) + top.text + top.inline + token.text);
|
||||
} else {
|
||||
if (top.inline) {
|
||||
lines.push(INDENT.repeat(top.depth + 1) + top.inline);
|
||||
}
|
||||
lines.push(INDENT.repeat(top.depth) + token.text);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// token.kind is 'open' | 'void' | 'verbatim': a block-level element is
|
||||
// about to render at the current depth, so any still-pending parent
|
||||
// frame can no longer merge onto a single line.
|
||||
commitTop();
|
||||
if (stack.length === 0) flushRootInline();
|
||||
const depth = stack.length;
|
||||
|
||||
if (token.kind === 'open') {
|
||||
stack.push({ text: token.text, depth, inline: '', committed: false });
|
||||
continue;
|
||||
}
|
||||
|
||||
// void or verbatim: renders on its own line, opens no new frame.
|
||||
lines.push(INDENT.repeat(depth) + token.text);
|
||||
}
|
||||
|
||||
// Unbalanced opens: nothing ever closed them. Flush what's left rather
|
||||
// than silently dropping content.
|
||||
while (stack.length) {
|
||||
const top = stack.pop()!;
|
||||
if (!top.committed) {
|
||||
lines.push(INDENT.repeat(top.depth) + top.text);
|
||||
}
|
||||
if (top.inline) {
|
||||
lines.push(INDENT.repeat(top.depth + 1) + top.inline);
|
||||
}
|
||||
}
|
||||
|
||||
flushRootInline();
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
Reference in New Issue
Block a user