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",