Repository: sveltejs/prettier-plugin-svelte (ts)

Issue:
HTML formatting differs in .svelte and .html files
I have markup for a component like this:
```html
<div>
    <a href="/some-long-href-lorem-ipsum/">Lorem ipsum lorem ipsum 1</a>,
    <a href="/some-long-href-lorem-ipsum/">Lorem ipsum lorem ipsum 2</a>,
    <a href="/some-long-href-lorem-ipsum/">Lorem ipsum lorem ipsum 3</a>
</div>
```

If I put this code into a `component.html` file and let prettier format it then the formatting stays as it is above.

However if I put this markup into a `component.svelte` file then it gets formatted to this:
```html
<div>
    <a href="/some-long-href-lorem-ipsum/">Lorem ipsum lorem ipsum</a>
    ,
    <a href="/some-long-href-lorem-ipsum/">Lorem ipsum lorem ipsum</a>
    ,
    <a href="/some-long-href-lorem-ipsum/">Lorem ipsum lorem ipsum</a>
</div>
```

This is a problem if the `a` tags are styled as `display: inline-block;`. To avoid I have the folllowing setting in my `.prettierrc` file:

```json
{ "htmlWhitespaceSensitivity": "strict" }
```

I suppose that prettier-plugin-svelte somehow doesn't regard this setting.

Pull request:
Sort out whitespace between text and HTML nodes
This solves the issue of whitespace either being wrongly added or removed inside inline tags or between text and tags.

See the description for #116 on which it builds. In addition to the examples mentioned there, it fixes more cases involving extraneous or removed whitespace. See examples below. It is, however, a much bigger rewrite than #116, so I suspect this PR might get turned down on the basis of being too radical a change. I'm therefore opening this as a separate PR so that merging just #116 is still an option. 

Apart from fixing more issues, I also find the code in this PR cleaner.

In addition to the fixes mentioned in #116 it fixes #58 fully. 

It also handles for example:

```
<a href=""><i>link</i></a>
```

where it used to add a whitespace inside the `<a>...</a>` tag to make

```
<a href="">
    <i>link</i>
</a>
```

------

You might not want whitespace before or after `{#if}` blocks, such as with this (somewhat contrived) code that prints `item*` for new items and `item!` for updated ones:

```
{#each items as item}
    <b>{item.name}</b>{#if item.isNew}*{/if}{#if item.isUpdated}!{/if}
{/each}
```

which used to be formatted as

```
{#each items as item}
    <b>{item.name}</b>
    {#if item.isNew}*{/if}
    {#if item.isUpdated}!{/if}
{/each}
```
thereby printing `item *` and `item !`. (Add a space or newline before each `{#if}` and they will still be placed on separated lines)

------


It used to turn

```
<div>
    <b>one</b>,<b>two</b>
</div>
```

into

```
<div>
    <b>one</b>
    ,
    <b>two</b>
</div>
```
(thereby adding whitespace around the comma). with this PR the result is

```
<div>
    <b>one</b>,<b>two</b>
</div>
```

------

It used to remove manually added empty lines between text and tags, so e.g. 

```
<div>

<Tag />
Text
<Tag />

Text

<Tag />


Text


<Tag />


</div>
```

would become

```
<div>

    <Tag />
    Text
    <Tag />
    Text
    <Tag />
    Text
    <Tag />

</div>
```

but is now

```
<div>
    <Tag />
    Text
    <Tag />

    Text

    <Tag />

    Text

    <Tag />
</div>
```

(note that the empty lines right inside the `<div>...</div>` are also removed)



Patch:
diff --git a/src/lib/elements.ts b/src/lib/elements.ts
new file mode 100644
index 0000000..2338fa9
--- /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 0000000..662a460
--- /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<T>(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 3eab9ce..03cd24c 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(['</', node.name, '>']),
+                    ...(isSelfClosingTag
+                        ? [options.svelteBracketNewLine ? '' : ' ', `/>`]
+                        : [
+                              '>',
+                              isEmpty
+                                  ? ''
+                                  : isInlineElement(node)
+                                  ? printIndentedPreservingWhitespace(path, print)
+                                  : printIndentedWithNewlines(path, print),
+                              `</${node.name}>`,
+                          ]),
                 ]),
             );
         }
@@ -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 `<b>1\n</b>` the `</b>` will be on its own line; for `<b>1</b>` 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 0000000..78c1264
--- /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() === '';
+}


Test patch:
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 0000000..a978627
--- /dev/null
+++ b/test/formatting/samples/do-not-add-whitespace-between-inline-elements/input.html
@@ -0,0 +1,1 @@
+<p><b>Apples</b><i>Orange</i><b>Bananas</b><i>Pineapples</i><b>Grapefruit</b><i>Kiwi</i></p>
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 0000000..190fc54
--- /dev/null
+++ b/test/formatting/samples/do-not-add-whitespace-between-inline-elements/output.html
@@ -0,0 +1,3 @@
+<p>
+    <b>Apples</b><i>Orange</i><b>Bananas</b><i>Pineapples</i><b>Grapefruit</b><i>Kiwi</i>
+</p>
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 0000000..fb6966d
--- /dev/null
+++ b/test/formatting/samples/inline-element-with-children-no-ws/input.html
@@ -0,0 +1,3 @@
+<b>Foo <Tag />
+
+  <Tag /></b>
\ 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 0000000..0223be8
--- /dev/null
+++ b/test/formatting/samples/inline-element-with-children-no-ws/output.html
@@ -0,0 +1,4 @@
+<b>Foo
+    <Tag />
+
+    <Tag /></b>
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 0000000..13b0fa7
--- /dev/null
+++ b/test/formatting/samples/inline-element-with-children-ws/input.html
@@ -0,0 +1,7 @@
+<b>
+  
+  Foo <Tag />
+
+  <Tag />
+
+</b>
\ 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 0000000..2e6a55c
--- /dev/null
+++ b/test/formatting/samples/inline-element-with-children-ws/output.html
@@ -0,0 +1,6 @@
+<b>
+    Foo
+    <Tag />
+
+    <Tag />
+</b>
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 0000000..873db7f
--- /dev/null
+++ b/test/formatting/samples/no-html-whitespace-inside-inline-element/input.html
@@ -0,0 +1,1 @@
+<p><b>Apples</b>, <b>Orange</b>, <b>Bananas</b>, <b>Pineapples</b>, <b>Grapefruit</b>, <b>Kiwi</b></p>
\ 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 0000000..e2e7671
--- /dev/null
+++ b/test/formatting/samples/no-html-whitespace-inside-inline-element/output.html
@@ -0,0 +1,4 @@
+<p>
+    <b>Apples</b>, <b>Orange</b>, <b>Bananas</b>, <b>Pineapples</b>, <b>Grapefruit</b>,
+    <b>Kiwi</b>
+</p>
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 0000000..2f8252e
--- /dev/null
+++ b/test/formatting/samples/no-html-whitespace-outside-inline-element/input.html
@@ -0,0 +1,1 @@
+<p>"<b>Bold</b> <i>Italic</i>"</p>
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 0000000..2f8252e
--- /dev/null
+++ b/test/formatting/samples/no-html-whitespace-outside-inline-element/output.html
@@ -0,0 +1,1 @@
+<p>"<b>Bold</b> <i>Italic</i>"</p>
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 0000000..407c4b5
--- /dev/null
+++ b/test/formatting/samples/whitespace-between-tags-and-text/input.html
@@ -0,0 +1,18 @@
+<div>
+
+    <Tag />
+    Text
+    <Tag />
+
+    Text
+
+    <Tag />
+
+
+    Text
+
+
+    <Tag />
+
+
+</div>
\ 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 0000000..967e8ab
--- /dev/null
+++ b/test/formatting/samples/whitespace-between-tags-and-text/output.html
@@ -0,0 +1,13 @@
+<div>
+    <Tag />
+    Text
+    <Tag />
+
+    Text
+
+    <Tag />
+
+    Text
+
+    <Tag />
+</div>
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 473d845..9ec7fd9 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 @@
-<a href="https://example.com" id="myLink" title="Go to this example website">
-    {linkText}
-</a>
+<a
+    href="https://example.com"
+    id="myLink"
+    title="Go to this example website">{linkText}</a>
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 473d845..9ec7fd9 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 @@
-<a href="https://example.com" id="myLink" title="Go to this example website">
-    {linkText}
-</a>
+<a
+    href="https://example.com"
+    id="myLink"
+    title="Go to this example website">{linkText}</a>
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 0000000..d09fdab
--- /dev/null
+++ b/test/printer/samples/if-block-without-whitespace.html
@@ -0,0 +1,3 @@
+{#each items as item}
+    <b>{item.name}</b>{#if item.isNew}*{/if}{#if item.isUpdated}!{/if}
+{/each}