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

')).toBe( + '
\n
\n

hi

\n
\n
', + ); + }); + + test('leaves inline tags on the same line as their text', () => { + expect(formatHtml('

hello world now

')).toBe( + '

hello world now

', + ); + }); + + test('void elements do not open an indent level', () => { + expect(formatHtml('

x

')).toBe( + '
\n \n
\n

x

\n
', + ); + }); + + test('preserves
 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('\n

x

'); + }); +}); 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('', 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('\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'); +}