diff --git a/changelog_unreleased/angular/15777.md b/changelog_unreleased/angular/15777.md new file mode 100644 index 000000000..5e71f974f --- /dev/null +++ b/changelog_unreleased/angular/15777.md @@ -0,0 +1,19 @@ +#### Support formatting for Angular ICU expression (#15777 by @sosukesuzuki) + +Support two kinds of Angular ICU expressions: [`plural`](https://angular.dev/guide/i18n/translation-files#translate-plurals) and [`select`](https://angular.dev/guide/i18n/translation-files#translate-alternate-expressions). + + +```html + + Updated: + {minutes, plural, + =0 {just now} + =1 {one minute ago} + other {{{minutes}} minutes ago} + } + + + + The author is {gender, select, male {male} female {female} other {other}} + +``` diff --git a/package.json b/package.json index 931307ffe..9adc64e7a 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "acorn": "8.11.2", "acorn-jsx": "5.3.2", "angular-estree-parser": "9.0.0", - "angular-html-parser": "5.0.2", + "angular-html-parser": "5.2.0", "camelcase": "8.0.0", "chalk": "5.3.0", "ci-info": "4.0.0", diff --git a/src/language-html/ast.js b/src/language-html/ast.js index ca9814174..bdcaf18b3 100644 --- a/src/language-html/ast.js +++ b/src/language-html/ast.js @@ -1,6 +1,8 @@ const NODES_KEYS = { attrs: true, children: true, + cases: true, // plural and select + expression: true, // for expansionCase }; const NON_ENUMERABLE_PROPERTIES = new Set(["parent"]); @@ -136,12 +138,12 @@ class Node { get prev() { // @ts-expect-error - return this.parent?.children[this.parent.children.indexOf(this) - 1]; + return this.parent?.children?.[this.parent.children.indexOf(this) - 1]; } get next() { // @ts-expect-error - return this.parent?.children[this.parent.children.indexOf(this) + 1]; + return this.parent?.children?.[this.parent.children.indexOf(this) + 1]; } // for element and attribute diff --git a/src/language-html/clean.js b/src/language-html/clean.js index c12531dde..50b8ed3b1 100644 --- a/src/language-html/clean.js +++ b/src/language-html/clean.js @@ -10,6 +10,9 @@ const ignoredProperties = new Set([ "tagDefinition", "tokens", "valueTokens", + "switchValueSourceSpan", + "expSourceSpan", + "valueSourceSpan", ]); const embeddedAngularControlFlowBlocks = new Set([ @@ -47,6 +50,10 @@ function clean(ast, newNode) { } } } + + if (ast.type === "angularIcuExpression") { + newNode.switchValue = ast.switchValue.trim(); + } } clean.ignoredProperties = ignoredProperties; diff --git a/src/language-html/parser-html.js b/src/language-html/parser-html.js index 96e75556a..fa1083fa3 100644 --- a/src/language-html/parser-html.js +++ b/src/language-html/parser-html.js @@ -67,6 +67,16 @@ function normalizeAngularControlFlowBlock(node) { }; } +function normalizeAngularIcuExpression(node) { + if (node.type === "plural" || node.type === "select") { + node.clause = node.type; + node.type = "angularIcuExpression"; + } + if (node.type === "expansionCase") { + node.type = "angularIcuCase"; + } +} + /** * @param {string} input * @param {ParseOptions} parseOptions @@ -261,6 +271,16 @@ function ngHtmlParser(input, parseOptions, options) { visitAll( new (class extends RecursiveVisitor { + // Angular does not visit to the children of expansionCase + // https://github.com/angular/angular/blob/e3a6bf9b6c3bef03df9bfc8f05b817bc875cbad6/packages/compiler/src/ml_parser/ast.ts#L161 + visitExpansionCase(ast, context) { + if (name === "angular") { + // @ts-expect-error + this.visitChildren(context, (visit) => { + visit(ast.expression); + }); + } + } visit(node) { restoreNameAndValue(node); addTagDefinition(node); @@ -374,6 +394,7 @@ function parse( } normalizeAngularControlFlowBlock(node); + normalizeAngularIcuExpression(node); }); return ast; diff --git a/src/language-html/print/angular-icu-expression.js b/src/language-html/print/angular-icu-expression.js new file mode 100644 index 000000000..acac9e155 --- /dev/null +++ b/src/language-html/print/angular-icu-expression.js @@ -0,0 +1,62 @@ +import { + softline, + line, + indent, + join, + group, +} from "../../document/builders.js"; +import htmlWhitespaceUtils from "../../utils/html-whitespace-utils.js"; +import { printClosingTagEnd, printOpeningTagStart } from "./tag.js"; + +/* + + Updated {minutes, plural, + =0 {just now} + =1 {one minute ago} + other {{{minutes}} minutes ago} + } + +*/ + +function printAngularIcuExpression(path, options, print) { + const { node } = path; + return [ + printOpeningTagStart(node, options), + group([ + node.switchValue.trim(), + ", ", + node.clause, + node.cases.length > 0 + ? [",", indent([line, join(line, path.map(print, "cases"))])] + : "", + softline, + ]), + printClosingTagEnd(node, options), + ]; +} + +function printAngularIcuCase(path, options, print) { + const { node } = path; + return [ + node.value, + " {", + group([ + indent([ + softline, + path.map(({ node }) => { + // FIXME: We need a fundamental solution. + // Like this: https://github.com/prettier/prettier/blob/c1ebae2e8303841e59bc02e9e719a93e964ab0e9/src/language-html/print-preprocess.js#L254 + if (node.type === "text" && !htmlWhitespaceUtils.trim(node.value)) { + return ""; + } + + return print(); + }, "expression"), + ]), + softline, + ]), + "}", + ]; +} + +export { printAngularIcuExpression, printAngularIcuCase }; diff --git a/src/language-html/print/element.js b/src/language-html/print/element.js index 7dff2937c..ed5c3a909 100644 --- a/src/language-html/print/element.js +++ b/src/language-html/print/element.js @@ -60,7 +60,8 @@ function printElement(path, options, print) { */ const shouldHugContent = node.children.length === 1 && - node.firstChild.type === "interpolation" && + (node.firstChild.type === "interpolation" || + node.firstChild.type === "angularIcuExpression") && node.firstChild.isLeadingSpaceSensitive && !node.firstChild.hasLeadingSpaces && node.lastChild.isTrailingSpaceSensitive && diff --git a/src/language-html/print/tag.js b/src/language-html/print/tag.js index 72119b2fa..f49ed555d 100644 --- a/src/language-html/print/tag.js +++ b/src/language-html/print/tag.js @@ -97,6 +97,8 @@ function printClosingTagEndMarker(node, options) { return "]>"; case "interpolation": return "}}"; + case "angularIcuExpression": + return "}"; case "element": if (node.isSelfClosing) { return "/>"; @@ -345,6 +347,8 @@ function printOpeningTagStartMarker(node) { return "{{"; case "docType": return node.value === "html" ? "<${node.rawName}`; diff --git a/src/language-html/printer-html.js b/src/language-html/printer-html.js index 1adabfdc0..702455b44 100644 --- a/src/language-html/printer-html.js +++ b/src/language-html/printer-html.js @@ -26,6 +26,10 @@ import { printAngularControlFlowBlockParameters, } from "./print/angular-control-flow-block.js"; import getVisitorKeys from "./get-visitor-keys.js"; +import { + printAngularIcuExpression, + printAngularIcuCase, +} from "./print/angular-icu-expression.js"; function genericPrint(path, options, print) { const { node } = path; @@ -49,6 +53,11 @@ function genericPrint(path, options, print) { case "angularControlFlowBlockParameter": return htmlWhitespaceUtils.trim(node.expression); + case "angularIcuExpression": + return printAngularIcuExpression(path, options, print); + case "angularIcuCase": + return printAngularIcuCase(path, options, print); + 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 1952fcf5e..6b7ebea48 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: [], + angularIcuExpression: ["cases"], + angularIcuCase: ["expression"], }; export default visitorKeys; diff --git a/tests/format/angular/icu-expression/__snapshots__/jsfmt.spec.js.snap b/tests/format/angular/icu-expression/__snapshots__/jsfmt.spec.js.snap new file mode 100644 index 000000000..b80c3ad4e --- /dev/null +++ b/tests/format/angular/icu-expression/__snapshots__/jsfmt.spec.js.snap @@ -0,0 +1,198 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`basic-plural-01.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +Updated {minutes, plural, =0 {just now} =1 {one minute ago} other {{{minutes}} minutes ago}} + +=====================================output===================================== +Updated + {minutes, plural, + =0 {just now} + =1 {one minute ago} + other {{{minutes}} minutes ago} + +} + +================================================================================ +`; + +exports[`basic-plural-02.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +{ count, plural, + =0 {{ sex, select, + other {

deeply nested

} + }} + =other {a lot} +} + +{ count123, plural, + =0 {{ sex, select, + other {

deeply nested

} + }} + =other {a lot} +} + +=====================================output===================================== +{count, plural, =0 {{sex, select, other {

deeply nested

}}} =other {a lot}} + +{count123, plural, + =0 {{sex, select, other {

deeply nested

}}} + =other {a lot} +} + +================================================================================ +`; + +exports[`basic-plural-03.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== + + {VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {beaucoup} } + {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {many} } + + +=====================================output===================================== + + {VAR_PLURAL, plural, + =0 {zero} + =1 {un} + =2 {deux} + other { + beaucoup + } + } + {VAR_PLURAL, plural, + =0 {zero} + =1 {one} + =2 {two} + other { + many + } + } + + +================================================================================ +`; + +exports[`basic-plural-04.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== + + {VAR_PLURAL, plural, =0 {Found no results} =1 {Found one result} other {Found results} } + + +=====================================output===================================== + + {VAR_PLURAL, plural, + =0 {Found no results} + =1 {Found one result} + other { + Found results + } + } + + +================================================================================ +`; + +exports[`basic-select-01.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +The author is {gender, select, male {male} female {female} other {other}} + +=====================================output===================================== +The author is {gender, select, male {male} female {female} other {other} +} + +================================================================================ +`; + +exports[`basic-select-02.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +{gender, select, male {male} female {female} other {other}} +{age, select, 10 {ten} 20 {twenty} other {other}} + +=====================================output===================================== +{gender, select, + male {male} + female {female} + other {other} +} +{age, select, 10 {ten} 20 {twenty} other {other}} + +================================================================================ +`; + +exports[`combination-01.html format 1`] = ` +====================================options===================================== +parsers: ["angular"] +printWidth: 80 + | printWidth +=====================================input====================================== +Updated: {minutes, plural, + =0 {just now} + =1 {one minute ago} + other {{{minutes}} minutes ago by {gender, select, male {male} female {female} other {other}}}} + + +=====================================output===================================== +Updated: + {minutes, plural, + =0 {just now} + =1 {one minute ago} + other { + {{minutes}} minutes ago by {gender, select, + male {male} + female {female} + other {other} + } + } + } + + +================================================================================ +`; diff --git a/tests/format/angular/icu-expression/basic-plural-01.html b/tests/format/angular/icu-expression/basic-plural-01.html new file mode 100644 index 000000000..ed160952e --- /dev/null +++ b/tests/format/angular/icu-expression/basic-plural-01.html @@ -0,0 +1 @@ +Updated {minutes, plural, =0 {just now} =1 {one minute ago} other {{{minutes}} minutes ago}} diff --git a/tests/format/angular/icu-expression/basic-plural-02.html b/tests/format/angular/icu-expression/basic-plural-02.html new file mode 100644 index 000000000..ce3d949ad --- /dev/null +++ b/tests/format/angular/icu-expression/basic-plural-02.html @@ -0,0 +1,13 @@ +{ count, plural, + =0 {{ sex, select, + other {

deeply nested

} + }} + =other {a lot} +} + +{ count123, plural, + =0 {{ sex, select, + other {

deeply nested

} + }} + =other {a lot} +} diff --git a/tests/format/angular/icu-expression/basic-plural-03.html b/tests/format/angular/icu-expression/basic-plural-03.html new file mode 100644 index 000000000..fc51a7647 --- /dev/null +++ b/tests/format/angular/icu-expression/basic-plural-03.html @@ -0,0 +1,4 @@ + + {VAR_PLURAL, plural, =0 {zero} =1 {un} =2 {deux} other {beaucoup} } + {VAR_PLURAL, plural, =0 {zero} =1 {one} =2 {two} other {many} } + diff --git a/tests/format/angular/icu-expression/basic-plural-04.html b/tests/format/angular/icu-expression/basic-plural-04.html new file mode 100644 index 000000000..e62fe8816 --- /dev/null +++ b/tests/format/angular/icu-expression/basic-plural-04.html @@ -0,0 +1,3 @@ + + {VAR_PLURAL, plural, =0 {Found no results} =1 {Found one result} other {Found results} } + diff --git a/tests/format/angular/icu-expression/basic-select-01.html b/tests/format/angular/icu-expression/basic-select-01.html new file mode 100644 index 000000000..1fb55daba --- /dev/null +++ b/tests/format/angular/icu-expression/basic-select-01.html @@ -0,0 +1 @@ +The author is {gender, select, male {male} female {female} other {other}} diff --git a/tests/format/angular/icu-expression/basic-select-02.html b/tests/format/angular/icu-expression/basic-select-02.html new file mode 100644 index 000000000..55000e8c0 --- /dev/null +++ b/tests/format/angular/icu-expression/basic-select-02.html @@ -0,0 +1,2 @@ +{gender, select, male {male} female {female} other {other}} +{age, select, 10 {ten} 20 {twenty} other {other}} diff --git a/tests/format/angular/icu-expression/combination-01.html b/tests/format/angular/icu-expression/combination-01.html new file mode 100644 index 000000000..088a8ac1b --- /dev/null +++ b/tests/format/angular/icu-expression/combination-01.html @@ -0,0 +1,5 @@ +Updated: {minutes, plural, + =0 {just now} + =1 {one minute ago} + other {{{minutes}} minutes ago by {gender, select, male {male} female {female} other {other}}}} + diff --git a/tests/format/angular/icu-expression/jsfmt.spec.js b/tests/format/angular/icu-expression/jsfmt.spec.js new file mode 100644 index 000000000..c67dba8b4 --- /dev/null +++ b/tests/format/angular/icu-expression/jsfmt.spec.js @@ -0,0 +1 @@ +run_spec(import.meta, ["angular"]); diff --git a/tests/unit/__snapshots__/visitor-keys.js.snap b/tests/unit/__snapshots__/visitor-keys.js.snap index 45be1cfc1..9f5fede73 100644 --- a/tests/unit/__snapshots__/visitor-keys.js.snap +++ b/tests/unit/__snapshots__/visitor-keys.js.snap @@ -1394,6 +1394,12 @@ exports[`visitor keys html 1`] = ` "angularControlFlowBlockParameters": [ "children", ], + "angularIcuCase": [ + "expression", + ], + "angularIcuExpression": [ + "cases", + ], "attribute": [], "cdata": [], "comment": [], diff --git a/yarn.lock b/yarn.lock index f44dbb5da..1dca16bb8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2150,12 +2150,12 @@ __metadata: languageName: node linkType: hard -"angular-html-parser@npm:5.0.2": - version: 5.0.2 - resolution: "angular-html-parser@npm:5.0.2" +"angular-html-parser@npm:5.2.0": + version: 5.2.0 + resolution: "angular-html-parser@npm:5.2.0" dependencies: tslib: ^2.6.2 - checksum: 86f8fc123e696b10878759c516d1876e4eff3d7cccf06cd22c2f572f44950e721480b44e2367802005f2638f3a84c7d7b826cd1fa0751387787f255097a5b401 + checksum: 12253b32a064f945e4abb5b107287a9de3576b5404161374bd9a237381895de40b88a9b96e830a4a996724865f7ef298cd046869a56cd1acc714794870401e7f languageName: node linkType: hard @@ -7305,7 +7305,7 @@ __metadata: acorn: 8.11.2 acorn-jsx: 5.3.2 angular-estree-parser: 9.0.0 - angular-html-parser: 5.0.2 + angular-html-parser: 5.2.0 benchmark: 2.1.4 browserslist-to-esbuild: 1.2.0 c8: 8.0.1