fluent-format 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(bun init:*)",
5
+ "Bash(bun add:*)",
6
+ "Bash(chmod:*)",
7
+ "Bash(bun run:*)",
8
+ "Bash(bun pm:*)"
9
+ ]
10
+ }
11
+ }
package/.vscodeignore ADDED
@@ -0,0 +1,19 @@
1
+ .vscode/**
2
+ .vscode-test/**
3
+ node_modules/**
4
+ .gitignore
5
+ .yarnrc
6
+ **/*.map
7
+ **/*.ts
8
+ !out/**/*.js
9
+ tsconfig.json
10
+ tsconfig.extension.json
11
+ test-*.ftl
12
+ example.ftl
13
+ test-locales/**
14
+ debug*.ts
15
+ *.md
16
+ !README.md
17
+ .git
18
+ .DS_Store
19
+ bun.lockb
package/CLAUDE.md ADDED
@@ -0,0 +1,111 @@
1
+ ---
2
+ description: Use Bun instead of Node.js, npm, pnpm, or vite.
3
+ globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json"
4
+ alwaysApply: false
5
+ ---
6
+
7
+ Default to using Bun instead of Node.js.
8
+
9
+ - Use `bun <file>` instead of `node <file>` or `ts-node <file>`
10
+ - Use `bun test` instead of `jest` or `vitest`
11
+ - Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
12
+ - Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
13
+ - Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
14
+ - Bun automatically loads .env, so don't use dotenv.
15
+
16
+ ## APIs
17
+
18
+ - `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
19
+ - `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
20
+ - `Bun.redis` for Redis. Don't use `ioredis`.
21
+ - `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
22
+ - `WebSocket` is built-in. Don't use `ws`.
23
+ - Prefer `Bun.file` over `node:fs`'s readFile/writeFile
24
+ - Bun.$`ls` instead of execa.
25
+
26
+ ## Testing
27
+
28
+ Use `bun test` to run tests.
29
+
30
+ ```ts#index.test.ts
31
+ import { test, expect } from "bun:test";
32
+
33
+ test("hello world", () => {
34
+ expect(1).toBe(1);
35
+ });
36
+ ```
37
+
38
+ ## Frontend
39
+
40
+ Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
41
+
42
+ Server:
43
+
44
+ ```ts#index.ts
45
+ import index from "./index.html"
46
+
47
+ Bun.serve({
48
+ routes: {
49
+ "/": index,
50
+ "/api/users/:id": {
51
+ GET: (req) => {
52
+ return new Response(JSON.stringify({ id: req.params.id }));
53
+ },
54
+ },
55
+ },
56
+ // optional websocket support
57
+ websocket: {
58
+ open: (ws) => {
59
+ ws.send("Hello, world!");
60
+ },
61
+ message: (ws, message) => {
62
+ ws.send(message);
63
+ },
64
+ close: (ws) => {
65
+ // handle close
66
+ }
67
+ },
68
+ development: {
69
+ hmr: true,
70
+ console: true,
71
+ }
72
+ })
73
+ ```
74
+
75
+ HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
76
+
77
+ ```html#index.html
78
+ <html>
79
+ <body>
80
+ <h1>Hello, world!</h1>
81
+ <script type="module" src="./frontend.tsx"></script>
82
+ </body>
83
+ </html>
84
+ ```
85
+
86
+ With the following `frontend.tsx`:
87
+
88
+ ```tsx#frontend.tsx
89
+ import React from "react";
90
+
91
+ // import .css files directly and it works
92
+ import './index.css';
93
+
94
+ import { createRoot } from "react-dom/client";
95
+
96
+ const root = createRoot(document.body);
97
+
98
+ export default function Frontend() {
99
+ return <h1>Hello, world!</h1>;
100
+ }
101
+
102
+ root.render(<Frontend />);
103
+ ```
104
+
105
+ Then, run index.ts
106
+
107
+ ```sh
108
+ bun --hot ./index.ts
109
+ ```
110
+
111
+ For more information, read the Bun API docs in `node_modules/bun-types/docs/**.md`.
package/README.md ADDED
@@ -0,0 +1,132 @@
1
+ # fluent-format
2
+
3
+ A CLI tool to format [Fluent](https://projectfluent.org/) (`.ftl`) files using the official `@fluent/syntax` parser.
4
+
5
+ ## Features
6
+
7
+ - Format single `.ftl` files or entire directories recursively
8
+ - Sort messages alphabetically within blank-line-separated groups
9
+ - Check if files are properly formatted (useful for CI/CD)
10
+ - Write formatted output back to files or print to stdout
11
+ - Built with [Bun](https://bun.sh) for speed
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ bun install
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ### Format and print to stdout
22
+
23
+ ```bash
24
+ bun run cli.ts <file-or-directory>
25
+ ```
26
+
27
+ ### Format and write to file
28
+
29
+ ```bash
30
+ bun run cli.ts --write <file-or-directory>
31
+ # or
32
+ bun run cli.ts -w <file-or-directory>
33
+ ```
34
+
35
+ ### Check if files are formatted
36
+
37
+ ```bash
38
+ bun run cli.ts --check <file-or-directory>
39
+ # or
40
+ bun run cli.ts -c <file-or-directory>
41
+ ```
42
+
43
+ This will exit with code 1 if any files need formatting, making it perfect for CI/CD pipelines.
44
+
45
+ ### Sort messages alphabetically
46
+
47
+ ```bash
48
+ bun run cli.ts --sort <file-or-directory>
49
+ # or
50
+ bun run cli.ts -s <file-or-directory>
51
+
52
+ # Combine with --write to save sorted output
53
+ bun run cli.ts --sort --write <file-or-directory>
54
+ ```
55
+
56
+ The sort feature groups messages by blank lines. Within each group, messages are sorted alphabetically by their ID. Group comments (comments at the start of a group) are preserved and attached to the first message after sorting.
57
+
58
+ ## Examples
59
+
60
+ ```bash
61
+ # Format a single file and print to stdout
62
+ bun run cli.ts example.ftl
63
+
64
+ # Format a single file and write changes
65
+ bun run cli.ts --write example.ftl
66
+
67
+ # Sort and format a file
68
+ bun run cli.ts --sort --write example.ftl
69
+
70
+ # Format all .ftl files in a directory
71
+ bun run cli.ts --write ./locales
72
+
73
+ # Sort all .ftl files in a directory
74
+ bun run cli.ts --sort --write ./locales
75
+
76
+ # Check if files are formatted (for CI)
77
+ bun run cli.ts --check ./locales
78
+ ```
79
+
80
+ ## Install globally
81
+
82
+ ```bash
83
+ bun link
84
+ ```
85
+
86
+ Then you can use it anywhere:
87
+
88
+ ```bash
89
+ fluent-format --write my-file.ftl
90
+ ```
91
+
92
+ ## How it works
93
+
94
+ This tool uses the official `@fluent/syntax` parser to:
95
+ 1. Parse `.ftl` files into an AST (Abstract Syntax Tree)
96
+ 2. Optionally sort messages alphabetically within groups (separated by blank lines)
97
+ 3. Serialize the AST back to properly formatted Fluent syntax
98
+ 4. Ensure consistent formatting across all your Fluent translation files
99
+
100
+ ### Sorting behavior
101
+
102
+ When using the `--sort` option:
103
+ - Messages are grouped by blank lines in the original file
104
+ - Within each group, messages are sorted alphabetically by their ID
105
+ - Comments at the beginning of a group are preserved and remain at the top of that group
106
+ - Blank lines between groups are maintained
107
+
108
+ Example:
109
+
110
+ **Before sorting:**
111
+ ```fluent
112
+ # Authentication
113
+ logout = Log out
114
+ login = Log in
115
+ signup = Sign up
116
+
117
+ # Settings
118
+ theme = Theme
119
+ language = Language
120
+ ```
121
+
122
+ **After sorting:**
123
+ ```fluent
124
+ # Authentication
125
+ login = Log in
126
+ logout = Log out
127
+ signup = Sign up
128
+
129
+ # Settings
130
+ language = Language
131
+ theme = Theme
132
+ ```
package/biome.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.3.15/schema.json",
3
+ "assist": {
4
+ "actions": {
5
+ "source": {
6
+ "organizeImports": "on"
7
+ }
8
+ }
9
+ },
10
+ "files": {
11
+ "includes": [
12
+ "**/*.ts",
13
+ "**/*.tsx",
14
+ "**/*.md",
15
+ "**/*.mdx",
16
+ "**/*.json",
17
+ "!**/_generated",
18
+ "!**/*.gen.ts",
19
+ "!**/*.js",
20
+ "!**/.vinxi",
21
+ "!**/*.queries.ts",
22
+ "!**/node_modules",
23
+ "!**/__pycache__",
24
+ "!**/.venv"
25
+ ]
26
+ },
27
+ "formatter": {
28
+ "indentStyle": "space",
29
+ "indentWidth": 2
30
+ },
31
+ "linter": {
32
+ "rules": {
33
+ "correctness": {
34
+ "noUnusedVariables": "error",
35
+ "noUnusedImports": "error",
36
+ "useJsxKeyInIterable": "off"
37
+ },
38
+ "a11y": {
39
+ "useSemanticElements": "off",
40
+ "useFocusableInteractive": "off",
41
+ "useKeyWithClickEvents": "off"
42
+ }
43
+ }
44
+ }
45
+ }
package/bun.lock ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "fluent-format",
7
+ "dependencies": {
8
+ "@fluent/syntax": "^0.19.0",
9
+ "commander": "^14.0.3",
10
+ },
11
+ "devDependencies": {
12
+ "@types/bun": "latest",
13
+ "@types/vscode": "^1.109.0",
14
+ "vscode-languageclient": "^9.0.1",
15
+ },
16
+ "peerDependencies": {
17
+ "typescript": "^5",
18
+ },
19
+ },
20
+ },
21
+ "packages": {
22
+ "@fluent/syntax": ["@fluent/syntax@0.19.0", "", {}, "sha512-5D2qVpZrgpjtqU4eNOcWGp1gnUCgjfM+vKGE2y03kKN6z5EBhtx0qdRFbg8QuNNj8wXNoX93KJoYb+NqoxswmQ=="],
23
+
24
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
25
+
26
+ "@types/node": ["@types/node@25.2.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ=="],
27
+
28
+ "@types/vscode": ["@types/vscode@1.109.0", "", {}, "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw=="],
29
+
30
+ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
31
+
32
+ "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
33
+
34
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
35
+
36
+ "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
37
+
38
+ "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
39
+
40
+ "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
41
+
42
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
43
+
44
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
45
+
46
+ "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
47
+
48
+ "vscode-languageclient": ["vscode-languageclient@9.0.1", "", { "dependencies": { "minimatch": "^5.1.0", "semver": "^7.3.7", "vscode-languageserver-protocol": "3.17.5" } }, "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA=="],
49
+
50
+ "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="],
51
+
52
+ "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
53
+ }
54
+ }
package/cli.ts ADDED
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import { formatFile, formatDirectory } from "./formatter";
5
+ import { existsSync, statSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ const program = new Command();
9
+
10
+ program
11
+ .name("fluent-format")
12
+ .description("A CLI tool to format Fluent (.ftl) files")
13
+ .version("0.1.0")
14
+ .argument("<path>", "File or directory to format")
15
+ .option("-w, --write", "Write formatted output to file", false)
16
+ .option(
17
+ "-c, --check",
18
+ "Check if files are formatted (exit with error if not)",
19
+ false,
20
+ )
21
+ .option(
22
+ "-s, --sort",
23
+ "Sort messages alphabetically within blank-line-separated groups",
24
+ false,
25
+ )
26
+ .action(
27
+ async (
28
+ path: string,
29
+ options: { write: boolean; check: boolean; sort: boolean },
30
+ ) => {
31
+ const targetPath = join(process.cwd(), path);
32
+
33
+ if (!existsSync(targetPath)) {
34
+ console.error(`Error: Path not found: ${targetPath}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const stats = statSync(targetPath);
39
+ const formatOptions = { sort: options.sort };
40
+
41
+ try {
42
+ if (stats.isFile()) {
43
+ if (!path.endsWith(".ftl")) {
44
+ console.error("Error: Only .ftl files are supported");
45
+ process.exit(1);
46
+ }
47
+
48
+ const result = await formatFile(
49
+ targetPath,
50
+ options.write,
51
+ formatOptions,
52
+ );
53
+
54
+ if (options.check) {
55
+ if (!result.isFormatted) {
56
+ console.error(`✗ ${path} needs formatting`);
57
+ process.exit(1);
58
+ } else {
59
+ console.log(`✓ ${path} is formatted`);
60
+ }
61
+ } else if (options.write) {
62
+ console.log(`✓ Formatted ${path}`);
63
+ } else {
64
+ console.log(result.content);
65
+ }
66
+ } else if (stats.isDirectory()) {
67
+ const results = await formatDirectory(
68
+ targetPath,
69
+ options.write,
70
+ formatOptions,
71
+ );
72
+
73
+ if (options.check) {
74
+ const unformatted = results.filter((r) => !r.isFormatted);
75
+ if (unformatted.length > 0) {
76
+ unformatted.forEach((r) =>
77
+ console.error(`✗ ${r.path} needs formatting`),
78
+ );
79
+ process.exit(1);
80
+ } else {
81
+ console.log(`✓ All ${results.length} files are formatted`);
82
+ }
83
+ } else {
84
+ results.forEach((r) => {
85
+ if (options.write) {
86
+ console.log(`✓ Formatted ${r.path}`);
87
+ }
88
+ });
89
+ if (!options.write) {
90
+ console.log(`Found ${results.length} .ftl files`);
91
+ }
92
+ }
93
+ }
94
+ } catch (error) {
95
+ console.error(
96
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
97
+ );
98
+ process.exit(1);
99
+ }
100
+ },
101
+ );
102
+
103
+ program.parse();
package/example.ftl ADDED
@@ -0,0 +1,13 @@
1
+ # Simple Fluent file for testing
2
+ hello = Hello, World!
3
+ welcome = Welcome to { $name }!
4
+ # Comment with details
5
+ goodbye = Goodbye,{ $name }
6
+ .title = Farewell
7
+ multi-line =
8
+ This is a multi-line
9
+ message that spans
10
+ multiple lines
11
+ # Another message
12
+ button-label = Click Me
13
+ .aria-label = Clickable button
package/extension.ts ADDED
@@ -0,0 +1,132 @@
1
+ import * as vscode from "vscode";
2
+ import { formatFluentContent } from "./formatter";
3
+
4
+ export function activate(context: vscode.ExtensionContext) {
5
+ console.log("Fluent Format extension is now active");
6
+
7
+ // Register format command
8
+ const formatCommand = vscode.commands.registerCommand(
9
+ "fluent-format.format",
10
+ async () => {
11
+ await formatDocument(false);
12
+ },
13
+ );
14
+
15
+ // Register format and sort command
16
+ const formatSortCommand = vscode.commands.registerCommand(
17
+ "fluent-format.formatSort",
18
+ async () => {
19
+ await formatDocument(true);
20
+ },
21
+ );
22
+
23
+ // Register document formatter provider
24
+ const formatterProvider =
25
+ vscode.languages.registerDocumentFormattingEditProvider(
26
+ { scheme: "file", language: "fluent" },
27
+ {
28
+ provideDocumentFormattingEdits(
29
+ document: vscode.TextDocument,
30
+ ): vscode.TextEdit[] {
31
+ const config = vscode.workspace.getConfiguration("fluentFormat");
32
+ const sortOnFormat = config.get<boolean>("sortOnFormat", false);
33
+
34
+ try {
35
+ const content = document.getText();
36
+ const formatted = formatFluentContent(content, {
37
+ sort: sortOnFormat,
38
+ });
39
+
40
+ const fullRange = new vscode.Range(
41
+ document.positionAt(0),
42
+ document.positionAt(content.length),
43
+ );
44
+
45
+ return [vscode.TextEdit.replace(fullRange, formatted)];
46
+ } catch (error) {
47
+ vscode.window.showErrorMessage(
48
+ `Fluent Format Error: ${error instanceof Error ? error.message : String(error)}`,
49
+ );
50
+ return [];
51
+ }
52
+ },
53
+ },
54
+ );
55
+
56
+ // Format on save
57
+ const formatOnSave = vscode.workspace.onWillSaveTextDocument((event) => {
58
+ const config = vscode.workspace.getConfiguration("fluentFormat");
59
+ const formatOnSaveEnabled = config.get<boolean>("formatOnSave", false);
60
+
61
+ if (formatOnSaveEnabled && event.document.languageId === "fluent") {
62
+ const sortOnFormat = config.get<boolean>("sortOnFormat", false);
63
+ event.waitUntil(formatDocumentPromise(event.document, sortOnFormat));
64
+ }
65
+ });
66
+
67
+ context.subscriptions.push(
68
+ formatCommand,
69
+ formatSortCommand,
70
+ formatterProvider,
71
+ formatOnSave,
72
+ );
73
+ }
74
+
75
+ async function formatDocument(sort: boolean): Promise<void> {
76
+ const editor = vscode.window.activeTextEditor;
77
+
78
+ if (!editor) {
79
+ vscode.window.showErrorMessage("No active editor");
80
+ return;
81
+ }
82
+
83
+ if (editor.document.languageId !== "fluent") {
84
+ vscode.window.showErrorMessage("Current file is not a Fluent (.ftl) file");
85
+ return;
86
+ }
87
+
88
+ try {
89
+ const document = editor.document;
90
+ const content = document.getText();
91
+ const formatted = formatFluentContent(content, { sort });
92
+
93
+ const fullRange = new vscode.Range(
94
+ document.positionAt(0),
95
+ document.positionAt(content.length),
96
+ );
97
+
98
+ await editor.edit((editBuilder) => {
99
+ editBuilder.replace(fullRange, formatted);
100
+ });
101
+
102
+ vscode.window.showInformationMessage(
103
+ sort ? "Fluent file formatted and sorted" : "Fluent file formatted",
104
+ );
105
+ } catch (error) {
106
+ vscode.window.showErrorMessage(
107
+ `Fluent Format Error: ${error instanceof Error ? error.message : String(error)}`,
108
+ );
109
+ }
110
+ }
111
+
112
+ async function formatDocumentPromise(
113
+ document: vscode.TextDocument,
114
+ sort: boolean,
115
+ ): Promise<vscode.TextEdit[]> {
116
+ try {
117
+ const content = document.getText();
118
+ const formatted = formatFluentContent(content, { sort });
119
+
120
+ const fullRange = new vscode.Range(
121
+ document.positionAt(0),
122
+ document.positionAt(content.length),
123
+ );
124
+
125
+ return [vscode.TextEdit.replace(fullRange, formatted)];
126
+ } catch (error) {
127
+ console.error("Format error:", error);
128
+ return [];
129
+ }
130
+ }
131
+
132
+ export function deactivate() {}
package/formatter.ts ADDED
@@ -0,0 +1,227 @@
1
+ import {
2
+ parse,
3
+ Resource,
4
+ serialize,
5
+ Entry,
6
+ Message,
7
+ Term,
8
+ lineOffset,
9
+ } from "@fluent/syntax";
10
+ import { readFileSync, writeFileSync, readdirSync, statSync } from "fs";
11
+ import { join } from "path";
12
+
13
+ export interface FormatResult {
14
+ path: string;
15
+ content: string;
16
+ isFormatted: boolean;
17
+ }
18
+
19
+ export interface FormatOptions {
20
+ sort?: boolean;
21
+ }
22
+
23
+ function getEntryId(entry: Entry): string | null {
24
+ if (entry.type === "Message") {
25
+ return (entry as Message).id.name;
26
+ }
27
+ if (entry.type === "Term") {
28
+ return (entry as Term).id.name;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function groupEntriesByBlankLines(
34
+ content: string,
35
+ entries: Entry[],
36
+ ): Entry[][] {
37
+ if (entries.length === 0) return [];
38
+
39
+ const lines = content.split("\n");
40
+ const groups: Entry[][] = [];
41
+ let currentGroup: Entry[] = [];
42
+
43
+ for (let i = 0; i < entries.length; i++) {
44
+ const entry = entries[i];
45
+ const nextEntry = entries[i + 1];
46
+
47
+ currentGroup.push(entry);
48
+
49
+ // Check if there's a blank line between this entry and the next one
50
+ if (nextEntry && entry.span && nextEntry.span) {
51
+ const currentEndLine = lineOffset(content, entry.span.end);
52
+ const nextStartLine = lineOffset(content, nextEntry.span.start);
53
+
54
+ // Check if there's a blank line in between
55
+ let hasBlankLine = false;
56
+ for (
57
+ let lineNum = currentEndLine + 1;
58
+ lineNum < nextStartLine;
59
+ lineNum++
60
+ ) {
61
+ if (lineNum < lines.length && lines[lineNum].trim() === "") {
62
+ hasBlankLine = true;
63
+ break;
64
+ }
65
+ }
66
+
67
+ if (hasBlankLine) {
68
+ groups.push(currentGroup);
69
+ currentGroup = [];
70
+ }
71
+ }
72
+ }
73
+
74
+ if (currentGroup.length > 0) {
75
+ groups.push(currentGroup);
76
+ }
77
+
78
+ return groups;
79
+ }
80
+
81
+ interface EntryUnit {
82
+ leadingComments: Entry[];
83
+ entry: Entry | null;
84
+ }
85
+
86
+ function createEntryUnits(entries: Entry[]): EntryUnit[] {
87
+ const units: EntryUnit[] = [];
88
+ let leadingComments: Entry[] = [];
89
+
90
+ for (const entry of entries) {
91
+ const entryId = getEntryId(entry);
92
+
93
+ if (!entryId) {
94
+ // This is a comment or other non-message/term entry
95
+ leadingComments.push(entry);
96
+ } else {
97
+ // This is a message or term
98
+ units.push({
99
+ leadingComments: [...leadingComments],
100
+ entry,
101
+ });
102
+ leadingComments = [];
103
+ }
104
+ }
105
+
106
+ // Handle any trailing comments
107
+ if (leadingComments.length > 0) {
108
+ units.push({
109
+ leadingComments,
110
+ entry: null,
111
+ });
112
+ }
113
+
114
+ return units;
115
+ }
116
+
117
+ function sortEntriesInGroups(content: string, entries: Entry[]): Entry[] {
118
+ const groups = groupEntriesByBlankLines(content, entries);
119
+ const sortedEntries: Entry[] = [];
120
+
121
+ for (const group of groups) {
122
+ if (group.length === 0) continue;
123
+
124
+ // Find and extract group comment from any entry
125
+ let groupComment = null;
126
+ for (const entry of group) {
127
+ if (
128
+ (entry.type === "Message" || entry.type === "Term") &&
129
+ (entry as any).comment
130
+ ) {
131
+ groupComment = (entry as any).comment;
132
+ (entry as any).comment = null; // Remove comment from original entry
133
+ break;
134
+ }
135
+ }
136
+
137
+ // Sort all entries in the group by their ID
138
+ const sorted = [...group].sort((a, b) => {
139
+ const idA = getEntryId(a);
140
+ const idB = getEntryId(b);
141
+
142
+ if (!idA && !idB) return 0;
143
+ if (!idA) return 1;
144
+ if (!idB) return -1;
145
+
146
+ return idA.localeCompare(idB);
147
+ });
148
+
149
+ // Attach group comment to the first entry
150
+ if (groupComment && sorted.length > 0) {
151
+ const firstEntry = sorted[0] as any;
152
+ if (firstEntry.type === "Message" || firstEntry.type === "Term") {
153
+ firstEntry.comment = groupComment;
154
+ }
155
+ }
156
+
157
+ sortedEntries.push(...sorted);
158
+ }
159
+
160
+ return sortedEntries;
161
+ }
162
+
163
+ export function formatFluentContent(
164
+ content: string,
165
+ options: FormatOptions = {},
166
+ ): string {
167
+ try {
168
+ const resource: Resource = parse(content, { withSpans: true });
169
+
170
+ if (options.sort) {
171
+ resource.body = sortEntriesInGroups(content, resource.body);
172
+ }
173
+
174
+ return serialize(resource, { withJunk: true });
175
+ } catch (error) {
176
+ throw new Error(
177
+ `Failed to parse Fluent content: ${error instanceof Error ? error.message : String(error)}`,
178
+ );
179
+ }
180
+ }
181
+
182
+ export async function formatFile(
183
+ filePath: string,
184
+ write: boolean = false,
185
+ options: FormatOptions = {},
186
+ ): Promise<FormatResult> {
187
+ const content = readFileSync(filePath, "utf-8");
188
+ const formatted = formatFluentContent(content, options);
189
+ const isFormatted = content === formatted;
190
+
191
+ if (write && !isFormatted) {
192
+ writeFileSync(filePath, formatted, "utf-8");
193
+ }
194
+
195
+ return {
196
+ path: filePath,
197
+ content: formatted,
198
+ isFormatted,
199
+ };
200
+ }
201
+
202
+ export async function formatDirectory(
203
+ dirPath: string,
204
+ write: boolean = false,
205
+ options: FormatOptions = {},
206
+ ): Promise<FormatResult[]> {
207
+ const results: FormatResult[] = [];
208
+
209
+ async function walk(dir: string) {
210
+ const entries = readdirSync(dir);
211
+
212
+ for (const entry of entries) {
213
+ const fullPath = join(dir, entry);
214
+ const stats = statSync(fullPath);
215
+
216
+ if (stats.isDirectory()) {
217
+ await walk(fullPath);
218
+ } else if (stats.isFile() && entry.endsWith(".ftl")) {
219
+ const result = await formatFile(fullPath, write, options);
220
+ results.push(result);
221
+ }
222
+ }
223
+ }
224
+
225
+ await walk(dirPath);
226
+ return results;
227
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ console.log("Hello via Bun!");
@@ -0,0 +1,19 @@
1
+ {
2
+ "comments": {
3
+ "lineComment": "#"
4
+ },
5
+ "brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
6
+ "autoClosingPairs": [
7
+ { "open": "{", "close": "}" },
8
+ { "open": "[", "close": "]" },
9
+ { "open": "(", "close": ")" },
10
+ { "open": "\"", "close": "\"" }
11
+ ],
12
+ "surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
13
+ "folding": {
14
+ "markers": {
15
+ "start": "^\\s*#.*",
16
+ "end": "^\\s*$"
17
+ }
18
+ }
19
+ }
package/package.json ADDED
@@ -0,0 +1,99 @@
1
+ {
2
+ "name": "fluent-format",
3
+ "displayName": "Fluent Format",
4
+ "version": "0.1.0",
5
+ "description": "Format and sort Fluent (.ftl) translation files",
6
+ "publisher": "fluent-format",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/yourusername/fluent-format"
11
+ },
12
+ "engines": {
13
+ "vscode": "^1.75.0"
14
+ },
15
+ "categories": [
16
+ "Formatters",
17
+ "Programming Languages"
18
+ ],
19
+ "keywords": [
20
+ "fluent",
21
+ "ftl",
22
+ "formatter",
23
+ "localization",
24
+ "l10n",
25
+ "i18n"
26
+ ],
27
+ "main": "./out/extension.js",
28
+ "activationEvents": [
29
+ "onLanguage:fluent"
30
+ ],
31
+ "contributes": {
32
+ "languages": [
33
+ {
34
+ "id": "fluent",
35
+ "extensions": [
36
+ ".ftl"
37
+ ],
38
+ "aliases": [
39
+ "Fluent",
40
+ "fluent"
41
+ ],
42
+ "configuration": "./language-configuration.json"
43
+ }
44
+ ],
45
+ "commands": [
46
+ {
47
+ "command": "fluent-format.format",
48
+ "title": "Format Fluent File"
49
+ },
50
+ {
51
+ "command": "fluent-format.formatSort",
52
+ "title": "Format and Sort Fluent File"
53
+ }
54
+ ],
55
+ "configuration": {
56
+ "title": "Fluent Format",
57
+ "properties": {
58
+ "fluentFormat.sortOnFormat": {
59
+ "type": "boolean",
60
+ "default": false,
61
+ "description": "Sort messages alphabetically when formatting"
62
+ },
63
+ "fluentFormat.formatOnSave": {
64
+ "type": "boolean",
65
+ "default": false,
66
+ "description": "Automatically format Fluent files on save"
67
+ }
68
+ }
69
+ }
70
+ },
71
+ "module": "index.ts",
72
+ "type": "module",
73
+ "bin": {
74
+ "fluent-format": "./cli.ts"
75
+ },
76
+ "scripts": {
77
+ "fluent-format": "bun run cli.ts",
78
+ "vscode:prepublish": "bun run compile",
79
+ "compile": "tsc -p tsconfig.extension.json",
80
+ "watch": "tsc -watch -p tsconfig.extension.json",
81
+ "package": "vsce package",
82
+ "format": "biome format --write",
83
+ "codex": "bunx @openai/codex@latest",
84
+ "claude": "bunx @anthropic-ai/claude-code@latest"
85
+ },
86
+ "devDependencies": {
87
+ "@biomejs/biome": "^2.3.15",
88
+ "@types/bun": "latest",
89
+ "@types/vscode": "^1.109.0",
90
+ "vscode-languageclient": "^9.0.1"
91
+ },
92
+ "peerDependencies": {
93
+ "typescript": "^5"
94
+ },
95
+ "dependencies": {
96
+ "@fluent/syntax": "^0.19.0",
97
+ "commander": "^14.0.3"
98
+ }
99
+ }
@@ -0,0 +1,18 @@
1
+ # Authentication messages
2
+ logout = Log out
3
+ login = Log in
4
+ signup = Sign up
5
+ forgot-password = Forgot password?
6
+
7
+ # No comment group
8
+ zebra-feature = Zebra Feature
9
+ apple-feature = Apple Feature
10
+ banana-feature = Banana Feature
11
+
12
+ # Settings
13
+ theme-dark = Dark Theme
14
+ theme-light = Light Theme
15
+ language = Language
16
+ profile = Profile
17
+
18
+ single-message = This is alone
@@ -0,0 +1,3 @@
1
+ app-name = My Application
2
+ welcome-message = Welcome { $username }!
3
+ .title = Welcome
@@ -0,0 +1,5 @@
1
+ language = Language
2
+ settings = Settings
3
+ theme = Theme
4
+ .light = Light Theme
5
+ .dark = Dark Theme
@@ -0,0 +1,3 @@
1
+ app-name = 私のアプリケーション
2
+ welcome-message = { $username }さん、ようこそ!
3
+ .title = ようこそ
package/test-sort.ftl ADDED
@@ -0,0 +1,12 @@
1
+ # First group
2
+ apple = Red fruit
3
+ banana = Yellow fruit
4
+ zebra = Zebra animal
5
+ # Second group
6
+ bird = Flying creature
7
+ cat = Feline pet
8
+ dog = Domestic animal
9
+ # Third group - already sorted
10
+ alpha = First letter
11
+ beta = Second letter
12
+ gamma = Third letter
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "commonjs",
4
+ "target": "ES2020",
5
+ "outDir": "out",
6
+ "lib": ["ES2020"],
7
+ "sourceMap": true,
8
+ "rootDir": ".",
9
+ "strict": true,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true
14
+ },
15
+ "include": ["extension.ts", "formatter.ts"],
16
+ "exclude": ["node_modules", "out", "**/*.test.ts"]
17
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }