From 5ca2fc9be8369bf8c96899e2e5e8f3264086645a Mon Sep 17 00:00:00 2001 From: SUZUKI Sosuke Date: Sat, 13 Jul 2024 18:24:57 +0900 Subject: [PATCH] Support Angular `@let` declaration syntax (#16474) gmail.com> --- changelog_unreleased/angular/16474.md | 17 ++ src/language-html/clean.js | 4 + src/language-html/embed.js | 8 + src/language-html/parser-html.js | 20 ++ src/language-html/printer-html.js | 14 +- src/language-html/visitor-keys.js | 2 + .../__snapshots__/format.test.js.snap | 280 ++++++++++++++++++ .../angular/let-declaration/basic-01.html | 3 + .../angular/let-declaration/basic-02.html | 5 + .../angular/let-declaration/basic-03.html | 15 + .../angular/let-declaration/complex.html | 39 +++ .../angular/let-declaration/format.test.js | 1 + .../__snapshots__/format.test.js.snap | 29 ++ .../minor-options/format.test.js | 19 ++ .../valid-from-tokenizer-test.html | 49 +++ tests/unit/__snapshots__/visitor-keys.js.snap | 4 + 16 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 changelog_unreleased/angular/16474.md create mode 100644 tests/format/angular/let-declaration/__snapshots__/format.test.js.snap create mode 100644 tests/format/angular/let-declaration/basic-01.html create mode 100644 tests/format/angular/let-declaration/basic-02.html create mode 100644 tests/format/angular/let-declaration/basic-03.html create mode 100644 tests/format/angular/let-declaration/complex.html create mode 100644 tests/format/angular/let-declaration/format.test.js create mode 100644 tests/format/angular/let-declaration/minor-options/__snapshots__/format.test.js.snap create mode 100644 tests/format/angular/let-declaration/minor-options/format.test.js create mode 100644 tests/format/angular/let-declaration/valid-from-tokenizer-test.html 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"; +

Dashboard for {{ name }}

+Hello, {{ name }} + +================================================================================ +`; + +exports[`basic-02.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== + + +@let greeting = 'Hello ' + name.value; + +@let user = user$ | async; + +=====================================output===================================== + + +@let greeting = "Hello " + name.value; + +@let user = user$ | async; + +================================================================================ +`; + +exports[`basic-03.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +@let topLevel = value; + +@if (condition) { + @let nested = value; +} + +
+ @let nestedNgIf = value; +
+ +{{topLevel}} + +{{nested}} + +{{nestedNgIf}} + +=====================================output===================================== +@let topLevel = value; + +@if (condition) { + @let nested = value; +} + +
+ @let nestedNgIf = value; +
+ +{{ topLevel }} + +{{ nested }} + +{{ nestedNgIf }} + +================================================================================ +`; + +exports[`complex.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +@let foo = a | b : c:d :e; +@let foo = a | pipe; +@let foo = 0 - 1; +@let foo = - 1; +@let foo = a ? 1 : 2; +@let foo = a ( 1 ) ( 2 ); +@let foo = a [ b ]; +@let foo = [ 1 ]; +@let foo = { 'a' : 1 }; +@let foo = { a : 1 }; +@let foo = true; +@let foo = undefined; +@let foo = null; +@let foo = ( 1 ); +@let foo = 1; +@let foo = 'hello'; +@let foo = a ( 1 , 2 ); +@let foo = a . b ( 1 , 2 ); +@let foo = x !; +@let foo = ! x; +@let foo = ( ( a ) ); +@let foo = a; +@let foo = a; // hello +@let foo = a . b; +@let foo = a ?. b ( ); +@let foo = a ?. b; +@let foo = a; // hello +@let foo = "SearchSelection.transmissionLayoutRadioButton" | localize:localizationSection; +@let foo = copyTypes[options.copyType]; +@let foo = listRow.NextScheduledSendStatus == 1 || listRow.NextScheduledSendStatus == 2 || listRow.NextScheduledSendStatus == 3; +@let foo = a; +@let foo = b; +@let foo = aNormalValue | aPipe; +@let foo = aReallyReallySuperLongValue | andASuperLongPipeJustToBreakThis; +@let foo = 'delete' | translate: {what: ('entities' | translate: {count: array.length})}; +@let foo = {a:1+{} }; +@let foo = {a:a==={} }; +@let foo = {a:!{} }; +@let foo = {a:a?b:{} }; + +=====================================output===================================== +@let foo = a | b: c : d : e; +@let foo = a | pipe; +@let foo = 0 - 1; +@let foo = -1; +@let foo = a ? 1 : 2; +@let foo = a(1)(2); +@let foo = a[b]; +@let foo = [1]; +@let foo = { a: 1 }; +@let foo = { a: 1 }; +@let foo = true; +@let foo = undefined; +@let foo = null; +@let foo = 1; +@let foo = 1; +@let foo = "hello"; +@let foo = a(1, 2); +@let foo = a.b(1, 2); +@let foo = x!; +@let foo = !x; +@let foo = a; +@let foo = a; +@let foo = a; // hello +@let foo = a.b; +@let foo = a?.b(); +@let foo = a?.b; +@let foo = a; // hello +@let foo = + "SearchSelection.transmissionLayoutRadioButton" + | localize: localizationSection; +@let foo = copyTypes[options.copyType]; +@let foo = + listRow.NextScheduledSendStatus == 1 || + listRow.NextScheduledSendStatus == 2 || + listRow.NextScheduledSendStatus == 3; +@let foo = a; +@let foo = b; +@let foo = aNormalValue | aPipe; +@let foo = aReallyReallySuperLongValue | andASuperLongPipeJustToBreakThis; +@let foo = + "delete" + | translate: { what: ("entities" | translate: { count: array.length }) }; +@let foo = { a: 1 + {} }; +@let foo = { a: a === {} }; +@let foo = { a: !{} }; +@let foo = { a: a ? b : {} }; + +================================================================================ +`; + +exports[`valid-from-tokenizer-test.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== + + +@let foo = 123 + 456; + +@let foo = 123 + 456; +@let foo=123 + 456; +@let foo =123 + 456; +@let foo= 123 + 456; + +@let +foo = 123; +@let +foo = 123; +@let + foo = 123; +@let foo += 123; +@let foo + = 123; +@let foo + = 123; +@let + foo + = 123; + +@let foo = +123 + + 456 + + 789 + ; + +@block {@let foo = 123 + 456;} + +@let foo = 'a; b'; +@let foo = "';'"; + +@let foo = '\\';\\'' + "\\","; + +@let foo = fn(a, b) + fn2(c, d, e); + +@let foo = [1, 2, 3]; + +@let foo = [0, [foo[1]], 3]; + +@let foo = {a: 1, b: {c: something + 2}}; +@let foo = {}; +@let foo = {foo: ";"}; + +@let foo = fn({a: 1, b: [otherFn([{c: ";"}], 321, {d: [',']})]}); + +=====================================output===================================== + + +@let foo = 123 + 456; + +@let foo = 123 + 456; +@let foo = 123 + 456; +@let foo = 123 + 456; +@let foo = 123 + 456; + +@let foo = 123; +@let foo = 123; +@let foo = 123; +@let foo = 123; +@let foo = 123; +@let foo = 123; +@let foo = 123; + +@let foo = 123 + 456 + 789; + +@block { + @let foo = 123 + 456; +} + +@let foo = "a; b"; +@let foo = "';'"; + +@let foo = "';'" + '",'; + +@let foo = fn(a, b) + fn2(c, d, e); + +@let foo = [1, 2, 3]; + +@let foo = [0, [foo[1]], 3]; + +@let foo = { a: 1, b: { c: something + 2 } }; +@let foo = {}; +@let foo = { foo: ";" }; + +@let foo = fn({ a: 1, b: [otherFn([{ c: ";" }], 321, { d: [","] })] }); + +================================================================================ +`; diff --git a/tests/format/angular/let-declaration/basic-01.html b/tests/format/angular/let-declaration/basic-01.html new file mode 100644 index 000000000..fda2d9186 --- /dev/null +++ b/tests/format/angular/let-declaration/basic-01.html @@ -0,0 +1,3 @@ +@let name = 'Frodo'; +

Dashboard for {{name}}

+Hello, {{name}} diff --git a/tests/format/angular/let-declaration/basic-02.html b/tests/format/angular/let-declaration/basic-02.html new file mode 100644 index 000000000..44aed5497 --- /dev/null +++ b/tests/format/angular/let-declaration/basic-02.html @@ -0,0 +1,5 @@ + + +@let greeting = 'Hello ' + name.value; + +@let user = user$ | async; diff --git a/tests/format/angular/let-declaration/basic-03.html b/tests/format/angular/let-declaration/basic-03.html new file mode 100644 index 000000000..763884ded --- /dev/null +++ b/tests/format/angular/let-declaration/basic-03.html @@ -0,0 +1,15 @@ +@let topLevel = value; + +@if (condition) { + @let nested = value; +} + +
+ @let nestedNgIf = value; +
+ +{{topLevel}} + +{{nested}} + +{{nestedNgIf}} diff --git a/tests/format/angular/let-declaration/complex.html b/tests/format/angular/let-declaration/complex.html new file mode 100644 index 000000000..77b7bf62f --- /dev/null +++ b/tests/format/angular/let-declaration/complex.html @@ -0,0 +1,39 @@ +@let foo = a | b : c:d :e; +@let foo = a | pipe; +@let foo = 0 - 1; +@let foo = - 1; +@let foo = a ? 1 : 2; +@let foo = a ( 1 ) ( 2 ); +@let foo = a [ b ]; +@let foo = [ 1 ]; +@let foo = { 'a' : 1 }; +@let foo = { a : 1 }; +@let foo = true; +@let foo = undefined; +@let foo = null; +@let foo = ( 1 ); +@let foo = 1; +@let foo = 'hello'; +@let foo = a ( 1 , 2 ); +@let foo = a . b ( 1 , 2 ); +@let foo = x !; +@let foo = ! x; +@let foo = ( ( a ) ); +@let foo = a; +@let foo = a; // hello +@let foo = a . b; +@let foo = a ?. b ( ); +@let foo = a ?. b; +@let foo = a; // hello +@let foo = "SearchSelection.transmissionLayoutRadioButton" | localize:localizationSection; +@let foo = copyTypes[options.copyType]; +@let foo = listRow.NextScheduledSendStatus == 1 || listRow.NextScheduledSendStatus == 2 || listRow.NextScheduledSendStatus == 3; +@let foo = a; +@let foo = b; +@let foo = aNormalValue | aPipe; +@let foo = aReallyReallySuperLongValue | andASuperLongPipeJustToBreakThis; +@let foo = 'delete' | translate: {what: ('entities' | translate: {count: array.length})}; +@let foo = {a:1+{} }; +@let foo = {a:a==={} }; +@let foo = {a:!{} }; +@let foo = {a:a?b:{} }; diff --git a/tests/format/angular/let-declaration/format.test.js b/tests/format/angular/let-declaration/format.test.js new file mode 100644 index 000000000..8cbc48b1a --- /dev/null +++ b/tests/format/angular/let-declaration/format.test.js @@ -0,0 +1 @@ +runFormatTest(import.meta, ["angular"]); diff --git a/tests/format/angular/let-declaration/minor-options/__snapshots__/format.test.js.snap b/tests/format/angular/let-declaration/minor-options/__snapshots__/format.test.js.snap new file mode 100644 index 000000000..73d489e95 --- /dev/null +++ b/tests/format/angular/let-declaration/minor-options/__snapshots__/format.test.js.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`snippet: #0 - {"embeddedLanguageFormatting":"off"} format 1`] = ` +====================================options===================================== +embeddedLanguageFormatting: "off" +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +@let foo = 'Hello' + ', World'; +=====================================output===================================== +@let foo = 'Hello' + ', World'; + +================================================================================ +`; + +exports[`snippet: #0 - {"semi":false} format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 +semi: false + | printWidth +=====================================input====================================== +@let foo = 'Hello' + ', World'; +=====================================output===================================== +@let foo = "Hello" + ", World"; + +================================================================================ +`; diff --git a/tests/format/angular/let-declaration/minor-options/format.test.js b/tests/format/angular/let-declaration/minor-options/format.test.js new file mode 100644 index 000000000..e5f35ba2c --- /dev/null +++ b/tests/format/angular/let-declaration/minor-options/format.test.js @@ -0,0 +1,19 @@ +const snippets = ["@let foo = 'Hello' + ', World'; "]; + +runFormatTest( + { + importMeta: import.meta, + snippets, + }, + ["angular"], + { embeddedLanguageFormatting: "off" }, +); + +runFormatTest( + { + importMeta: import.meta, + snippets, + }, + ["angular"], + { semi: false }, +); diff --git a/tests/format/angular/let-declaration/valid-from-tokenizer-test.html b/tests/format/angular/let-declaration/valid-from-tokenizer-test.html new file mode 100644 index 000000000..ab7211158 --- /dev/null +++ b/tests/format/angular/let-declaration/valid-from-tokenizer-test.html @@ -0,0 +1,49 @@ + + +@let foo = 123 + 456; + +@let foo = 123 + 456; +@let foo=123 + 456; +@let foo =123 + 456; +@let foo= 123 + 456; + +@let +foo = 123; +@let +foo = 123; +@let + foo = 123; +@let foo += 123; +@let foo + = 123; +@let foo + = 123; +@let + foo + = 123; + +@let foo = +123 + + 456 + + 789 + ; + +@block {@let foo = 123 + 456;} + +@let foo = 'a; b'; +@let foo = "';'"; + +@let foo = '\';\'' + "\","; + +@let foo = fn(a, b) + fn2(c, d, e); + +@let foo = [1, 2, 3]; + +@let foo = [0, [foo[1]], 3]; + +@let foo = {a: 1, b: {c: something + 2}}; +@let foo = {}; +@let foo = {foo: ";"}; + +@let foo = fn({a: 1, b: [otherFn([{c: ";"}], 321, {d: [',']})]}); diff --git a/tests/unit/__snapshots__/visitor-keys.js.snap b/tests/unit/__snapshots__/visitor-keys.js.snap index fee7bd210..d944bc7ca 100644 --- a/tests/unit/__snapshots__/visitor-keys.js.snap +++ b/tests/unit/__snapshots__/visitor-keys.js.snap @@ -1427,6 +1427,10 @@ exports[`visitor keys html 1`] = ` "angularIcuExpression": [ "cases", ], + "angularLetDeclaration": [ + "init", + ], + "angularLetDeclarationInitializer": [], "attribute": [], "cdata": [], "comment": [],