Repository: prettier/prettier (JavaScript)

Issue:
Support for the new Angular Control Flow Syntax
Angular is introducing a new control flow syntax in it's [RFC](https://github.com/angular/angular/discussions/50719).

I'm opening this issue in order to track the support for it in the prettier formatter.

Because the new sytanx follows the svelte syntax a lot, I believe we can learn/be-inspired/copy a lot from the svelte one. 
 


Hints:
Update: Angular choose to go with @-syntax for the control flow.

https://blog.angular.io/meet-angulars-new-control-flow-a02c6eee7843
Thanks for opening this.  With v17 right around the corner, I hope this gets some love in time.  
To provide some concrete dates: the Angular v17 release candidate will be mid-October, and v17 final is expected early November.
Thank you for sharing, I'll look into this.
Notes:

- Introduce AST fro control flow: https://github.com/angular/angular/pull/51299
- Control flow keywords are `defer`, `switch`, `for`, `if`

@sosukesuzuki cant wait to see it arrive thanks for taking your time and doing it 🎉 

> Notes:
> 
> * Introduce AST fro control flow: [Introduce AST for control flow angular/angular#51299](https://github.com/angular/angular/pull/51299)
> * Control flow keywords are `defer`, `switch`, `for`, `if`

The full list of keywords:
- Defer: `@defer`, `@placeholder`, `@loading`, `@error`
- Conditionals: `@if`, `@else`, `@else if`, `@switch`, `@case`, `@default`
- Loops: `@for`, `@empty`

The keywords are followed by optional parentheses, then curly braces, e.g.:
```
@if (foo) {
   <span></span>
} @else if (bar) {
   <div></div>
}
```
Let us know if you need anything from us. Thank you for your time and effort!
This looks like a big change needed to our HTML printer (maybe also parser?).
Latest angular template parser supports `tokenizeBlocks?` option( https://github.com/angular/angular/commit/29aaded0c3fbf669a95ea70928b4458579fbcc39 ). I think we need to enable that when `parser="angular"` for parsing blocks (`@if`, `@else`, `@defer`, etc...).

So, I plan that I would start by updating angular-html-parser to the latest version.
Can I confirm that this will ensure that control flow blocks will always have a newline between them and the enclosing code?

So this:
```
@if (foo) {
   <span></span>
} @else if (bar) {
   <div></div>
}
```

Never this:
```
@if (foo) { <span>
</span> }
```
now that Angular 17 has just release, any updates to this? Thanks in advance!

Patch:
diff --git a/src/language-html/clean.js b/src/language-html/clean.js
--- a/src/language-html/clean.js
+++ b/src/language-html/clean.js
@@ -12,6 +12,14 @@ const ignoredProperties = new Set([
   "valueTokens",
 ]);
 
+const embeddedAngularControlFlowBlocks = new Set([
+  "if",
+  "else if",
+  "for",
+  "switch",
+  "case",
+]);
+
 function clean(ast, newNode) {
   if (ast.type === "text" || ast.type === "comment") {
     return null;
@@ -29,6 +37,16 @@ function clean(ast, newNode) {
   if (ast.type === "docType") {
     delete newNode.value;
   }
+
+  if (ast.type === "block") {
+    for (const parameter of newNode.parameters) {
+      if (embeddedAngularControlFlowBlocks.has(ast.name)) {
+        delete parameter.expression;
+      } else {
+        parameter.expression = parameter.expression.trim();
+      }
+    }
+  }
 }
 
 clean.ignoredProperties = ignoredProperties;
diff --git a/src/language-html/embed.js b/src/language-html/embed.js
--- a/src/language-html/embed.js
+++ b/src/language-html/embed.js
@@ -23,6 +23,15 @@ import {
 import isVueSfcWithTypescriptScript from "./utils/is-vue-sfc-with-typescript-script.js";
 import getNodeContent from "./get-node-content.js";
 import printAttribute from "./embed/attribute.js";
+import printAngularControlFlowBlockParameters from "./embed/angular-control-flow-block-parameters.js";
+
+const embeddedAngularControlFlowBlocks = new Set([
+  "if",
+  "else if",
+  "for",
+  "switch",
+  "case",
+]);
 
 function embed(path, options) {
   const { node } = path;
@@ -135,6 +144,17 @@ function embed(path, options) {
 
     case "front-matter":
       return (textToDoc) => printFrontMatter(node, textToDoc);
+
+    // TODO: This should work on `blockParameter`, but currently we don't have a parser for it.
+    case "block":
+      if (
+        !embeddedAngularControlFlowBlocks.has(node.name) ||
+        node.parameters.length === 0
+      ) {
+        return;
+      }
+
+      return printAngularControlFlowBlockParameters;
   }
 }
 
diff --git a/src/language-html/embed/angular-control-flow-block-parameters.js b/src/language-html/embed/angular-control-flow-block-parameters.js
new file mode 100644
--- /dev/null
+++ b/src/language-html/embed/angular-control-flow-block-parameters.js
@@ -0,0 +1,27 @@
+import { formatAttributeValue, shouldHugJsExpression } from "./utils.js";
+
+async function printAngularControlFlowBlockParameters(
+  textToDoc,
+  print,
+  path,
+  /* options,*/
+) {
+  const { node } = path;
+  const code = node.parameters.map(({ expression }) => expression).join("; ");
+
+  const doc = await formatAttributeValue(
+    code,
+    textToDoc,
+    {
+      parser: "__ng_directive",
+      __isInHtmlAttribute: false,
+      trailingComma: "none",
+    },
+    shouldHugJsExpression,
+  );
+
+  // We have to put the doc on the node, since `node.parameters` is not a valid "node"
+  node.__embed_parameters_doc = doc;
+}
+
+export default printAngularControlFlowBlockParameters;
diff --git a/src/language-html/parser-html.js b/src/language-html/parser-html.js
--- a/src/language-html/parser-html.js
+++ b/src/language-html/parser-html.js
@@ -38,6 +38,13 @@ import { locStart, locEnd } from "./loc.js";
  * @typedef {{filepath?: string}} Options
  */
 
+// `@else    if`
+function normalizeAngularControlFlowBlockName(node) {
+  if (node.type === "block") {
+    node.name = node.name.toLowerCase().replaceAll(/\s+/g, " ").trim();
+  }
+}
+
 /**
  * @param {string} input
  * @param {ParseOptions} parseOptions
@@ -62,6 +69,7 @@ function ngHtmlParser(input, parseOptions, options) {
       ? (...args) =>
           shouldParseAsRawText(...args) ? TagContentType.RAW_TEXT : undefined
       : undefined,
+    tokenizeAngularBlocks: name === "angular" ? true : undefined,
   });
 
   if (name === "vue") {
@@ -236,6 +244,7 @@ function ngHtmlParser(input, parseOptions, options) {
         addTagDefinition(node);
         normalizeName(node);
         fixSourceSpan(node);
+        normalizeAngularControlFlowBlockName(node);
       }
     })(),
     rootNodes,
diff --git a/src/language-html/print/angular-control-flow-block-settings.evaluate.js b/src/language-html/print/angular-control-flow-block-settings.evaluate.js
new file mode 100644
--- /dev/null
+++ b/src/language-html/print/angular-control-flow-block-settings.evaluate.js
@@ -0,0 +1,65 @@
+const DEFAULT_ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS = {
+  isFollowingBlock: false,
+  followingBlocks: new Set(),
+};
+
+const ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS = new Map(
+  [
+    {
+      name: "if",
+      isFollowingBlock: false,
+      followingBlocks: ["else if", "else"],
+    },
+    {
+      name: "else if",
+      isFollowingBlock: true,
+      followingBlocks: ["else if", "else"],
+    },
+    { name: "else", isFollowingBlock: true, followingBlocks: [] },
+
+    { name: "switch", isFollowingBlock: false, followingBlocks: [] },
+    { name: "case", isFollowingBlock: false, followingBlocks: [] },
+    { name: "default", isFollowingBlock: false, followingBlocks: [] },
+
+    { name: "for", isFollowingBlock: false, followingBlocks: ["empty"] },
+    { name: "empty", isFollowingBlock: true, followingBlocks: [] },
+
+    {
+      name: "defer",
+      isFollowingBlock: false,
+      followingBlocks: ["placeholder", "error", "loading"],
+    },
+
+    {
+      name: "placeholder",
+      isFollowingBlock: true,
+      followingBlocks: ["placeholder", "error", "loading"],
+    },
+
+    {
+      name: "error",
+      isFollowingBlock: true,
+      followingBlocks: ["placeholder", "error", "loading"],
+    },
+
+    {
+      name: "loading",
+      isFollowingBlock: true,
+      followingBlocks: ["placeholder", "error", "loading"],
+    },
+  ].map((settings) => [
+    settings.name,
+    {
+      DEFAULT_ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS,
+      ...settings,
+      followingBlocks: settings.followingBlocks
+        ? new Set(settings.followingBlocks)
+        : DEFAULT_ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS.followingBlocks,
+    },
+  ]),
+);
+
+export default {
+  DEFAULT_ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS,
+  ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS,
+};
diff --git a/src/language-html/print/angular-control-flow-block.js b/src/language-html/print/angular-control-flow-block.js
new file mode 100644
--- /dev/null
+++ b/src/language-html/print/angular-control-flow-block.js
@@ -0,0 +1,84 @@
+import {
+  group,
+  hardline,
+  softline,
+  line,
+  indent,
+  join,
+} from "../../document/builders.js";
+import { printChildren } from "./children.js";
+import settings from "./angular-control-flow-block-settings.evaluate.js";
+
+const unClosedBlocks = new WeakSet();
+
+function printAngularControlFlowBlock(path, options, print) {
+  const { node } = path;
+  const setting =
+    settings.ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS.get(node.name) ??
+    settings.DEFAULT_ANGULAR_CONTROL_FLOW_BLOCK_SETTINGS;
+  const docs = [];
+
+  const previousBlock = findPreviousBlock(path);
+
+  if (setting.isFollowingBlock && unClosedBlocks.has(previousBlock)) {
+    docs.push("} ");
+  }
+
+  docs.push("@", node.name);
+
+  if (node.parameters.length > 0) {
+    const parametersDoc = group(
+      node.__embed_parameters_doc ?? [
+        indent([softline, join([";", line], path.map(print, "parameters"))]),
+        softline,
+      ],
+    );
+
+    docs.push(" (", parametersDoc, ")");
+  }
+
+  docs.push(" {");
+  const shouldPrintCloseBracket = shouldCloseBlock(
+    node,
+    setting.followingBlocks,
+  );
+
+  if (!shouldPrintCloseBracket) {
+    unClosedBlocks.add(node);
+  }
+
+  if (node.children.length > 0) {
+    node.firstChild.hasLeadingSpaces = true;
+    node.lastChild.hasTrailingSpaces = true;
+    docs.push(indent([hardline, printChildren(path, options, print)]));
+    if (shouldPrintCloseBracket) {
+      docs.push(hardline, "}");
+    }
+  } else if (shouldPrintCloseBracket) {
+    docs.push("}");
+  }
+
+  return group(docs, { shouldBreak: true });
+}
+
+function shouldCloseBlock(node, names) {
+  return !(
+    names.size > 0 &&
+    node.next?.type === "block" &&
+    names.has(node.next.name)
+  );
+}
+
+function findPreviousBlock(path) {
+  const { siblings, index } = path;
+
+  for (let i = index - 1; i >= 0; i--) {
+    const sibling = siblings[i];
+
+    if (sibling.type === "block") {
+      return sibling;
+    }
+  }
+}
+
+export { printAngularControlFlowBlock };
diff --git a/src/language-html/print/tag.js b/src/language-html/print/tag.js
--- a/src/language-html/print/tag.js
+++ b/src/language-html/print/tag.js
@@ -128,6 +128,7 @@ function needsToBorrowPrevClosingTagEndMarker(node) {
   return (
     node.prev &&
     node.prev.type !== "docType" &&
+    node.type !== "block" &&
     !isTextLikeNode(node.prev) &&
     node.isLeadingSpaceSensitive &&
     !node.hasLeadingSpaces
diff --git a/src/language-html/printer-html.js b/src/language-html/printer-html.js
--- a/src/language-html/printer-html.js
+++ b/src/language-html/printer-html.js
@@ -6,6 +6,7 @@ import { fill, group, hardline } from "../document/builders.js";
 import { cleanDoc, replaceEndOfLine } from "../document/utils.js";
 import UnexpectedNodeError from "../utils/unexpected-node-error.js";
 import getPreferredQuote from "../utils/get-preferred-quote.js";
+import htmlWhitespaceUtils from "../utils/html-whitespace-utils.js";
 import clean from "./clean.js";
 import { unescapeQuoteEntities, getTextValueParts } from "./utils/index.js";
 import preprocess from "./print-preprocess.js";
@@ -20,6 +21,7 @@ import {
 } from "./print/tag.js";
 import { printElement } from "./print/element.js";
 import { printChildren } from "./print/children.js";
+import { printAngularControlFlowBlock } from "./print/angular-control-flow-block.js";
 import getVisitorKeys from "./get-visitor-keys.js";
 
 function genericPrint(path, options, print) {
@@ -37,6 +39,11 @@ function genericPrint(path, options, print) {
     case "ieConditionalComment":
       return printElement(path, options, print);
 
+    case "block":
+      return printAngularControlFlowBlock(path, options, print);
+    case "blockParameter":
+      return htmlWhitespaceUtils.trim(node.expression);
+
     case "ieConditionalStartComment":
     case "ieConditionalEndComment":
       return [printOpeningTagStart(node), printClosingTagEnd(node)];
diff --git a/src/language-html/visitor-keys.js b/src/language-html/visitor-keys.js
--- a/src/language-html/visitor-keys.js
+++ b/src/language-html/visitor-keys.js
@@ -11,6 +11,8 @@ const visitorKeys = {
   comment: [],
   attribute: [],
   cdata: [],
+  block: ["children", "parameters"],
+  blockParameter: [],
 };
 
 export default visitorKeys;
diff --git a/src/language-js/print/angular.js b/src/language-js/print/angular.js
--- a/src/language-js/print/angular.js
+++ b/src/language-js/print/angular.js
@@ -62,7 +62,9 @@ function printAngular(path, options, print) {
       const shouldNotPrintColon =
         isNgForOf(path) ||
         (((index === 1 &&
-          (node.key.name === "then" || node.key.name === "else")) ||
+          (node.key.name === "then" ||
+            node.key.name === "else" ||
+            node.key.name === "as")) ||
           (index === 2 &&
             node.key.name === "else" &&
             parent.body[index - 1].type === "NGMicrosyntaxKeyedExpression" &&
@@ -88,13 +90,11 @@ function printAngular(path, options, print) {
   }
 }
 
-function isNgForOf({ node, index, parent }) {
+function isNgForOf({ node, index }) {
   return (
     node.type === "NGMicrosyntaxKeyedExpression" &&
     node.key.name === "of" &&
-    index === 1 &&
-    parent.body[0].type === "NGMicrosyntaxLet" &&
-    parent.body[0].value === null
+    index === 1
   );
 }
 


Test patch:
diff --git a/tests/format/angular/control-flow/__snapshots__/jsfmt.spec.js.snap b/tests/format/angular/control-flow/__snapshots__/jsfmt.spec.js.snap
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/__snapshots__/jsfmt.spec.js.snap
@@ -0,0 +1,551 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`after-close-tag.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+<ion-list class="timeline" appScrollItem>
+  @for (let item of items; index as i; trackBy: trackByFn) {
+    <app-tweet-item></app-tweet-item>@if (
+      areaId &&
+      canTweet
+    ) {
+      <ion-item class="recommend-trust-up" lines="full"></ion-item>
+    }
+  }
+</ion-list>
+
+<ng-container *ngIf="
+      areaId &&
+      canTweet
+">
+<ion-item class="recommend-trust-up" lines="full"></ion-item>
+</ng-container>
+
+
+@if (
+      areaId &&
+      canTweet
+    ) {
+      <ion-item class="recommend-trust-up" lines="full"></ion-item>
+    }
+
+=====================================output=====================================
+<ion-list class="timeline" appScrollItem>
+  @for (let item of items; index as i; trackBy: trackByFn) {
+    <app-tweet-item></app-tweet-item>
+    @if (areaId && canTweet) {
+      <ion-item class="recommend-trust-up" lines="full"></ion-item>
+    }
+  }
+</ion-list>
+
+<ng-container *ngIf="areaId && canTweet">
+  <ion-item class="recommend-trust-up" lines="full"></ion-item>
+</ng-container>
+
+@if (areaId && canTweet) {
+  <ion-item class="recommend-trust-up" lines="full"></ion-item>
+}
+
+================================================================================
+`;
+
+exports[`defer.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+@defer (when isCheckedDefer()) {
+<app-c1/>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+@error {
+<span>Error</span>
+}
+@loading(minimum 1s) {
+<span>Loading...</span>
+}
+
+
+@defer (on interaction) {
+<span>Clicked</span>
+}
+@placeholder {
+<span>Placeholder (click on it!)</span>
+}
+
+
+@defer (on hover) {
+<span>Hovered</span>
+}
+@placeholder {
+<span>Placeholder (hover it!)</span>
+}
+
+
+@defer (on idle) {
+<span>Browser has reached an idle state</span>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+
+
+@defer (on timer(5s)) {
+<span>Visible after 5s</span>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+
+
+@defer (on viewport) {
+<app-c2 text="The block entered the viewport"/>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+
+
+@defer (on interaction; prefetch on hover) {
+<app-c3/>
+}
+@placeholder {
+<span>Placeholder (hover it, then click on it!)</span>
+}
+
+
+@defer (on interaction; prefetch on hover) {
+<app-c3/>
+}
+@placeholder {
+<span>Placeholder (hover it, then click on it!)</span>
+}
+
+=====================================output=====================================
+@defer (when isCheckedDefer()) {
+  <app-c1 />
+} @placeholder {
+  <span>Placeholder</span>
+} @error {
+  <span>Error</span>
+} @loading (minimum 1s) {
+  <span>Loading...</span>
+}
+
+@defer (on interaction) {
+  <span>Clicked</span>
+} @placeholder {
+  <span>Placeholder (click on it!)</span>
+}
+
+@defer (on hover) {
+  <span>Hovered</span>
+} @placeholder {
+  <span>Placeholder (hover it!)</span>
+}
+
+@defer (on idle) {
+  <span>Browser has reached an idle state</span>
+} @placeholder {
+  <span>Placeholder</span>
+}
+
+@defer (on timer(5s)) {
+  <span>Visible after 5s</span>
+} @placeholder {
+  <span>Placeholder</span>
+}
+
+@defer (on viewport) {
+  <app-c2 text="The block entered the viewport" />
+} @placeholder {
+  <span>Placeholder</span>
+}
+
+@defer (on interaction; prefetch on hover) {
+  <app-c3 />
+} @placeholder {
+  <span>Placeholder (hover it, then click on it!)</span>
+}
+
+@defer (on interaction; prefetch on hover) {
+  <app-c3 />
+} @placeholder {
+  <span>Placeholder (hover it, then click on it!)</span>
+}
+
+================================================================================
+`;
+
+exports[`element-tags.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+@if (true) {<img />}
+
+=====================================output=====================================
+@if (true) {
+  <img />
+}
+
+================================================================================
+`;
+
+exports[`for.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+<ul>
+@for (
+            let
+
+            item of items;index as
+i;
+            trackBy: trackByFn
+) {
+<li><strong>{{item.name}}</strong></li>
+}
+@empty {
+<span>The collection is empty</span>
+}
+</ul>
+
+
+<li *ngFor="
+            let
+
+            item of items;index as
+i;
+            trackBy: trackByFn
+"></li>
+
+
+
+<ul>
+@for (item of collection; track item.id; let index = $index, first = $first; let last = $last, even = $even, odd = $odd; let count = $count) {
+
+<li><strong>{{item.name}}</strong> index={{index}} first={{first}} last={{last}} even={{even}} odd={{odd}} count={{count}}</li>
+
+}
+</ul>
+
+<ul>
+@for (item of
+ emptyCollection; track item.id;) {
+<li><strong>{{item.name}}</strong></li>
+}
+@empty {
+  <span>The collection is empty</span>
+}
+</ul>
+<li *ngFor="
+item of
+ emptyCollection; track item.id;
+"></li>
+
+=====================================output=====================================
+<ul>
+  @for (let item of items; index as i; trackBy: trackByFn) {
+    <li>
+      <strong>{{ item.name }}</strong>
+    </li>
+  } @empty {
+    <span>The collection is empty</span>
+  }
+</ul>
+
+<li *ngFor="let item of items; index as i; trackBy: trackByFn"></li>
+
+<ul>
+  @for (
+    item of collection;
+    track item.id;
+    let index = $index, first = $first;
+    let last = $last, even = $even, odd = $odd;
+    let count = $count
+  ) {
+    <li>
+      <strong>{{ item.name }}</strong> index={{ index }} first={{
+        first
+      }}
+      last={{ last }} even={{ even }} odd={{ odd }} count={{ count }}
+    </li>
+  }
+</ul>
+
+<ul>
+  @for (item of emptyCollection; track: item.id) {
+    <li>
+      <strong>{{ item.name }}</strong>
+    </li>
+  } @empty {
+    <span>The collection is empty</span>
+  }
+</ul>
+<li *ngFor="item of emptyCollection; track: item.id"></li>
+
+================================================================================
+`;
+
+exports[`if.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+<h3>&#64;if and &#64;else</h3>
+<div>
+<input #checkbox type="checkbox" [checked]="isChecked()" (change)="isChecked.set(checkbox.checked)" id="checkbox"/>
+</div>
+<div>
+@if (isChecked()) {
+<span>Checked</span>
+}
+@else {
+<span>Not checked</span>
+}
+</div>
+
+@if (users$   |   async;   as   users) { {{   users.length   }} }
+@else if (users$   |   async;   as   users) { {{   users.length   }} }
+
+<div *ngIf="users$   |   async;   as   users">{{   users.length   }}</div>
+
+@else {}
+
+
+@if (isDev) {}
+@else             if (test) {}
+
+@if      (foo(     "quotes")) {}
+@else if (foo(     'quotes')) {}
+
+@if ( widthCategory !== undefined && widthCategory >
+topStartToSideStartMaxSize) {
+<app-component />
+}
+
+@if ( widthCategory; as item) {
+}
+
+<div *ngIf="widthCategory; as item"></div>
+
+=====================================output=====================================
+<h3>&#64;if and &#64;else</h3>
+<div>
+  <input
+    #checkbox
+    type="checkbox"
+    [checked]="isChecked()"
+    (change)="isChecked.set(checkbox.checked)"
+    id="checkbox"
+  />
+</div>
+<div>
+  @if (isChecked()) {
+    <span>Checked</span>
+  } @else {
+    <span>Not checked</span>
+  }
+</div>
+
+@if (users$ | async; as users) {
+  {{ users.length }}
+} @else if (users$ | async; as users) {
+  {{ users.length }}
+}
+
+<div *ngIf="users$ | async; as users">{{ users.length }}</div>
+
+@else {}
+
+@if (isDev) {
+} @else if (test) {}
+
+@if (foo("quotes")) {
+} @else if (foo("quotes")) {}
+
+@if (
+  widthCategory !== undefined && widthCategory > topStartToSideStartMaxSize
+) {
+  <app-component />
+}
+
+@if (widthCategory; as item) {}
+
+<div *ngIf="widthCategory; as item"></div>
+
+================================================================================
+`;
+
+exports[`mix.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+@if (user.isHuman) {
+<human-profile [data]="user" />
+} @else if (user.isRobot) {
+<!-- robot users are rare, so load their profiles lazily -->
+@defer {
+<robot-profile [data]="user" />
+}
+} @else {
+<p>The profile is unknown!</p>
+}
+
+=====================================output=====================================
+@if (user.isHuman) {
+  <human-profile [data]="user" />
+} @else if (user.isRobot) {
+  <!-- robot users are rare, so load their profiles lazily -->
+  @defer {
+    <robot-profile [data]="user" />
+  }
+} @else {
+  <p>The profile is unknown!</p>
+}
+
+================================================================================
+`;
+
+exports[`switch.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+@switch (radioValue()) {
+@case (1) {
+<span>Case 1</span>
+}
+@case (2) {
+<span>Case 2</span>
+}
+@default {
+<span>Default case (Not 1 or 2)</span>
+}
+}
+
+
+@switch(
+  should
+.be.formatted
+) {
+  @case(
+  should
+.be.formatted) {}
+}
+
+=====================================output=====================================
+@switch (radioValue()) {
+  @case (1) {
+    <span>Case 1</span>
+  }
+  @case (2) {
+    <span>Case 2</span>
+  }
+  @default {
+    <span>Default case (Not 1 or 2)</span>
+  }
+}
+
+@switch (should.be.formatted) {
+  @case (should.be.formatted) {}
+}
+
+================================================================================
+`;
+
+exports[`unclosed.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+@empty{}
+
+@else {}
+
+=====================================output=====================================
+@empty {}
+
+@else {}
+
+================================================================================
+`;
+
+exports[`unknown-block.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+@unknown (foo) {
+} @unknown (foo) {
+}
+
+=====================================output=====================================
+@unknown (foo) {}
+@unknown (foo) {}
+
+================================================================================
+`;
+
+exports[`with-comment.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+<div>
+  <!-- comment -->
+  <span>Not checked</span>
+</div>
+
+
+<!-- comment -->
+@if (isChecked()) {
+<span>
+  Checked
+  <!-- comment -->
+</span>
+} <!-- comment -->
+@else {
+<span>Not checked</span>
+}
+<!-- comment -->
+
+=====================================output=====================================
+<div>
+  <!-- comment -->
+  <span>Not checked</span>
+</div>
+
+<!-- comment -->
+@if (isChecked()) {
+  <span>
+    Checked
+    <!-- comment -->
+  </span>
+}
+<!-- comment -->
+@else {
+  <span>Not checked</span>
+}
+<!-- comment -->
+
+================================================================================
+`;
diff --git a/tests/format/angular/control-flow/after-close-tag.html b/tests/format/angular/control-flow/after-close-tag.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/after-close-tag.html
@@ -0,0 +1,25 @@
+<ion-list class="timeline" appScrollItem>
+  @for (let item of items; index as i; trackBy: trackByFn) {
+    <app-tweet-item></app-tweet-item>@if (
+      areaId &&
+      canTweet
+    ) {
+      <ion-item class="recommend-trust-up" lines="full"></ion-item>
+    }
+  }
+</ion-list>
+
+<ng-container *ngIf="
+      areaId &&
+      canTweet
+">
+<ion-item class="recommend-trust-up" lines="full"></ion-item>
+</ng-container>
+
+
+@if (
+      areaId &&
+      canTweet
+    ) {
+      <ion-item class="recommend-trust-up" lines="full"></ion-item>
+    }
diff --git a/tests/format/angular/control-flow/defer.html b/tests/format/angular/control-flow/defer.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/defer.html
@@ -0,0 +1,68 @@
+@defer (when isCheckedDefer()) {
+<app-c1/>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+@error {
+<span>Error</span>
+}
+@loading(minimum 1s) {
+<span>Loading...</span>
+}
+
+
+@defer (on interaction) {
+<span>Clicked</span>
+}
+@placeholder {
+<span>Placeholder (click on it!)</span>
+}
+
+
+@defer (on hover) {
+<span>Hovered</span>
+}
+@placeholder {
+<span>Placeholder (hover it!)</span>
+}
+
+
+@defer (on idle) {
+<span>Browser has reached an idle state</span>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+
+
+@defer (on timer(5s)) {
+<span>Visible after 5s</span>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+
+
+@defer (on viewport) {
+<app-c2 text="The block entered the viewport"/>
+}
+@placeholder {
+<span>Placeholder</span>
+}
+
+
+@defer (on interaction; prefetch on hover) {
+<app-c3/>
+}
+@placeholder {
+<span>Placeholder (hover it, then click on it!)</span>
+}
+
+
+@defer (on interaction; prefetch on hover) {
+<app-c3/>
+}
+@placeholder {
+<span>Placeholder (hover it, then click on it!)</span>
+}
diff --git a/tests/format/angular/control-flow/element-tags.html b/tests/format/angular/control-flow/element-tags.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/element-tags.html
@@ -0,0 +1 @@
+@if (true) {<img />}
diff --git a/tests/format/angular/control-flow/embedded-language-formatting-off/__snapshots__/jsfmt.spec.js.snap b/tests/format/angular/control-flow/embedded-language-formatting-off/__snapshots__/jsfmt.spec.js.snap
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/embedded-language-formatting-off/__snapshots__/jsfmt.spec.js.snap
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`if.html - {"embeddedLanguageFormatting":"off"} format 1`] = `
+====================================options=====================================
+embeddedLanguageFormatting: "off"
+parsers: ["angular"]
+printWidth: 80
+                                                                                | printWidth
+=====================================input======================================
+@if      (should.   not.    format) {  }
+@else if (should.   not.    format) {  }
+
+=====================================output=====================================
+@if (should.   not.    format) {
+} @else if (should.   not.    format) {}
+
+================================================================================
+`;
diff --git a/tests/format/angular/control-flow/embedded-language-formatting-off/if.html b/tests/format/angular/control-flow/embedded-language-formatting-off/if.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/embedded-language-formatting-off/if.html
@@ -0,0 +1,2 @@
+@if      (should.   not.    format) {  }
+@else if (should.   not.    format) {  }
diff --git a/tests/format/angular/control-flow/embedded-language-formatting-off/jsfmt.spec.js b/tests/format/angular/control-flow/embedded-language-formatting-off/jsfmt.spec.js
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/embedded-language-formatting-off/jsfmt.spec.js
@@ -0,0 +1 @@
+run_spec(import.meta, ["angular"], { embeddedLanguageFormatting: "off" });
diff --git a/tests/format/angular/control-flow/for.html b/tests/format/angular/control-flow/for.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/for.html
@@ -0,0 +1,47 @@
+<ul>
+@for (
+            let
+
+            item of items;index as
+i;
+            trackBy: trackByFn
+) {
+<li><strong>{{item.name}}</strong></li>
+}
+@empty {
+<span>The collection is empty</span>
+}
+</ul>
+
+
+<li *ngFor="
+            let
+
+            item of items;index as
+i;
+            trackBy: trackByFn
+"></li>
+
+
+
+<ul>
+@for (item of collection; track item.id; let index = $index, first = $first; let last = $last, even = $even, odd = $odd; let count = $count) {
+
+<li><strong>{{item.name}}</strong> index={{index}} first={{first}} last={{last}} even={{even}} odd={{odd}} count={{count}}</li>
+
+}
+</ul>
+
+<ul>
+@for (item of
+ emptyCollection; track item.id;) {
+<li><strong>{{item.name}}</strong></li>
+}
+@empty {
+  <span>The collection is empty</span>
+}
+</ul>
+<li *ngFor="
+item of
+ emptyCollection; track item.id;
+"></li>
diff --git a/tests/format/angular/control-flow/if.html b/tests/format/angular/control-flow/if.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/if.html
@@ -0,0 +1,36 @@
+<h3>&#64;if and &#64;else</h3>
+<div>
+<input #checkbox type="checkbox" [checked]="isChecked()" (change)="isChecked.set(checkbox.checked)" id="checkbox"/>
+</div>
+<div>
+@if (isChecked()) {
+<span>Checked</span>
+}
+@else {
+<span>Not checked</span>
+}
+</div>
+
+@if (users$   |   async;   as   users) { {{   users.length   }} }
+@else if (users$   |   async;   as   users) { {{   users.length   }} }
+
+<div *ngIf="users$   |   async;   as   users">{{   users.length   }}</div>
+
+@else {}
+
+
+@if (isDev) {}
+@else             if (test) {}
+
+@if      (foo(     "quotes")) {}
+@else if (foo(     'quotes')) {}
+
+@if ( widthCategory !== undefined && widthCategory >
+topStartToSideStartMaxSize) {
+<app-component />
+}
+
+@if ( widthCategory; as item) {
+}
+
+<div *ngIf="widthCategory; as item"></div>
diff --git a/tests/format/angular/control-flow/jsfmt.spec.js b/tests/format/angular/control-flow/jsfmt.spec.js
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/jsfmt.spec.js
@@ -0,0 +1 @@
+run_spec(import.meta, ["angular"]);
diff --git a/tests/format/angular/control-flow/mix.html b/tests/format/angular/control-flow/mix.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/mix.html
@@ -0,0 +1,10 @@
+@if (user.isHuman) {
+<human-profile [data]="user" />
+} @else if (user.isRobot) {
+<!-- robot users are rare, so load their profiles lazily -->
+@defer {
+<robot-profile [data]="user" />
+}
+} @else {
+<p>The profile is unknown!</p>
+}
diff --git a/tests/format/angular/control-flow/small-print-width/__snapshots__/jsfmt.spec.js.snap b/tests/format/angular/control-flow/small-print-width/__snapshots__/jsfmt.spec.js.snap
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/small-print-width/__snapshots__/jsfmt.spec.js.snap
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`test.html - {"printWidth":20} format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 20
+                    | printWidth
+=====================================input======================================
+@unknown (long expression;long expression;long expression;long expression;long expression;long expression;long expression;long expression;) {
+
+}
+
+=====================================output=====================================
+@unknown (
+  long expression;
+  long expression;
+  long expression;
+  long expression;
+  long expression;
+  long expression;
+  long expression;
+  long expression
+) {}
+
+================================================================================
+`;
diff --git a/tests/format/angular/control-flow/small-print-width/jsfmt.spec.js b/tests/format/angular/control-flow/small-print-width/jsfmt.spec.js
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/small-print-width/jsfmt.spec.js
@@ -0,0 +1 @@
+run_spec(import.meta, ["angular"], { printWidth: 20 });
diff --git a/tests/format/angular/control-flow/small-print-width/test.html b/tests/format/angular/control-flow/small-print-width/test.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/small-print-width/test.html
@@ -0,0 +1,3 @@
+@unknown (long expression;long expression;long expression;long expression;long expression;long expression;long expression;long expression;) {
+
+}
diff --git a/tests/format/angular/control-flow/switch.html b/tests/format/angular/control-flow/switch.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/switch.html
@@ -0,0 +1,21 @@
+@switch (radioValue()) {
+@case (1) {
+<span>Case 1</span>
+}
+@case (2) {
+<span>Case 2</span>
+}
+@default {
+<span>Default case (Not 1 or 2)</span>
+}
+}
+
+
+@switch(
+  should
+.be.formatted
+) {
+  @case(
+  should
+.be.formatted) {}
+}
diff --git a/tests/format/angular/control-flow/unclosed.html b/tests/format/angular/control-flow/unclosed.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/unclosed.html
@@ -0,0 +1,3 @@
+@empty{}
+
+@else {}
diff --git a/tests/format/angular/control-flow/unknown-block.html b/tests/format/angular/control-flow/unknown-block.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/unknown-block.html
@@ -0,0 +1,3 @@
+@unknown (foo) {
+} @unknown (foo) {
+}
diff --git a/tests/format/angular/control-flow/with-comment.html b/tests/format/angular/control-flow/with-comment.html
new file mode 100644
--- /dev/null
+++ b/tests/format/angular/control-flow/with-comment.html
@@ -0,0 +1,17 @@
+<div>
+  <!-- comment -->
+  <span>Not checked</span>
+</div>
+
+
+<!-- comment -->
+@if (isChecked()) {
+<span>
+  Checked
+  <!-- comment -->
+</span>
+} <!-- comment -->
+@else {
+<span>Not checked</span>
+}
+<!-- comment -->
diff --git a/tests/integration/__tests__/parser-api.js b/tests/integration/__tests__/parser-api.js
--- a/tests/integration/__tests__/parser-api.js
+++ b/tests/integration/__tests__/parser-api.js
@@ -47,6 +47,7 @@ test("parsers should allow omit optional arguments", async () => {
   expect(typeof parsers[0][1].parse).toBe("function");
   const code = {
     graphql: "type A {hero: Character}",
+    angular: "<div></div>",
   };
   for (const [name, { parse }] of parsers) {
     await expect(
diff --git a/tests/unit/__snapshots__/visitor-keys.js.snap b/tests/unit/__snapshots__/visitor-keys.js.snap
--- a/tests/unit/__snapshots__/visitor-keys.js.snap
+++ b/tests/unit/__snapshots__/visitor-keys.js.snap
@@ -1390,6 +1390,11 @@ exports[`visitor keys graphql 1`] = `
 exports[`visitor keys html 1`] = `
 {
   "attribute": [],
+  "block": [
+    "children",
+    "parameters",
+  ],
+  "blockParameter": [],
   "cdata": [],
   "comment": [],
   "docType": [],