diff --git a/changelog_unreleased/angular/16474.md b/changelog_unreleased/angular/16474.md
new file mode 100644
index 000000000..f1eaf310e
--- /dev/null
+++ b/changelog_unreleased/angular/16474.md
@@ -0,0 +1,17 @@
+#### Support `@let` declaration syntax (#16474 by @sosukesuzuki)
+
+Adds support for Angular v18 `@let` declaration syntax.
+
+Please see the following code example. The `@let` declaration allows you to define local variables within the template:
+
+
+```html
+@let name = 'Frodo';
+
+
Dashboard for {{name}}
+Hello, {{name}}
+```
+
+For more details, please refer to the excellent blog post by the Angular Team: [Introducing @let in Angular](https://blog.angular.dev/introducing-let-in-angular-686f9f383f0f).
+
+We also appreciate the Angular Team for kindly answering our questions to implement this feature.
diff --git a/src/language-html/clean.js b/src/language-html/clean.js
index 0b8ac2e1f..2cee871eb 100644
--- a/src/language-html/clean.js
+++ b/src/language-html/clean.js
@@ -61,6 +61,10 @@ function clean(original, cloned) {
if (original.type === "angularIcuExpression") {
cloned.switchValue = original.switchValue.trim();
}
+
+ if (original.type === "angularLetDeclarationInitializer") {
+ delete cloned.value;
+ }
}
clean.ignoredProperties = ignoredProperties;
diff --git a/src/language-html/embed.js b/src/language-html/embed.js
index d713ce9ab..05d1e672c 100644
--- a/src/language-html/embed.js
+++ b/src/language-html/embed.js
@@ -8,6 +8,7 @@ import {
import printFrontMatter from "../utils/front-matter/print.js";
import printAngularControlFlowBlockParameters from "./embed/angular-control-flow-block-parameters.js";
import printAttribute from "./embed/attribute.js";
+import { formatAttributeValue } from "./embed/utils.js";
import getNodeContent from "./get-node-content.js";
import {
needsToBorrowPrevClosingTagEndMarker,
@@ -148,6 +149,13 @@ function embed(path, options) {
}
return printAngularControlFlowBlockParameters;
+
+ case "angularLetDeclarationInitializer":
+ return (textToDoc) =>
+ formatAttributeValue(node.value, textToDoc, {
+ parser: "__ng_binding",
+ __isInHtmlAttribute: false,
+ });
}
}
diff --git a/src/language-html/parser-html.js b/src/language-html/parser-html.js
index 4ab9bf158..464ece5d8 100644
--- a/src/language-html/parser-html.js
+++ b/src/language-html/parser-html.js
@@ -68,6 +68,24 @@ function normalizeAngularControlFlowBlock(node) {
};
}
+function normalizeAngularLetDeclaration(node) {
+ if (node.type !== "letDeclaration") {
+ return;
+ }
+
+ // Similar to `VariableDeclarator` in estree
+ node.type = "angularLetDeclaration";
+ node.id = node.name;
+ node.init = {
+ type: "angularLetDeclarationInitializer",
+ sourceSpan: new ParseSourceSpan(node.valueSpan.start, node.valueSpan.end),
+ value: node.value,
+ };
+
+ delete node.name;
+ delete node.value;
+}
+
function normalizeAngularIcuExpression(node) {
if (node.type === "plural" || node.type === "select") {
node.clause = node.type;
@@ -103,6 +121,7 @@ function ngHtmlParser(input, parseOptions, options) {
shouldParseAsRawText(...args) ? TagContentType.RAW_TEXT : undefined
: undefined,
tokenizeAngularBlocks: name === "angular" ? true : undefined,
+ tokenizeAngularLetDeclaration: name === "angular" ? true : undefined,
});
if (name === "vue") {
@@ -395,6 +414,7 @@ function parse(
}
normalizeAngularControlFlowBlock(node);
+ normalizeAngularLetDeclaration(node);
normalizeAngularIcuExpression(node);
});
diff --git a/src/language-html/printer-html.js b/src/language-html/printer-html.js
index 84e1e76e7..e3cf238a7 100644
--- a/src/language-html/printer-html.js
+++ b/src/language-html/printer-html.js
@@ -2,7 +2,7 @@
* @typedef {import("../document/builders.js").Doc} Doc
*/
-import { fill, group, hardline } from "../document/builders.js";
+import { fill, group, hardline, indent, line } from "../document/builders.js";
import { cleanDoc, replaceEndOfLine } from "../document/utils.js";
import getPreferredQuote from "../utils/get-preferred-quote.js";
import htmlWhitespaceUtils from "../utils/html-whitespace-utils.js";
@@ -53,6 +53,18 @@ function genericPrint(path, options, print) {
case "angularControlFlowBlockParameter":
return htmlWhitespaceUtils.trim(node.expression);
+ case "angularLetDeclaration":
+ // print like "break-after-operator" layout assignment in estree printer
+ return group([
+ "@let ",
+ group([node.id, " =", group(indent([line, print("init")]))]),
+ // semicolon is required
+ ";",
+ ]);
+ case "angularLetDeclarationInitializer":
+ // basically printed via embedded formatting
+ return node.value;
+
case "angularIcuExpression":
return printAngularIcuExpression(path, options, print);
case "angularIcuCase":
diff --git a/src/language-html/visitor-keys.js b/src/language-html/visitor-keys.js
index 6b7ebea48..eabe9e62e 100644
--- a/src/language-html/visitor-keys.js
+++ b/src/language-html/visitor-keys.js
@@ -14,6 +14,8 @@ const visitorKeys = {
angularControlFlowBlock: ["children", "parameters"],
angularControlFlowBlockParameters: ["children"],
angularControlFlowBlockParameter: [],
+ angularLetDeclaration: ["init"],
+ angularLetDeclarationInitializer: [],
angularIcuExpression: ["cases"],
angularIcuCase: ["expression"],
};
diff --git a/tests/format/angular/let-declaration/__snapshots__/format.test.js.snap b/tests/format/angular/let-declaration/__snapshots__/format.test.js.snap
new file mode 100644
index 000000000..b57cd22a6
--- /dev/null
+++ b/tests/format/angular/let-declaration/__snapshots__/format.test.js.snap
@@ -0,0 +1,280 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`basic-01.html format 1`] = `
+====================================options=====================================
+parsers: ["angular"]
+printWidth: 80
+ | printWidth
+=====================================input======================================
+@let name = 'Frodo';
+
Dashboard for {{name}}
+Hello, {{name}}
+
+=====================================output=====================================
+@let name = "Frodo";
+