Skip to content

Commit 52b705a

Browse files
committed
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.
1 parent 9a925d2 commit 52b705a

16 files changed

Lines changed: 498 additions & 4 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,12 @@ be installed independently of each other.
1717
- [@fluent/dedent](https://github.com/projectfluent/fluent.js/tree/main/fluent-dedent)
1818
- [@fluent/dom](https://github.com/projectfluent/fluent.js/tree/main/fluent-dom)
1919
- [@fluent/langneg](https://github.com/projectfluent/fluent.js/tree/main/fluent-langneg)
20+
- [@fluent/prettier-plugin](https://github.com/projectfluent/fluent.js/tree/main/fluent-prettier-plugin)
2021
- [@fluent/react](https://github.com/projectfluent/fluent.js/tree/main/fluent-react)
2122
- [@fluent/sequence](https://github.com/projectfluent/fluent.js/tree/main/fluent-sequence)
2223
- [@fluent/syntax](https://github.com/projectfluent/fluent.js/tree/main/fluent-syntax)
2324

24-
You can install each of the above packages via `npm`, e.g. `npm install @fluent/react`.
25+
You can install each of the above packages via `npm`, e.g. `npm install @fluent/react`.
2526
See the end of this `README` for instructions on how to build `fluent.js` locally.
2627

2728
## Learn the FTL syntax

fluent-prettier-plugin/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
esm/*
2+
!esm/package.json
3+
/index.js

fluent-prettier-plugin/.npmignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
.nyc_output
2+
coverage
3+
esm/.compiled
4+
src
5+
test
6+
tsconfig.json
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Changelog
2+
3+
## @fluent/prettier-plugin 0.1.0
4+
5+
- Initial release

fluent-prettier-plugin/README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# @fluent/prettier-plugin ![](https://github.com/projectfluent/fluent.js/workflows/test/badge.svg)
2+
3+
`@fluent/prettier-plugin` is a [Prettier](https://prettier.io/) plugin
4+
built on top of `@fluent/syntax` to format Project Fluent `.ftl`
5+
files. It's part of [Project Fluent][].
6+
7+
[project fluent]: https://projectfluent.org
8+
9+
The formatter normalizes valid Fluent syntax, such as indentation,
10+
newlines, and spacing within placeables and functions. Invalid input
11+
is rejected with an error.
12+
13+
Terms and messages sort alphabetically, giving deterministic output
14+
that is easy to read and helps minimize merge conflicts. Example:
15+
16+
```fluent
17+
account = Account
18+
-brand-name = Foo 3000
19+
welcome = Welcome, { $name }, to { -brand-name }!
20+
```
21+
22+
Both stand-alone comments and comments bound to messages (see the
23+
[syntax guide](https://projectfluent.org/fluent/guide/comments.html))
24+
are preserved, and stand-alone comments keep their original order.
25+
Groups of messages and terms delineated by stand-alone comments are
26+
sorted separately.
27+
28+
See the unit tests for more formatting examples.
29+
30+
## Installation
31+
32+
```sh
33+
npm install --save-dev prettier @fluent/prettier-plugin
34+
```
35+
36+
## How to use
37+
38+
```sh
39+
npx prettier --plugin=@fluent/prettier-plugin --write "**/*.ftl"
40+
```
41+
42+
Add the plugin to the project Prettier config to automatically use it:
43+
44+
```json
45+
{
46+
"plugins": ["@fluent/prettier-plugin"]
47+
}
48+
```
49+
50+
Individual files can opt out of formatting via a `@noformat` or
51+
`@noprettier` ‘pragma’ comment at the top of the file. Example:
52+
53+
```fluent
54+
# @noformat
55+
beta=Beta
56+
alpha=Alpha
57+
```
58+
59+
## Vue support
60+
61+
When using [fluent-vue](https://github.com/fluent-vue/fluent-vue) and
62+
per-component messages in Vue single-file components (SFC), add a
63+
`lang="fluent"` attribute on `<fluent>` custom blocks to tell Prettier which
64+
formatter to use:
65+
66+
```vue
67+
<fluent locale="en" lang="fluent">
68+
account = Account
69+
greeting = Hello, { $name }
70+
</fluent>
71+
```
72+
73+
The [eslint-plugin-vue](https://eslint.vuejs.org/)
74+
[`vue/block-lang`](https://eslint.vuejs.org/rules/block-lang) rule can
75+
be used to enforce this:
76+
77+
```json
78+
{
79+
"vue/block-lang": [
80+
"error",
81+
{
82+
"fluent": {
83+
"lang": "fluent"
84+
}
85+
}
86+
]
87+
}
88+
```
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
{
2+
"name": "@fluent/prettier-plugin",
3+
"description": "Prettier plugin for Fluent files",
4+
"version": "0.1.0",
5+
"homepage": "https://projectfluent.org",
6+
"author": "Mozilla <l10n-drivers@mozilla.org>",
7+
"license": "Apache-2.0",
8+
"contributors": [
9+
{
10+
"name": "wouter bolsterlee",
11+
"email": "wouter@bolsterl.ee"
12+
}
13+
],
14+
"main": "./index.js",
15+
"module": "./esm/index.js",
16+
"types": "./esm/index.d.ts",
17+
"repository": {
18+
"type": "git",
19+
"url": "https://github.com/projectfluent/fluent.js.git"
20+
},
21+
"keywords": [
22+
"fluent",
23+
"format",
24+
"ftl",
25+
"i18n",
26+
"internationalization",
27+
"l10n",
28+
"localization",
29+
"prettier",
30+
"prettier-plugin",
31+
"translation"
32+
],
33+
"scripts": {
34+
"build": "tsc",
35+
"postbuild": "rollup -c ../rollup.config.mjs --globals @fluent/syntax:FluentSyntax"
36+
},
37+
"engines": {
38+
"node": "^20.19 || ^22.12 || >=24"
39+
},
40+
"dependencies": {
41+
"@fluent/syntax": "^0.19.0"
42+
},
43+
"devDependencies": {
44+
"@fluent/dedent": "^0.5.0"
45+
},
46+
"peerDependencies": {
47+
"prettier": "^3.0.0"
48+
}
49+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as fluentSyntax from "@fluent/syntax";
2+
import type { Plugin } from "prettier";
3+
4+
const plugin: Plugin<fluentSyntax.Resource> = {
5+
languages: [
6+
{
7+
name: "Fluent",
8+
parsers: ["fluent"],
9+
extensions: [".ftl"],
10+
linguistLanguageId: 206353404, // see https://github.com/github-linguist/linguist/blob/main/lib/linguist/languages.yml
11+
},
12+
],
13+
parsers: {
14+
fluent: {
15+
parse(text) {
16+
const resource = fluentSyntax.parse(text, { withSpans: true });
17+
const firstJunkEntry = resource.body.find(
18+
entry => entry instanceof fluentSyntax.Junk
19+
);
20+
if (firstJunkEntry) {
21+
throw createParseError(text, firstJunkEntry);
22+
}
23+
return resource;
24+
},
25+
astFormat: "fluent-ast",
26+
hasIgnorePragma(text) {
27+
return Boolean(
28+
text.trimStart().match(/^#{1,3}\s*(?:@noformat|@noprettier)\b/)
29+
);
30+
},
31+
locStart(node) {
32+
return node.span?.start ?? 0;
33+
},
34+
locEnd(node) {
35+
return node.span?.end ?? 0;
36+
},
37+
},
38+
},
39+
printers: {
40+
"fluent-ast": {
41+
print(path) {
42+
return fluentSyntax.serialize(sortResource(path.node), {});
43+
},
44+
},
45+
},
46+
};
47+
export default plugin;
48+
49+
function sortResource(resource: fluentSyntax.Resource): fluentSyntax.Resource {
50+
type SortableEntry = fluentSyntax.Message | fluentSyntax.Term;
51+
function compare(a: SortableEntry, b: SortableEntry): number {
52+
return a.id.name.localeCompare(b.id.name);
53+
}
54+
const entries: fluentSyntax.Entry[] = [];
55+
const pending: SortableEntry[] = [];
56+
for (const entry of resource.body) {
57+
if (
58+
entry instanceof fluentSyntax.Message ||
59+
entry instanceof fluentSyntax.Term
60+
) {
61+
pending.push(entry);
62+
continue;
63+
}
64+
if (pending.length) {
65+
entries.push(...pending.sort(compare));
66+
pending.length = 0;
67+
}
68+
entries.push(entry);
69+
}
70+
entries.push(...pending.sort(compare));
71+
return new fluentSyntax.Resource(entries);
72+
}
73+
74+
type FluentParseError = Error & {
75+
// Optional, but Prettier gives nicer error messages when set.
76+
loc?: { start: { line: number; column: number } };
77+
};
78+
79+
function createParseError(
80+
text: string,
81+
junk: fluentSyntax.Junk
82+
): FluentParseError {
83+
const annotation = junk.annotations[0];
84+
const offset = annotation?.span?.start ?? junk.span?.start ?? 0;
85+
const line = fluentSyntax.lineOffset(text, offset) + 1;
86+
const column = fluentSyntax.columnOffset(text, offset) + 1;
87+
const details = annotation
88+
? `${annotation.code}: ${annotation.message}`
89+
: "Invalid Fluent syntax";
90+
const error: FluentParseError = new Error(`${details} (${line}:${column})`);
91+
error.loc = { start: { line, column } };
92+
return error;
93+
}

0 commit comments

Comments
 (0)