feat(site-builder): add dependency-free formatHtml prettifier

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-08 18:48:41 -07:00
co-authored by Claude Opus 5
parent 4ac57e1c4c
commit 321a193b83
2 changed files with 252 additions and 0 deletions
+41
View File
@@ -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>');
});
});
+211
View File
@@ -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');
}