diff --git a/changelog_unreleased/angular/15606.md b/changelog_unreleased/angular/15606.md
new file mode 100644
index 000000000..99469974f
--- /dev/null
+++ b/changelog_unreleased/angular/15606.md
@@ -0,0 +1,7 @@
+#### Support Angular control flow (#15606 by @DingWeizhe, @fisker)
+
+Added support for built-in control flow in Angular 17.
+
+For more details about control flow, you can check this article on the official blog.
+
+https://blog.angular.io/introducing-angular-v17-4d7033312e4b
diff --git a/src/language-html/clean.js b/src/language-html/clean.js
index 7734924e4..c095b4290 100644
--- 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
index e06426e8a..42e5d1947 100644
--- 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
index 000000000..821944648
--- /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
index e0ccf8649..fdfb0214d 100644
--- 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
index 000000000..6cdc57906
--- /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
index 000000000..b579143d3
--- /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
index 925809567..86f5a53c8 100644
--- 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
index ec59926f7..ee2f5c8a1 100644
--- 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
index 521da08b8..9fef1bb20 100644
--- 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
index 811f00ec1..df014d74f 100644
--- 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
);
}
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
index 000000000..d36ee42e8
--- /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======================================
+}
+
+=====================================output=====================================
+@if (true) {
+
+}
+
+================================================================================
+`;
+
+exports[`for.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+ | printWidth
+=====================================input======================================
+
The profile is unknown!
+} + +=====================================output===================================== +@if (user.isHuman) { +The profile is unknown!
+} + +================================================================================ +`; + +exports[`switch.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +@switch (radioValue()) { +@case (1) { +Case 1 +} +@case (2) { +Case 2 +} +@default { +Default case (Not 1 or 2) +} +} + + +@switch( + should +.be.formatted +) { + @case( + should +.be.formatted) {} +} + +=====================================output===================================== +@switch (radioValue()) { + @case (1) { + Case 1 + } + @case (2) { + Case 2 + } + @default { + Default case (Not 1 or 2) + } +} + +@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====================================== +The profile is unknown!
+} 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 index 000000000..4997d47ba --- /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 index 000000000..134176e7e --- /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 index 000000000..dffe53b37 --- /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 index 000000000..f3c2bcc3a --- /dev/null +++ b/tests/format/angular/control-flow/switch.html @@ -0,0 +1,21 @@ +@switch (radioValue()) { +@case (1) { +Case 1 +} +@case (2) { +Case 2 +} +@default { +Default case (Not 1 or 2) +} +} + + +@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 index 000000000..f25927cc8 --- /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 index 000000000..23bdd997c --- /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 index 000000000..0085029f7 --- /dev/null +++ b/tests/format/angular/control-flow/with-comment.html @@ -0,0 +1,17 @@ +