diff --git a/craft/src/utils/format-html.test.ts b/craft/src/utils/format-html.test.ts new file mode 100644 index 0000000..a7bb1a1 --- /dev/null +++ b/craft/src/utils/format-html.test.ts @@ -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('
hi
hi
\nhello world now
')).toBe( + 'hello world now
', + ); + }); + + test('void elements do not open an indent level', () => { + expect(formatHtml('
x
\n x
\n contents verbatim', () => {
+ const src = ' keep\n this
';
+ expect(formatHtml(src)).toBe('\n keep\n this
\n');
+ });
+
+ test('is idempotent', () => {
+ const once = formatHtml('hi
');
+ 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('x
')).toBe('\nx
');
+ });
+});
diff --git a/craft/src/utils/format-html.ts b/craft/src/utils/format-html.ts
new file mode 100644
index 0000000..07214dd
--- /dev/null
+++ b/craft/src/utils/format-html.ts
@@ -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 (, , , ...) are left exactly where they sit,
+ * and 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. `hi
`), 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 ...
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() : '';
+
+ // swallows everything up to its closing tag, untouched.
+ if (tag === 'pre' && !raw.startsWith('')) {
+ const closeIdx = src.toLowerCase().indexOf('', gt);
+ if (closeIdx !== -1) {
+ const end = closeIdx + '
'.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');
+}