diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9fd2fa2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + - run: npm install --no-audit --no-fund + - run: npm run check + - run: npm test + - run: npm pack --dry-run diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f0652ea --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,30 @@ +name: Publish to npm + +on: + push: + tags: ["v*"] + +permissions: + contents: read + id-token: write + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: https://registry.npmjs.org + - name: Verify version tag + shell: bash + run: | + expected="v$(node -p 'require("./package.json").version')" + test "$GITHUB_REF_NAME" = "$expected" || { + echo "Expected tag $expected, got $GITHUB_REF_NAME" >&2 + exit 1 + } + - run: npm install --no-audit --no-fund + - run: npm test + - run: npm publish --access public diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ddedbd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +coverage/ +*.tgz diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4d2b19e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Angular-RU contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3cec7cb..f4f04e5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,64 @@ # prettier-plugin-angular-comment -Prettier plugin for opt-in Angular template literal formatting with `/* Angular */`. +Format Angular template literals in JavaScript and TypeScript with an explicit `/* Angular */` marker. -Implementation and npm publishing setup are being added in a pull request. +Community plugin implementing the behavior proposed in [prettier/prettier#20317](https://github.com/prettier/prettier/pull/20317) for [#17615](https://github.com/prettier/prettier/issues/17615), without changing Prettier core. + +## Install + +```sh +npm install -D prettier prettier-plugin-angular-comment +``` + +Configure Prettier: + +```json +{ "plugins": ["prettier-plugin-angular-comment"] } +``` + +## Usage + +```ts +const template = /* Angular */ ` + @if (visible) { + {{ name }} + } @else { + Hidden + } +`; +``` + +The marker is case sensitive and applies only to the following template literal. Existing HTML comments, normal templates, and Angular component templates continue to use Prettier's built-in formatting. JS template expressions (`${value}`) are supported. Changing whitespace inside a template can change its runtime string value; opt in only where appropriate. + +## API + +```js +import * as prettier from "prettier"; +import * as plugin from "prettier-plugin-angular-comment"; +const result = await prettier.format(source, { parser: "typescript", plugins: [plugin] }); +``` + +Set `embeddedLanguageFormatting: "off"` to disable the plugin's embedding, along with other embedded formatting. + +## Requirements and limitations + +- Prettier 3.x / Node 18+. +- Supports JS/TS parsers backed by the built-in `estree` printer. +- May conflict with another plugin that also overrides `estree.embed`. Check plugin order. +- Invalid Angular embedded syntax falls back to normal formatting, consistent with Prettier's embed error handling. + +## Development + +```sh +npm install +npm test +npm pack --dry-run +``` + +## npm releases + +Create the npm package first if it does not yet exist. Configure [npm Trusted Publishing](https://docs.npmjs.com/trusted-publishers/) for `Angular-RU/prettier-plugin-angular-comment` and the workflow `publish.yml`; trusted publishing requires an existing npm package and matching owner access. After merging a version bump, push its matching `vX.Y.Z` Git tag. The GitHub Actions workflow tests and publishes via OIDC. Never put a long-lived npm token in the repository. + +## License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..806ddd2 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "prettier-plugin-angular-comment", + "version": "0.1.0", + "description": "Format Angular templates in JS/TS template literals with /* Angular */", + "type": "module", + "exports": { + ".": "./src/index.js" + }, + "files": [ + "src/index.js", + "README.md", + "LICENSE" + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "prettier": "^3.0.0" + }, + "devDependencies": { + "prettier": "^3.0.0" + }, + "scripts": { + "test": "node --test", + "check": "node --check src/index.js && node --check test/index.test.js", + "prepack": "npm run check && npm test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Angular-RU/prettier-plugin-angular-comment.git" + }, + "bugs": "https://github.com/Angular-RU/prettier-plugin-angular-comment/issues", + "homepage": "https://github.com/Angular-RU/prettier-plugin-angular-comment#readme", + "license": "MIT", + "keywords": [ + "prettier", + "prettier-plugin", + "angular", + "template-literals" + ], + "publishConfig": { + "access": "public" + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..b3fc616 --- /dev/null +++ b/src/index.js @@ -0,0 +1,121 @@ +import { doc } from "prettier"; +import { printers as prettierPrinters } from "prettier/plugins/estree"; + +const { group, indent, line, label, lineSuffixBoundary } = doc.builders; +const { mapDoc } = doc.utils; +const originalPrinter = prettierPrinters.estree; +let nextTemplateId = 0; + +function hasAngularComment(node) { + return node?.comments?.some( + (comment) => + comment.leading && + comment.value === " Angular " && + originalPrinter.isBlockComment(comment), + ); +} + +function isAngularTemplate(path) { + const { node, parent } = path; + if (hasAngularComment(node)) { + return true; + } + if ( + parent?.type === "AsConstExpression" || + (parent?.type === "TSAsExpression" && + parent.typeAnnotation?.type === "TSTypeReference" && + parent.typeAnnotation.typeName?.type === "Identifier" && + parent.typeAnnotation.typeName.name === "const") || + parent?.type === "ExpressionStatement" + ) { + return hasAngularComment(parent); + } + return false; +} + +function escapeTemplateText(text) { + return text.replaceAll(/([\\\x60]|\$\{)/g, "\\$1"); +} + +function embed(path, options) { + const { node } = path; + if ( + node.type !== "TemplateLiteral" || + node.quasis.some((quasi) => quasi.value.cooked === null) || + !isAngularTemplate(path) + ) { + return originalPrinter.embed?.(path, options); + } + + if (node.quasis.length === 1 && node.quasis[0].value.raw.trim() === "") { + return String.fromCharCode(96, 96); + } + + return async (textToDoc, print) => { + const id = nextTemplateId++; + const suffix = "_" + id + "_IN_JS"; + const placeholder = (index) => + "PRETTIER_ANGULAR_PLACEHOLDER_" + index + suffix; + const text = node.quasis + .map( + (quasi, index) => + quasi.value.cooked + + (index === node.quasis.length - 1 ? "" : placeholder(index)), + ) + .join(""); + + const expressionDocs = path.map( + () => ["$" + "{", print(), lineSuffixBoundary, "}"], + "expressions", + ); + + let topLevelCount = 0; + const embeddedDoc = await textToDoc(text, { + parser: "angular", + __onHtmlRoot(root) { + topLevelCount = root.children.length; + }, + }); + + const regex = new RegExp( + "PRETTIER_ANGULAR_PLACEHOLDER_(\\d+)" + suffix, + "g", + ); + const contentDoc = mapDoc(embeddedDoc, (current) => { + if (typeof current !== "string") { + return current; + } + return current.split(regex).flatMap((part, index) => + index % 2 === 0 + ? part ? [escapeTemplateText(part)] : [] + : [expressionDocs[Number(part)]], + ); + }); + + const leadingWhitespace = /^\s/.test(text) ? " " : ""; + const trailingWhitespace = /\s$/.test(text) ? " " : ""; + const linebreak = + options.htmlWhitespaceSensitivity === "ignore" || + (leadingWhitespace && trailingWhitespace) + ? line + : null; + + const result = linebreak + ? group(["\x60", indent([linebreak, group(contentDoc)]), linebreak, "\x60"]) + : group([ + "\x60", + leadingWhitespace, + topLevelCount > 1 ? indent(group(contentDoc)) : group(contentDoc), + trailingWhitespace, + "\x60", + ]); + return label({ embed: true }, result); + }; +} + +export const printers = { + estree: { + ...originalPrinter, + embed, + }, +}; diff --git a/test/index.test.js b/test/index.test.js new file mode 100644 index 0000000..c367927 --- /dev/null +++ b/test/index.test.js @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import * as prettier from "prettier"; +import * as plugin from "../src/index.js"; + +const tick = String.fromCharCode(96); +const format = (source, parser = "typescript", extra = {}) => + prettier.format(source, { parser, plugins: [plugin], ...extra }); + +for (const parser of ["babel", "typescript"]) { + test("formats Angular control flow (" + parser + ")", async () => { + const input = "const t = /* Angular */ " + tick + + "\n@if(first){Hello {{name}}}@else if(second){Two}@else{Other}\n" + + tick + ";"; + const output = await format(input, parser); + assert.match(output, /@if \(first\) \{/); + assert.match(output, /\} @else if \(second\) \{/); + assert.match(output, /\} @else \{/); + assert.match(output, /\{\{ name \}\}/); + assert.equal(await format(output, parser), output); + }); +} + +test("supports Angular @for blocks", async () => { + const input = "const t = /* Angular */ " + tick + + "\n@for(item of items; track item.id){
{{item.name}}
}\n" + tick + ";"; + assert.match(await format(input), /@for \(item of items; track item.id\) \{/); +}); + +test("preserves JavaScript template interpolations", async () => { + const input = "const t = /* Angular */ " + tick + + "\n@if (visible) {$" + "{value}
}\n" + tick + ";"; + const output = await format(input, "babel"); + assert.match(output, /\$\{value\}/); + assert.match(output, /@if \(visible\) \{/); + assert.equal(await format(output, "babel"), output); +}); + +test("preserves unmarked templates", async () => { + const input = "const t = " + tick + + "