From bc0e0c74d668987740048671c4c395bed0554f31 Mon Sep 17 00:00:00 2001 From: Andreas Ehrencrona Date: Thu, 27 Aug 2020 12:30:21 +0200 Subject: [PATCH] Sort out whitespace between text and HTML nodes (#120) This solves the issue of whitespace either being wrongly added or removed inside inline tags or between text and tags. Fixes #58, fixes #103, fixes #24 --- src/lib/elements.ts | 74 ++++ src/print/doc-helpers.ts | 107 +++++ src/print/index.ts | 386 ++++++++++-------- src/print/node-helpers.ts | 54 +++ .../input.html | 1 + .../output.html | 3 + .../input.html | 3 + .../output.html | 4 + .../input.html | 7 + .../output.html | 6 + .../input.html | 1 + .../output.html | 4 + .../input.html | 1 + .../output.html | 1 + .../input.html | 18 + .../output.html | 13 + .../output.html | 7 +- ...-with-several-attributes-and-mustache.html | 7 +- .../samples/if-block-without-whitespace.html | 3 + 19 files changed, 522 insertions(+), 178 deletions(-) create mode 100644 src/lib/elements.ts create mode 100644 src/print/doc-helpers.ts create mode 100644 src/print/node-helpers.ts create mode 100644 test/formatting/samples/do-not-add-whitespace-between-inline-elements/input.html create mode 100644 test/formatting/samples/do-not-add-whitespace-between-inline-elements/output.html create mode 100644 test/formatting/samples/inline-element-with-children-no-ws/input.html create mode 100644 test/formatting/samples/inline-element-with-children-no-ws/output.html create mode 100644 test/formatting/samples/inline-element-with-children-ws/input.html create mode 100644 test/formatting/samples/inline-element-with-children-ws/output.html create mode 100644 test/formatting/samples/no-html-whitespace-inside-inline-element/input.html create mode 100644 test/formatting/samples/no-html-whitespace-inside-inline-element/output.html create mode 100644 test/formatting/samples/no-html-whitespace-outside-inline-element/input.html create mode 100644 test/formatting/samples/no-html-whitespace-outside-inline-element/output.html create mode 100644 test/formatting/samples/whitespace-between-tags-and-text/input.html create mode 100644 test/formatting/samples/whitespace-between-tags-and-text/output.html create mode 100644 test/printer/samples/if-block-without-whitespace.html diff --git a/src/lib/elements.ts b/src/lib/elements.ts new file mode 100644 index 00000000..2338fa97 --- /dev/null +++ b/src/lib/elements.ts @@ -0,0 +1,74 @@ +export type TagName = keyof HTMLElementTagNameMap | 'svg'; + +// @see http://xahlee.info/js/html5_non-closing_tag.html +export const selfClosingTags = [ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]; + +// https://developer.mozilla.org/en-US/docs/Web/HTML/Inline_elements#Elements +export const inlineElements: TagName[] = [ + 'a', + 'abbr', + 'audio', + 'b', + 'bdi', + 'bdo', + 'br', + 'button', + 'canvas', + 'cite', + 'code', + 'data', + 'datalist', + 'del', + 'dfn', + 'em', + 'embed', + 'i', + 'iframe', + 'img', + 'input', + 'ins', + 'kbd', + 'label', + 'map', + 'mark', + 'meter', + 'noscript', + 'object', + 'output', + 'picture', + 'progress', + 'q', + 'ruby', + 's', + 'samp', + 'select', + 'slot', + 'small', + 'span', + 'strong', + 'sub', + 'sup', + 'svg', + 'template', + 'textarea', + 'time', + 'u', + 'var', + 'video', + 'wbr' +]; diff --git a/src/print/doc-helpers.ts b/src/print/doc-helpers.ts new file mode 100644 index 00000000..662a4609 --- /dev/null +++ b/src/print/doc-helpers.ts @@ -0,0 +1,107 @@ +import { Doc, doc } from 'prettier'; + +export function isLine(doc: Doc) { + return typeof doc === 'object' && doc.type === 'line' +} + +export function isLineDiscardedIfLonely(doc: Doc) { + return isLine(doc) && !(doc as doc.builders.Line).keepIfLonely +} + +/** + * Check if the doc is empty, i.e. consists of nothing more than empty strings (possibly nested). + */ +export function isEmptyDoc(doc: Doc): boolean { + if (typeof doc === 'string') { + return doc.length === 0; + } + + if (doc.type === 'line') { + return !doc.keepIfLonely; + } + + const { contents } = doc as { contents?: Doc }; + + if (contents) { + return isEmptyDoc(contents); + } + + const { parts } = doc as { parts?: Doc[] }; + + if (parts) { + return isEmptyGroup(parts); + } + + return false; +} + +export function isEmptyGroup(group: Doc[]): boolean { + return !group.find(doc => !isEmptyDoc(doc)) +} + +/** + * Trims both leading and trailing nodes matching `isWhitespace` independent of nesting level + * (though all trimmed adjacent nodes need to be a the same level). Modifies the `docs` array. + */ +export function trim(docs: Doc[], isWhitespace: (doc: Doc) => boolean): Doc[] { + trimLeft(docs, isWhitespace); + trimRight(docs, isWhitespace); + + return docs; +} + +/** + * Trims the leading nodes matching `isWhitespace` independent of nesting level (though all nodes need to be a the same level) + * and returnes the removed nodes. + */ +export function trimLeft(group: Doc[], isWhitespace: (doc: Doc) => boolean): Doc[] | undefined { + let firstNonWhitespace = group.findIndex((doc) => !isWhitespace(doc)); + + if (firstNonWhitespace < 0 && group.length) { + firstNonWhitespace = group.length; + } + + if (firstNonWhitespace > 0) { + return group.splice(0, firstNonWhitespace); + } else { + const parts = getParts(group[0]); + + if (parts) { + return trimLeft(parts, isWhitespace); + } + } +} + +/** + * Trims the trailing nodes matching `isWhitespace` independent of nesting level (though all nodes need to be a the same level) + * and returnes the removed nodes. + */ +export function trimRight(group: Doc[], isWhitespace: (doc: Doc) => boolean): Doc[] | undefined { + let lastNonWhitespace = group.length ? findLastIndex((doc) => !isWhitespace(doc), group) : 0; + + if (lastNonWhitespace < group.length - 1) { + return group.splice(lastNonWhitespace + 1); + } else { + const parts = getParts(group[group.length - 1]); + + if (parts) { + return trimRight(parts, isWhitespace); + } + } +} + +function getParts(doc: Doc): Doc[] | undefined { + if (typeof doc === 'object' && (doc.type === 'fill' || doc.type === 'concat')) { + return doc.parts; + } +} + +function findLastIndex(isMatch: (item: T) => boolean, items: T[]) { + for (let i = items.length - 1; i >= 0; i--) { + if (isMatch(items[i])) { + return i; + } + } + + return -1; +} diff --git a/src/print/index.ts b/src/print/index.ts index 3eab9ce8..03cd24ca 100644 --- a/src/print/index.ts +++ b/src/print/index.ts @@ -1,10 +1,27 @@ import { FastPath, Doc, doc, ParserOptions } from 'prettier'; -import { Node, IdentifierNode, MustacheTagNode, IfBlockNode, EachBlockNode } from './nodes'; +import { Node, MustacheTagNode, IfBlockNode } from './nodes'; import { isASTNode } from './helpers'; import { extractAttributes } from '../lib/extractAttributes'; import { getText } from '../lib/getText'; import { parseSortOrder, SortOrderPart } from '../options'; import { hasSnippedContent, unsnipContent } from '../lib/snipTagContent'; +import { selfClosingTags } from '../lib/elements'; +import { + canBreakBefore, + canBreakAfter, + isInlineElement, + isInlineNode, + isEmptyNode, +} from './node-helpers'; +import { + isLine, + isLineDiscardedIfLonely, + isEmptyGroup, + trim, + trimLeft, + trimRight, +} from './doc-helpers'; + const { concat, join, @@ -31,26 +48,10 @@ declare module 'prettier' { } } -// @see http://xahlee.info/js/html5_non-closing_tag.html -const SELF_CLOSING_TAGS = [ - 'area', - 'base', - 'br', - 'col', - 'embed', - 'hr', - 'img', - 'input', - 'link', - 'meta', - 'param', - 'source', - 'track', - 'wbr', -]; - let ignoreNext = false; +const keepIfLonelyLine = { ...line, keepIfLonely: true, hard: true }; + export function print(path: FastPath, options: ParserOptions, print: PrintFn): Doc { const n = path.getValue(); if (!n) { @@ -86,7 +87,7 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D } }, }; - parseSortOrder(options.svelteSortOrder).forEach(p => addParts[p]()); + parseSortOrder(options.svelteSortOrder).forEach((p) => addParts[p]()); return group(join(hardline, parts)); } @@ -94,14 +95,12 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D const node = n as Node; if (ignoreNext && (node.type !== 'Text' || !isEmptyNode(node))) { - ignoreNext = false + ignoreNext = false; return concat( - options.originalText.slice( - options.locStart(node), - options.locEnd(node) - ) - .split('\n') - .flatMap((o, i) => i == 0 ? o : [literalline, o]) + options.originalText + .slice(options.locStart(node), options.locEnd(node)) + .split('\n') + .flatMap((o, i) => (i == 0 ? o : [literalline, o])), ); } @@ -113,7 +112,7 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D return ''; } - return concat([printChildren(path, print, false), hardline]); + return concat([...trim(printChildren(path, print), isLine), hardline]); case 'Text': if (isEmptyNode(node)) { return { @@ -141,22 +140,22 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D * For non-empty text nodes each sequence of non-whitespace characters (effectively, * each "word") is joined by a single `line`, which will be rendered as a single space * until this node's current line is out of room, at which `fill` will break at the - * most convienient instance of `line`. + * most convenient instance of `line`. */ - return fill(join(line, (node.raw || node.data).split(/[\t\n\f\r ]+/)).parts); + return fill(splitTextToDocs(node.raw || node.data)); case 'Element': case 'InlineComponent': case 'Slot': case 'Window': case 'Head': case 'Title': { - const isEmpty = node.children.every(child => isEmptyNode(child)); + const isEmpty = node.children.every((child) => isEmptyNode(child)); const isSelfClosingTag = isEmpty && (!options.svelteStrictMode || node.type !== 'Element' || - SELF_CLOSING_TAGS.indexOf(node.name) !== -1); + selfClosingTags.indexOf(node.name) !== -1); return group( concat([ @@ -168,14 +167,14 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D concat([ node.type === 'InlineComponent' && node.expression ? concat([ - line, - 'this=', - open, - printJS(path, print, 'expression'), - close, - ]) + line, + 'this=', + open, + printJS(path, print, 'expression'), + close, + ]) : '', - ...path.map(childPath => childPath.call(print), 'attributes'), + ...path.map((childPath) => childPath.call(print), 'attributes'), options.svelteBracketNewLine ? dedent(isSelfClosingTag ? line : softline) : '', @@ -183,11 +182,17 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D ), ), - isSelfClosingTag ? `${options.svelteBracketNewLine ? '' : ' '}/>` : '>', - - isEmpty ? '' : indent(printChildren(path, print)), - - isSelfClosingTag ? '' : concat(['']), + ...(isSelfClosingTag + ? [options.svelteBracketNewLine ? '' : ' ', `/>`] + : [ + '>', + isEmpty + ? '' + : isInlineElement(node) + ? printIndentedPreservingWhitespace(path, print) + : printIndentedWithNewlines(path, print), + ``, + ]), ]), ); } @@ -199,17 +204,17 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D node.name, indent( - group(concat(path.map(childPath => childPath.call(print), 'attributes'))), + group(concat(path.map((childPath) => childPath.call(print), 'attributes'))), ), ' />', ]), ); case 'Identifier': - return node.name; - case 'AttributeShorthand': { - return node.expression.name; - } + return node.name; + case 'AttributeShorthand': { + return node.expression.name; + } case 'Attribute': { const hasLoneMustacheTag = node.value !== true && @@ -224,23 +229,23 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D if (hasLoneMustacheTag) { const expression = (node.value as [MustacheTagNode])[0].expression; isAttributeShorthand = - expression.type === 'Identifier' && expression.name === node.name; + expression.type === 'Identifier' && expression.name === node.name; } if (isAttributeShorthand && options.svelteAllowShorthand) { return concat([line, '{', node.name, '}']); - } else { - const def: Doc[] = [line, node.name]; - if (node.value !== true) { - def.push('='); - const quotes = !hasLoneMustacheTag || options.svelteStrictMode; - - quotes && def.push('"'); - def.push(...path.map(childPath => childPath.call(print), 'value')); - quotes && def.push('"'); - } - return concat(def); - } + } else { + const def: Doc[] = [line, node.name]; + if (node.value !== true) { + def.push('='); + const quotes = !hasLoneMustacheTag || options.svelteStrictMode; + + quotes && def.push('"'); + def.push(...path.map((childPath) => childPath.call(print), 'value')); + quotes && def.push('"'); + } + return concat(def); + } } case 'MustacheTag': return concat(['{', printJS(path, print, 'expression'), '}']); @@ -249,7 +254,7 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D '{#if ', printJS(path, print, 'expression'), '}', - indent(printChildren(path, print)), + printIndentedWithNewlines(path, print), ]; if (node.else) { @@ -258,7 +263,7 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D def.push('{/if}'); - return group(concat(def)); + return concat([group(concat(def)), breakParent]); } case 'ElseBlock': { // Else if @@ -272,18 +277,18 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D const ifNode = node.children[0] as IfBlockNode; const def: Doc[] = [ '{:else if ', - path.map(ifPath => printJS(path, print, 'expression'), 'children')[0], + path.map((ifPath) => printJS(path, print, 'expression'), 'children')[0], '}', - indent(path.map(ifPath => printChildren(ifPath, print), 'children')[0]), + path.map((ifPath) => printIndentedWithNewlines(ifPath, print), 'children')[0], ]; if (ifNode.else) { - def.push(path.map(ifPath => ifPath.call(print, 'else'), 'children')[0]); + def.push(path.map((ifPath) => ifPath.call(print, 'else'), 'children')[0]); } return group(concat(def)); } - return group(concat(['{:else}', indent(printChildren(path, print))])); + return group(concat(['{:else}', printIndentedWithNewlines(path, print)])); } case 'EachBlock': { const def: Doc[] = [ @@ -301,7 +306,7 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D def.push(' (', printJS(path, print, 'key'), ')'); } - def.push('}', indent(printChildren(path, print))); + def.push('}', printIndentedWithNewlines(path, print)); if (node.else) { def.push(path.call(print, 'else')); @@ -309,7 +314,7 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D def.push('{/each}'); - return group(concat(def)); + return concat([group(concat(def)), breakParent]); } case 'AwaitBlock': { const hasPendingBlock = node.pending.children.some((n) => !isEmptyNode(n)); @@ -360,7 +365,11 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D case 'ThenBlock': case 'PendingBlock': case 'CatchBlock': - return printChildren(path, print); + return concat([ + softline, + ...trim(printChildren(path, print), isLine), + dedent(softline), + ]); case 'EventHandler': return concat([ line, @@ -398,7 +407,7 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D node.name, // shorthand let directives have `null` expressions !node.expression || - (node.expression.type === 'Identifier' && node.expression.name === node.name) + (node.expression.type === 'Identifier' && node.expression.name === node.name) ? '' : concat(['=', open, printJS(path, print, 'expression'), close]), ]); @@ -463,99 +472,66 @@ export function print(path: FastPath, options: ParserOptions, print: PrintFn): D throw new Error('unknown node type: ' + node.type); } -function isEmptyGroup(group: Doc[]): boolean { - if (group.length === 0) { - return true; - } - - if (group.length > 1) { - return false; - } - - const lonelyDoc = group[0]; - - if (typeof lonelyDoc === 'string' || lonelyDoc.type !== 'line') { - return false; - } - - return !lonelyDoc.keepIfLonely; -} - -/** - * Due to how `String.prototype.split` works, `TextNode`s with leading whitespace will be printed - * to a `Fill` that has two additional parts at the begnning: an empty string (`''`) and a `line`. - * If such a `Fill` doc is present at the beginning of an inline node group, those additional parts - * need to be removed to prevent additional whitespace at the beginning of the parent's inner - * content or after a sibling block node (i.e. HTML tags). - */ -function trimLeft(group: Doc[]): void { - if (group.length === 0) { - return; - } +function printChildren(path: FastPath, print: PrintFn): Doc[] { + let childDocs: Doc[] = []; + let currentGroup: { doc: Doc; node: Node }[] = []; + // the index of the last child doc we could add a linebreak after + let lastBreakIndex = -1; - const first = group[0]; - if (typeof first === 'string') { - return; - } + /** + * Call when reaching a point where a linebreak is possible. Will + * put all `childDocs` since the last possible linebreak position + * into a `concat` to avoid them breaking. + */ + function linebreakPossible() { + if (lastBreakIndex >= 0 && lastBreakIndex < childDocs.length - 1) { + childDocs = childDocs + .slice(0, lastBreakIndex) + .concat(concat(childDocs.slice(lastBreakIndex))); + } - if (first.type === 'line') { - group.shift(); - return; + lastBreakIndex = -1; } - if (first.type !== 'fill') { - return; - } + /** + * Add a document to the output. + * @param childDoc null means do not add anything but allow for the possibility of a linebreak here. + */ + function outputChildDoc(childDoc: Doc | null, fromNodes: Node[]) { + const firstNode = fromNodes[0]; + const lastNode = fromNodes[fromNodes.length - 1]; - // find the index of the first part that isn't an empty string or a line - const trimIndex = first.parts.findIndex(part => - typeof part === 'string' ? part !== '' : part.type !== 'line', - ); + if (!childDoc || canBreakBefore(firstNode)) { + linebreakPossible(); - first.parts.splice(0, trimIndex); -} + const lastChild = childDocs[childDocs.length - 1]; -/** - * Due to how `String.prototype.split` works, `TextNode`s with trailing whitespace will be printed - * to a `Fill` that has two additional parts at the end: a `line` and an empty string (`''`). If - * such a `Fill` doc is present at the beginning of an inline node group, those additional parts - * need to be removed to prevent additional whitespace at the end of the parent's inner content or - * before a sibling block node (i.e. HTML tags). - */ -function trimRight(group: Doc[]): void { - if (group.length === 0) { - return; - } + // separate children by softlines, but not if the children are already lines. + // one exception: allow for a line break before "keepIfLonely" lines because they represent an empty line + if ( + childDoc != null && + !isLineDiscardedIfLonely(childDoc) && + lastChild != null && + !isLine(lastChild) + ) { + childDocs.push(softline); + } + } - const last = group[group.length - 1]; - if (typeof last === 'string') { - return; - } + if (lastBreakIndex < 0 && childDoc && !canBreakAfter(lastNode)) { + lastBreakIndex = childDocs.length; + } - if (last.type === 'line') { - group.pop(); - return; + if (childDoc) { + childDocs.push(childDoc); + } } - if (last.type !== 'fill') { - return; + function lastChildDocProduced() { + // line breaks are ok after last child + outputChildDoc(null, []); } - last.parts.reverse(); - - // find the index of the first part that isn't an empty string or a line - const trimIndex = last.parts.findIndex(part => - typeof part === 'string' ? part !== '' : part.type !== 'line', - ); - - last.parts.splice(0, trimIndex); - last.parts.reverse(); -} - -function printChildren(path: FastPath, print: PrintFn, surroundingLines = true): Doc { - const childDocs: Doc[] = []; - let currentGroup: Doc[] = []; - /** * Sequences of inline nodes (currently, `TextNode`s and `MustacheTag`s) are collected into * groups and printed as a single `Fill` doc so that linebreaks as a result of sibling block @@ -564,33 +540,107 @@ function printChildren(path: FastPath, print: PrintFn, surroundingLines = true): * desired to have text directly wrapping a mustache tag without additional whitespace. */ function flush() { - if (!isEmptyGroup(currentGroup)) { - trimLeft(currentGroup); - trimRight(currentGroup); - childDocs.push(fill(currentGroup)); + let groupDocs = currentGroup.map((item) => item.doc); + const groupNodes = currentGroup.map((item) => item.node); + + for (let doc of extractOutermostNewlines(groupDocs)) { + outputChildDoc(doc, groupNodes); } + currentGroup = []; } - path.each(childPath => { + path.each((childPath) => { const childNode = childPath.getValue() as Node; const childDoc = childPath.call(print); if (isInlineNode(childNode)) { - currentGroup.push(childDoc); + currentGroup.push({ doc: childDoc, node: childNode }); } else { flush(); - childDocs.push(concat([breakParent, childDoc])); + + outputChildDoc(isLine(childDoc) ? childDoc : concat([breakParent, childDoc]), [ + childNode, + ]); } }, 'children'); flush(); + lastChildDocProduced(); + + return childDocs; +} + +/** + * Print the nodes in `path` indented and with leading and trailing newlines. + */ +function printIndentedWithNewlines(path: FastPath, print: PrintFn): Doc { + return indent( + concat([softline, ...trim(printChildren(path, print), isLine), dedent(softline)]), + ); +} - return concat([ - surroundingLines ? softline : '', - join(hardline, childDocs), - surroundingLines ? dedent(softline) : '', - ]); +/** + * Print the nodes in `path` indented but without adding any leading or trailing newlines. + */ +function printIndentedPreservingWhitespace(path: FastPath, print: PrintFn) { + return indent(concat(dedentFinalNewline(printChildren(path, print)))); +} + +/** + * Split the text into words separated by whitespace. Replace the whitespaces by lines, + * collapsing multiple whitespaces into a single line. + * + * If the text starts or ends with multiple newlines, those newlines should be "keepIfLonely" + * since we want double newlines in the output. + */ +function splitTextToDocs(text: string): Doc[] { + let docs: Doc[] = text.split(/[\t\n\f\r ]+/); + + docs = join(line, docs).parts.filter((s) => s !== ''); + + // if the text starts with two newlines, the first doc is already a newline. make it "keepIfLonely" + if (text.match(/^([\t\f\r ]*\n){2}/)) { + docs[0] = keepIfLonelyLine; + } + + // if the text ends with two newlines, the last doc is already a newline. make it "keepIfLonely" + if (text.match(/(\n[\t\f\r ]*){2}$/)) { + docs[docs.length - 1] = keepIfLonelyLine; + } + + return docs; +} + +/** + * If there is a trailing newline, pull it out and put it inside a `dedent`. This is used + * when we want to preserve whitespace, but still indent the newline if there is one + * (e.g. for `1\n` the `` will be on its own line; for `1` it can't + * because it would introduce new whitespace) + */ +function dedentFinalNewline(docs: Doc[]): Doc[] { + const trimmedRight = trimRight(docs, isLine); + + if (trimmedRight) { + return [...docs, dedent(trimmedRight[trimmedRight.length - 1])]; + } else { + return docs; + } +} + +/** + * Pull out any nested leading or trailing lines and put them at the top level. + */ + +function extractOutermostNewlines(docs: Doc[]): Doc[] { + const leadingLines: Doc[] = trimLeft(docs, isLine) || []; + const trailingLines: Doc[] = trimRight(docs, isLine) || []; + + return [ + ...leadingLines, + ...(!isEmptyGroup(docs) ? [fill(docs)] : ([] as Doc[])), + ...trailingLines, + ]; } function printJS(path: FastPath, print: PrintFn, name?: string) { @@ -603,14 +653,6 @@ function printJS(path: FastPath, print: PrintFn, name?: string) { return path.call(print, name); } -function isInlineNode(node: Node): boolean { - return node.type === 'Text' || node.type === 'MustacheTag'; -} - -function isEmptyNode(node: Node): boolean { - return node.type === 'Text' && (node.raw || node.data).trim() === ''; -} - function expandNode(node): string { if (node === null) { return ''; diff --git a/src/print/node-helpers.ts b/src/print/node-helpers.ts new file mode 100644 index 00000000..78c12649 --- /dev/null +++ b/src/print/node-helpers.ts @@ -0,0 +1,54 @@ +import { Node } from './nodes'; +import { inlineElements, TagName } from '../lib/elements'; + +export function isInlineElement(node: Node) { + return node.type === 'Element' && inlineElements.includes(node.name as TagName); +} + +export function isWhitespaceChar(ch: string) { + return ' \t\n\r'.indexOf(ch) >= 0; +} + +export function canBreakAfter(node: Node) { + switch (node.type) { + case 'Text': + return isWhitespaceChar(node.raw[node.raw.length - 1]); + case 'Element': + return !isInlineElement(node); + default: + return true; + } +} + +export function canBreakBefore(node: Node) { + switch (node.type) { + case 'Text': + return isWhitespaceChar(node.raw[0]); + case 'Element': + return !isInlineElement(node); + default: + return true; + } +} + +export function isInlineNode(node: Node): boolean { + switch (node.type) { + case 'Text': + const text = node.raw || node.data; + const isAllWhitespace = text.trim() === '' + + return !isAllWhitespace || text === ''; + case 'MustacheTag': + case 'EachBlock': + case 'IfBlock': + return true; + case 'Element': + return isInlineElement(node); + default: + return false; + } +} + +export function isEmptyNode(node: Node): boolean { + return node.type === 'Text' && (node.raw || node.data).trim() === ''; +} diff --git a/test/formatting/samples/do-not-add-whitespace-between-inline-elements/input.html b/test/formatting/samples/do-not-add-whitespace-between-inline-elements/input.html new file mode 100644 index 00000000..a9786277 --- /dev/null +++ b/test/formatting/samples/do-not-add-whitespace-between-inline-elements/input.html @@ -0,0 +1 @@ +

ApplesOrangeBananasPineapplesGrapefruitKiwi

diff --git a/test/formatting/samples/do-not-add-whitespace-between-inline-elements/output.html b/test/formatting/samples/do-not-add-whitespace-between-inline-elements/output.html new file mode 100644 index 00000000..190fc54a --- /dev/null +++ b/test/formatting/samples/do-not-add-whitespace-between-inline-elements/output.html @@ -0,0 +1,3 @@ +

+ ApplesOrangeBananasPineapplesGrapefruitKiwi +

diff --git a/test/formatting/samples/inline-element-with-children-no-ws/input.html b/test/formatting/samples/inline-element-with-children-no-ws/input.html new file mode 100644 index 00000000..fb6966df --- /dev/null +++ b/test/formatting/samples/inline-element-with-children-no-ws/input.html @@ -0,0 +1,3 @@ +Foo + + \ No newline at end of file diff --git a/test/formatting/samples/inline-element-with-children-no-ws/output.html b/test/formatting/samples/inline-element-with-children-no-ws/output.html new file mode 100644 index 00000000..0223be84 --- /dev/null +++ b/test/formatting/samples/inline-element-with-children-no-ws/output.html @@ -0,0 +1,4 @@ +Foo + + + diff --git a/test/formatting/samples/inline-element-with-children-ws/input.html b/test/formatting/samples/inline-element-with-children-ws/input.html new file mode 100644 index 00000000..13b0fa72 --- /dev/null +++ b/test/formatting/samples/inline-element-with-children-ws/input.html @@ -0,0 +1,7 @@ + + + Foo + + + + \ No newline at end of file diff --git a/test/formatting/samples/inline-element-with-children-ws/output.html b/test/formatting/samples/inline-element-with-children-ws/output.html new file mode 100644 index 00000000..2e6a55c1 --- /dev/null +++ b/test/formatting/samples/inline-element-with-children-ws/output.html @@ -0,0 +1,6 @@ + + Foo + + + + diff --git a/test/formatting/samples/no-html-whitespace-inside-inline-element/input.html b/test/formatting/samples/no-html-whitespace-inside-inline-element/input.html new file mode 100644 index 00000000..873db7f0 --- /dev/null +++ b/test/formatting/samples/no-html-whitespace-inside-inline-element/input.html @@ -0,0 +1 @@ +

Apples, Orange, Bananas, Pineapples, Grapefruit, Kiwi

\ No newline at end of file diff --git a/test/formatting/samples/no-html-whitespace-inside-inline-element/output.html b/test/formatting/samples/no-html-whitespace-inside-inline-element/output.html new file mode 100644 index 00000000..e2e76717 --- /dev/null +++ b/test/formatting/samples/no-html-whitespace-inside-inline-element/output.html @@ -0,0 +1,4 @@ +

+ Apples, Orange, Bananas, Pineapples, Grapefruit, + Kiwi +

diff --git a/test/formatting/samples/no-html-whitespace-outside-inline-element/input.html b/test/formatting/samples/no-html-whitespace-outside-inline-element/input.html new file mode 100644 index 00000000..2f8252e4 --- /dev/null +++ b/test/formatting/samples/no-html-whitespace-outside-inline-element/input.html @@ -0,0 +1 @@ +

"Bold Italic"

diff --git a/test/formatting/samples/no-html-whitespace-outside-inline-element/output.html b/test/formatting/samples/no-html-whitespace-outside-inline-element/output.html new file mode 100644 index 00000000..2f8252e4 --- /dev/null +++ b/test/formatting/samples/no-html-whitespace-outside-inline-element/output.html @@ -0,0 +1 @@ +

"Bold Italic"

diff --git a/test/formatting/samples/whitespace-between-tags-and-text/input.html b/test/formatting/samples/whitespace-between-tags-and-text/input.html new file mode 100644 index 00000000..407c4b51 --- /dev/null +++ b/test/formatting/samples/whitespace-between-tags-and-text/input.html @@ -0,0 +1,18 @@ +
+ + + Text + + + Text + + + + + Text + + + + + +
\ No newline at end of file diff --git a/test/formatting/samples/whitespace-between-tags-and-text/output.html b/test/formatting/samples/whitespace-between-tags-and-text/output.html new file mode 100644 index 00000000..967e8ab3 --- /dev/null +++ b/test/formatting/samples/whitespace-between-tags-and-text/output.html @@ -0,0 +1,13 @@ +
+ + Text + + + Text + + + + Text + + +
diff --git a/test/formatting/samples/wrap-element-attributes-and-children/output.html b/test/formatting/samples/wrap-element-attributes-and-children/output.html index 473d8456..9ec7fd93 100644 --- a/test/formatting/samples/wrap-element-attributes-and-children/output.html +++ b/test/formatting/samples/wrap-element-attributes-and-children/output.html @@ -1,3 +1,4 @@ - - {linkText} - +{linkText} diff --git a/test/printer/samples/element-with-several-attributes-and-mustache.html b/test/printer/samples/element-with-several-attributes-and-mustache.html index 473d8456..9ec7fd93 100644 --- a/test/printer/samples/element-with-several-attributes-and-mustache.html +++ b/test/printer/samples/element-with-several-attributes-and-mustache.html @@ -1,3 +1,4 @@ - - {linkText} - +{linkText} diff --git a/test/printer/samples/if-block-without-whitespace.html b/test/printer/samples/if-block-without-whitespace.html new file mode 100644 index 00000000..d09fdab3 --- /dev/null +++ b/test/printer/samples/if-block-without-whitespace.html @@ -0,0 +1,3 @@ +{#each items as item} + {item.name}{#if item.isNew}*{/if}{#if item.isUpdated}!{/if} +{/each}