From 52b705adf2f1867b14c33b25a015be76e00c5819 Mon Sep 17 00:00:00 2001 From: wouter bolsterlee Date: Mon, 8 Jun 2026 21:03:16 +0200 Subject: [PATCH] Add @fluent/prettier-plugin package to autoformat Fluent resources This adds a @fluent/prettier-plugin subpackage containing a Prettier plugin to autoformat FTL resource files. See added README for details. The packaging, metadata, license, documentation, etc. follows the conventions used by the the other packages in this monorepo. The package is not included in the typedoc docs, because it is not intended to be used as a library, but indirectly via Prettier as a plugin. The vitest configuration now also runs tests written in TypeScript, and @types/node is added as dev dependency, because it gets referenced from tsconfig. Closes #667. --- README.md | 3 +- fluent-prettier-plugin/.gitignore | 3 + fluent-prettier-plugin/.npmignore | 6 + fluent-prettier-plugin/CHANGELOG.md | 5 + fluent-prettier-plugin/README.md | 88 +++++++++ fluent-prettier-plugin/esm/package.json | 3 + fluent-prettier-plugin/package.json | 49 +++++ fluent-prettier-plugin/src/index.ts | 93 +++++++++ fluent-prettier-plugin/test/formatter_test.ts | 184 ++++++++++++++++++ fluent-prettier-plugin/test/tsconfig.json | 10 + fluent-prettier-plugin/tsconfig.json | 7 + package-lock.json | 41 +++- package.json | 2 + rollup.config.mjs | 1 + tsconfig.json | 3 +- vitest.config.ts | 4 +- 16 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 fluent-prettier-plugin/.gitignore create mode 100644 fluent-prettier-plugin/.npmignore create mode 100644 fluent-prettier-plugin/CHANGELOG.md create mode 100644 fluent-prettier-plugin/README.md create mode 100644 fluent-prettier-plugin/esm/package.json create mode 100644 fluent-prettier-plugin/package.json create mode 100644 fluent-prettier-plugin/src/index.ts create mode 100644 fluent-prettier-plugin/test/formatter_test.ts create mode 100644 fluent-prettier-plugin/test/tsconfig.json create mode 100644 fluent-prettier-plugin/tsconfig.json diff --git a/README.md b/README.md index f786d6ad..f054ffa4 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ be installed independently of each other. - [@fluent/dedent](https://github.com/projectfluent/fluent.js/tree/main/fluent-dedent) - [@fluent/dom](https://github.com/projectfluent/fluent.js/tree/main/fluent-dom) - [@fluent/langneg](https://github.com/projectfluent/fluent.js/tree/main/fluent-langneg) +- [@fluent/prettier-plugin](https://github.com/projectfluent/fluent.js/tree/main/fluent-prettier-plugin) - [@fluent/react](https://github.com/projectfluent/fluent.js/tree/main/fluent-react) - [@fluent/sequence](https://github.com/projectfluent/fluent.js/tree/main/fluent-sequence) - [@fluent/syntax](https://github.com/projectfluent/fluent.js/tree/main/fluent-syntax) -You can install each of the above packages via `npm`, e.g. `npm install @fluent/react`. +You can install each of the above packages via `npm`, e.g. `npm install @fluent/react`. See the end of this `README` for instructions on how to build `fluent.js` locally. ## Learn the FTL syntax diff --git a/fluent-prettier-plugin/.gitignore b/fluent-prettier-plugin/.gitignore new file mode 100644 index 00000000..a31b0fab --- /dev/null +++ b/fluent-prettier-plugin/.gitignore @@ -0,0 +1,3 @@ +esm/* +!esm/package.json +/index.js diff --git a/fluent-prettier-plugin/.npmignore b/fluent-prettier-plugin/.npmignore new file mode 100644 index 00000000..52443a3d --- /dev/null +++ b/fluent-prettier-plugin/.npmignore @@ -0,0 +1,6 @@ +.nyc_output +coverage +esm/.compiled +src +test +tsconfig.json diff --git a/fluent-prettier-plugin/CHANGELOG.md b/fluent-prettier-plugin/CHANGELOG.md new file mode 100644 index 00000000..6bc71a7b --- /dev/null +++ b/fluent-prettier-plugin/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## @fluent/prettier-plugin 0.1.0 + +- Initial release diff --git a/fluent-prettier-plugin/README.md b/fluent-prettier-plugin/README.md new file mode 100644 index 00000000..a235b40c --- /dev/null +++ b/fluent-prettier-plugin/README.md @@ -0,0 +1,88 @@ +# @fluent/prettier-plugin ![](https://github.com/projectfluent/fluent.js/workflows/test/badge.svg) + +`@fluent/prettier-plugin` is a [Prettier](https://prettier.io/) plugin +built on top of `@fluent/syntax` to format Project Fluent `.ftl` +files. It's part of [Project Fluent][]. + +[project fluent]: https://projectfluent.org + +The formatter normalizes valid Fluent syntax, such as indentation, +newlines, and spacing within placeables and functions. Invalid input +is rejected with an error. + +Terms and messages sort alphabetically, giving deterministic output +that is easy to read and helps minimize merge conflicts. Example: + +```fluent +account = Account +-brand-name = Foo 3000 +welcome = Welcome, { $name }, to { -brand-name }! +``` + +Both stand-alone comments and comments bound to messages (see the +[syntax guide](https://projectfluent.org/fluent/guide/comments.html)) +are preserved, and stand-alone comments keep their original order. +Groups of messages and terms delineated by stand-alone comments are +sorted separately. + +See the unit tests for more formatting examples. + +## Installation + +```sh +npm install --save-dev prettier @fluent/prettier-plugin +``` + +## How to use + +```sh +npx prettier --plugin=@fluent/prettier-plugin --write "**/*.ftl" +``` + +Add the plugin to the project Prettier config to automatically use it: + +```json +{ + "plugins": ["@fluent/prettier-plugin"] +} +``` + +Individual files can opt out of formatting via a `@noformat` or +`@noprettier` ‘pragma’ comment at the top of the file. Example: + +```fluent +# @noformat +beta=Beta +alpha=Alpha +``` + +## Vue support + +When using [fluent-vue](https://github.com/fluent-vue/fluent-vue) and +per-component messages in Vue single-file components (SFC), add a +`lang="fluent"` attribute on `` custom blocks to tell Prettier which +formatter to use: + +```vue + +account = Account +greeting = Hello, { $name } + +``` + +The [eslint-plugin-vue](https://eslint.vuejs.org/) +[`vue/block-lang`](https://eslint.vuejs.org/rules/block-lang) rule can +be used to enforce this: + +```json +{ + "vue/block-lang": [ + "error", + { + "fluent": { + "lang": "fluent" + } + } + ] +} +``` diff --git a/fluent-prettier-plugin/esm/package.json b/fluent-prettier-plugin/esm/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/fluent-prettier-plugin/esm/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/fluent-prettier-plugin/package.json b/fluent-prettier-plugin/package.json new file mode 100644 index 00000000..69d13329 --- /dev/null +++ b/fluent-prettier-plugin/package.json @@ -0,0 +1,49 @@ +{ + "name": "@fluent/prettier-plugin", + "description": "Prettier plugin for Fluent files", + "version": "0.1.0", + "homepage": "https://projectfluent.org", + "author": "Mozilla ", + "license": "Apache-2.0", + "contributors": [ + { + "name": "wouter bolsterlee", + "email": "wouter@bolsterl.ee" + } + ], + "main": "./index.js", + "module": "./esm/index.js", + "types": "./esm/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/projectfluent/fluent.js.git" + }, + "keywords": [ + "fluent", + "format", + "ftl", + "i18n", + "internationalization", + "l10n", + "localization", + "prettier", + "prettier-plugin", + "translation" + ], + "scripts": { + "build": "tsc", + "postbuild": "rollup -c ../rollup.config.mjs --globals @fluent/syntax:FluentSyntax" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "dependencies": { + "@fluent/syntax": "^0.19.0" + }, + "devDependencies": { + "@fluent/dedent": "^0.5.0" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } +} diff --git a/fluent-prettier-plugin/src/index.ts b/fluent-prettier-plugin/src/index.ts new file mode 100644 index 00000000..58ed2602 --- /dev/null +++ b/fluent-prettier-plugin/src/index.ts @@ -0,0 +1,93 @@ +import * as fluentSyntax from "@fluent/syntax"; +import type { Plugin } from "prettier"; + +const plugin: Plugin = { + languages: [ + { + name: "Fluent", + parsers: ["fluent"], + extensions: [".ftl"], + linguistLanguageId: 206353404, // see https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml + }, + ], + parsers: { + fluent: { + parse(text) { + const resource = fluentSyntax.parse(text, { withSpans: true }); + const firstJunkEntry = resource.body.find( + entry => entry instanceof fluentSyntax.Junk + ); + if (firstJunkEntry) { + throw createParseError(text, firstJunkEntry); + } + return resource; + }, + astFormat: "fluent-ast", + hasIgnorePragma(text) { + return Boolean( + text.trimStart().match(/^#{1,3}\s*(?:@noformat|@noprettier)\b/) + ); + }, + locStart(node) { + return node.span?.start ?? 0; + }, + locEnd(node) { + return node.span?.end ?? 0; + }, + }, + }, + printers: { + "fluent-ast": { + print(path) { + return fluentSyntax.serialize(sortResource(path.node), {}); + }, + }, + }, +}; +export default plugin; + +function sortResource(resource: fluentSyntax.Resource): fluentSyntax.Resource { + type SortableEntry = fluentSyntax.Message | fluentSyntax.Term; + function compare(a: SortableEntry, b: SortableEntry): number { + return a.id.name.localeCompare(b.id.name); + } + const entries: fluentSyntax.Entry[] = []; + const pending: SortableEntry[] = []; + for (const entry of resource.body) { + if ( + entry instanceof fluentSyntax.Message || + entry instanceof fluentSyntax.Term + ) { + pending.push(entry); + continue; + } + if (pending.length) { + entries.push(...pending.sort(compare)); + pending.length = 0; + } + entries.push(entry); + } + entries.push(...pending.sort(compare)); + return new fluentSyntax.Resource(entries); +} + +type FluentParseError = Error & { + // Optional, but Prettier gives nicer error messages when set. + loc?: { start: { line: number; column: number } }; +}; + +function createParseError( + text: string, + junk: fluentSyntax.Junk +): FluentParseError { + const annotation = junk.annotations[0]; + const offset = annotation?.span?.start ?? junk.span?.start ?? 0; + const line = fluentSyntax.lineOffset(text, offset) + 1; + const column = fluentSyntax.columnOffset(text, offset) + 1; + const details = annotation + ? `${annotation.code}: ${annotation.message}` + : "Invalid Fluent syntax"; + const error: FluentParseError = new Error(`${details} (${line}:${column})`); + error.loc = { start: { line, column } }; + return error; +} diff --git a/fluent-prettier-plugin/test/formatter_test.ts b/fluent-prettier-plugin/test/formatter_test.ts new file mode 100644 index 00000000..e01cc221 --- /dev/null +++ b/fluent-prettier-plugin/test/formatter_test.ts @@ -0,0 +1,184 @@ +import ftl from "@fluent/dedent"; +import fluentPrettierPlugin from "@fluent/prettier-plugin"; +import * as prettier from "prettier"; + +async function format(source: string): Promise { + return await prettier.format(source, { + parser: "fluent", + plugins: [fluentPrettierPlugin], + checkIgnorePragma: true, + }); +} + +test("normalizes whitespace", async () => { + const input = ftl` + -brand-name = Some brand + example= This is an example. + + + welcome = + + Welcome, {$name}, to { -brand-name }! + + `; + const expected = ftl` + -brand-name = Some brand + example = This is an example. + welcome = Welcome, { $name }, to { -brand-name }! + + `; + await expect(format(input)).resolves.toBe(expected); +}); + +test("handles multiline text", async () => { + // based on https://projectfluent.org/fluent/guide/text.html + const input = ftl` + multi = Text can also span multiple lines as long as + each new line is indented by at least one space. + Because all lines in this message are indented + by the same amount, all indentation will be + removed from the final value. + + with-indents = + Indentation common to all indented lines is removed + from the final text value. + This line has 2 spaces in front of it. + + `; + const expected = ftl` + multi = + Text can also span multiple lines as long as + each new line is indented by at least one space. + Because all lines in this message are indented + by the same amount, all indentation will be + removed from the final value. + with-indents = + Indentation common to all indented lines is removed + from the final text value. + This line has 2 spaces in front of it. + + `; + await expect(format(input)).resolves.toBe(expected); +}); + +test("sorts messages and terms", async () => { + const input = ftl` + foo = Foo + -example-term = Example term + bar = Bar + + `; + const expected = ftl` + bar = Bar + -example-term = Example term + foo = Foo + + `; + await expect(format(input)).resolves.toBe(expected); +}); + +test("formats functions, selectors, placeables, and attributes", async () => { + const input = ftl` + last-notice = + Last checked: { DATETIME( + $lastChecked, + day:"numeric",month: "long" + ) }. + message-count = {$count -> + [one] { $count } message + *[other] { $count } messages + } + user = + .label=Example label + .placeholder = hello, { + $name + } + + `; + const expected = ftl` + last-notice = Last checked: { DATETIME($lastChecked, day: "numeric", month: "long") }. + message-count = + { $count -> + [one] { $count } message + *[other] { $count } messages + } + user = + .label = Example label + .placeholder = hello, { $name } + + `; + await expect(format(input)).resolves.toBe(expected); +}); + +test("keeps bound comments with the associated message", async () => { + const input = ftl` + # comment about beta + beta = Beta + alpha = Alpha + + `; + await expect(format(input)).resolves.toBe(ftl` + alpha = Alpha + # comment about beta + beta = Beta + + `); +}); + +test("leaves stand-alone comments intact and uses them as sorting boundary", async () => { + const input = ftl` + ### file comment + beta = Beta + alpha = Alpha + ## section 1 + greeting = Hello + another-greeting = Hi + + + ## section 2 + ## formatting stays intact, e.g. multiple spaces + + message = Example message + another-message = Another example message + + `; + const expected = ftl` + ### file comment + + alpha = Alpha + beta = Beta + + ## section 1 + + another-greeting = Hi + greeting = Hello + + ## section 2 + ## formatting stays intact, e.g. multiple spaces + + another-message = Another example message + message = Example message + + `; + await expect(format(input)).resolves.toBe(expected); +}); + +test("respects ignore pragma comments", async () => { + const input = ftl` + # @noformat + foo=foo + bar=bar + + `; + await expect(format(input)).resolves.toBe(input); +}); + +test("fails on invalid syntax", async () => { + const input = ftl` + invalid + + `; + await expect(format(input)).rejects.toThrow( + 'E0003: Expected token: "=" (1:8)' + ); +}); diff --git a/fluent-prettier-plugin/test/tsconfig.json b/fluent-prettier-plugin/test/tsconfig.json new file mode 100644 index 00000000..a8e09fdc --- /dev/null +++ b/fluent-prettier-plugin/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": true, + "moduleResolution": "bundler", + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["*.ts"] +} diff --git a/fluent-prettier-plugin/tsconfig.json b/fluent-prettier-plugin/tsconfig.json new file mode 100644 index 00000000..1c550163 --- /dev/null +++ b/fluent-prettier-plugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "./esm" + }, + "include": ["./src/**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json index b640ec3d..9a4df2c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,11 @@ "./fluent-langneg", "./fluent-react", "./fluent-syntax", + "./fluent-prettier-plugin", "./fluent-gecko" ], "devDependencies": { + "@types/node": "^25.9.2", "colors": "^1.3.3", "commander": "^2.20", "eslint": "^9.23.0", @@ -84,6 +86,23 @@ "node": "^20.19 || ^22.12 || >=24" } }, + "fluent-prettier-plugin": { + "name": "@fluent/prettier-plugin", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@fluent/syntax": "^0.19.0" + }, + "devDependencies": { + "@fluent/dedent": "^0.5.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "fluent-react": { "name": "@fluent/react", "version": "0.15.2", @@ -967,6 +986,10 @@ "resolved": "fluent-langneg", "link": true }, + "node_modules/@fluent/prettier-plugin": { + "resolved": "fluent-prettier-plugin", + "link": true + }, "node_modules/@fluent/react": { "resolved": "fluent-react", "link": true @@ -1557,6 +1580,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -4713,7 +4746,6 @@ "version": "3.7.4", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -5874,6 +5906,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", diff --git a/package.json b/package.json index 2063d1bc..54065635 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "./fluent-langneg", "./fluent-react", "./fluent-syntax", + "./fluent-prettier-plugin", "./fluent-gecko" ], "scripts": { @@ -27,6 +28,7 @@ "trailingComma": "es5" }, "devDependencies": { + "@types/node": "^25.9.2", "colors": "^1.3.3", "commander": "^2.20", "eslint": "^9.23.0", diff --git a/rollup.config.mjs b/rollup.config.mjs index c910f4cc..e26365b2 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -5,6 +5,7 @@ const globalName = { "@fluent/dedent": "FluentDedent", "@fluent/dom": "FluentDOM", "@fluent/langneg": "FluentLangNeg", + "@fluent/prettier-plugin": "FluentPrettierPlugin", "@fluent/react": "FluentReact", "@fluent/sequence": "FluentSequence", "@fluent/syntax": "FluentSyntax", diff --git a/tsconfig.json b/tsconfig.json index 0908de60..b1297628 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "baseUrl": ".", "paths": { "@fluent/bundle": ["./fluent-bundle/src/index.ts"], - "@fluent/sequence": ["./fluent-sequence/src/index.ts"] + "@fluent/sequence": ["./fluent-sequence/src/index.ts"], + "@fluent/syntax": ["./fluent-syntax/src/index.ts"] } } } diff --git a/vitest.config.ts b/vitest.config.ts index 1d19b3ce..14dc51b7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,14 +5,16 @@ export default defineConfig({ alias: { "@fluent/bundle": "/fluent-bundle/src/index.ts", "@fluent/dedent": "/fluent-dedent/src/index.ts", + "@fluent/prettier-plugin": "/fluent-prettier-plugin/src/index.ts", "@fluent/sequence": "/fluent-sequence/src/index.ts", + "@fluent/syntax": "/fluent-syntax/src/index.ts", }, projects: [ { extends: true, test: { name: "common", - include: ["fluent-*/test/*_test.js"], + include: ["fluent-*/test/*_test.{js,ts}"], exclude: ["fluent-dom/", "fluent-react/"], globals: true, environment: "node",