Skip to content

Commit 656cd0c

Browse files
committed
refactor: migrate to `@fluent/syntax
1 parent 86bf702 commit 656cd0c

6 files changed

Lines changed: 397 additions & 108 deletions

File tree

packages/scaffold/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
}
7070
},
7171
"dependencies": {
72+
"@fluent/syntax": "^0.19.0",
7273
"@swc/core": "^1.11.16",
7374
"adm-zip": "^0.5.16",
7475
"bumpp": "^10.1.0",

packages/scaffold/src/core/builder.ts

Lines changed: 6 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@ import type { Context } from "../types/index.js";
22
import type { Manifest } from "../types/manifest.js";
33
import type { UpdateJSON } from "../types/update-json.js";
44
import { existsSync } from "node:fs";
5-
import { readFile, writeFile } from "node:fs/promises";
6-
import { basename, dirname, join, resolve } from "node:path";
5+
import { readFile } from "node:fs/promises";
6+
import { join, resolve } from "node:path";
77
import process from "node:process";
88
import AdmZip from "adm-zip";
9-
import { escapeRegExp, toMerged } from "es-toolkit";
9+
import { toMerged } from "es-toolkit";
1010
import { build as buildAsync } from "esbuild";
11-
import { copy, emptyDir, move, outputFile, outputJSON, readJSON, writeJson } from "fs-extra/esm";
11+
import { copy, emptyDir, outputFile, outputJSON, readJSON, writeJson } from "fs-extra/esm";
1212
import styleText from "node-style-text";
1313
import { glob } from "tinyglobby";
1414
import { generateHash } from "../utils/crypto.js";
1515
import { is32BitNumber } from "../utils/number.js";
1616
import { PrefsManager, renderPluginPrefsDts } from "../utils/prefs-manager.js";
1717
import { dateFormat, replaceInFile, toArray } from "../utils/string.js";
1818
import { Base } from "./base.js";
19+
import buildLocale from "./builder/fluent.js";
1920

2021
export default class Build extends Base {
2122
private buildTime: string;
@@ -145,109 +146,7 @@ export default class Build extends Base {
145146

146147
async prepareLocaleFiles() {
147148
const { dist, namespace, build } = this.ctx;
148-
149-
const ignores = toArray(build.fluent.ignore);
150-
151-
// https://regex101.com/r/lQ9x5p/1
152-
// eslint-disable-next-line regexp/no-super-linear-backtracking
153-
const FTL_MESSAGE_PATTERN = /^(?<message>[a-z]\S*)( *= *)(?<pattern>.*)$/gim;
154-
const HTML_DATAI10NID_PATTERN = new RegExp(`(data-l10n-id)="((?!${namespace})\\S*)"`, "g");
155-
156-
// Get locale names
157-
const localePaths = await glob(`${dist}/addon/locale/*`, { onlyDirectories: true });
158-
const localeNames = localePaths.map(locale => basename(locale));
159-
this.logger.debug(`Locale names:", ${localeNames}`);
160-
161-
const allMessages = new Set<string>();
162-
const messagesByLocale = new Map<string, Set<string>>();
163-
164-
for (const localeName of localeNames) {
165-
// Prefix Fluent messages in each ftl, add message to set.
166-
const localeMessages = new Set<string>();
167-
const ftlPaths = await glob(`${dist}/addon/locale/${localeName}/**/*.ftl`);
168-
169-
await Promise.all(ftlPaths.map(async (ftlPath: string) => {
170-
let ftlContent = await readFile(ftlPath, "utf-8");
171-
const matches = [...ftlContent.matchAll(FTL_MESSAGE_PATTERN)];
172-
173-
for (const match of matches) {
174-
const [matched, message, _pattern] = match;
175-
if (message) {
176-
localeMessages.add(message);
177-
allMessages.add(message);
178-
ftlContent = ftlContent.replace(new RegExp(`^${escapeRegExp(matched)}`, "gm"), `${namespace}-${matched}`);
179-
}
180-
}
181-
182-
// If prefixFluentMessages===true, we save the changed ftl file,
183-
// otherwise discard the changes
184-
if (build.fluent.prefixFluentMessages)
185-
await writeFile(ftlPath, ftlContent);
186-
187-
// rename *.ftl to addonRef-*.ftl
188-
if (build.fluent.prefixLocaleFiles === true) {
189-
await move(ftlPath, `${dirname(ftlPath)}/${namespace}-${basename(ftlPath)}`);
190-
this.logger.debug(`FTL file '${ftlPath}' is renamed to '${namespace}-${basename(ftlPath)}'.`);
191-
}
192-
}));
193-
194-
messagesByLocale.set(localeName, localeMessages);
195-
}
196-
197-
// Prefix Fluent messages in xhtml
198-
const messagesInHTML = new Set<string>();
199-
const htmlPaths = await glob([
200-
`${dist}/addon/**/*.xhtml`,
201-
`${dist}/addon/**/*.html`,
202-
]);
203-
await Promise.all(htmlPaths.map(async (htmlPath) => {
204-
let htmlContent = await readFile(htmlPath, "utf-8");
205-
const matches = [...htmlContent.matchAll(HTML_DATAI10NID_PATTERN)];
206-
207-
for (const match of matches) {
208-
const [matched, attrKey, attrVal] = match;
209-
210-
if (ignores.includes(attrVal)) {
211-
this.logger.debug(`HTML data-i10n-id ${attrVal} is in ignore list, skip to namespace`);
212-
continue;
213-
}
214-
215-
if (!allMessages.has(attrVal)) {
216-
this.logger.warn(`HTML data-i10n-id '${styleText.blue(attrVal)}' in ${styleText.gray(htmlPath)} do not exist in any FTL message, skip to namespace it.`);
217-
continue;
218-
}
219-
220-
messagesInHTML.add(attrVal);
221-
const namespacedAttr = `${namespace}-${attrVal}`;
222-
htmlContent = htmlContent.replace(matched, `${attrKey}="${namespacedAttr}"`);
223-
this.logger.debug(`HTML data-i10n-id '${styleText.blue(attrVal)}' in ${styleText.gray(htmlPath)} is namespaced to ${styleText.blue(namespacedAttr)}.`);
224-
}
225-
226-
if (build.fluent.prefixFluentMessages)
227-
await writeFile(htmlPath, htmlContent);
228-
}));
229-
230-
// Check miss 1: Cross check in diff locale - seems no need
231-
// messagesByLocale.forEach((messageInThisLang, lang) => {
232-
// // Needs Nodejs 22
233-
// const diff = allMessages.difference(messageInThisLang);
234-
// if (diff.size)
235-
// this.logger.warn(`FTL messages '${Array.from(diff).join(", ")}' don't exist the locale '${lang}'`);
236-
// });
237-
238-
// Check miss 2: Check ids in HTML but not in ftl
239-
messagesInHTML.forEach((messageInHTML) => {
240-
if (ignores.includes(messageInHTML))
241-
return;
242-
243-
const missingLocales = [...messagesByLocale.entries()]
244-
.filter(([_, messages]) => !messages.has(messageInHTML))
245-
.map(([locale]) => locale);
246-
247-
if (missingLocales.length > 0) {
248-
this.logger.warn(`HTML data-l10n-id '${styleText.blue(messageInHTML)}' is missing in locales: ${missingLocales.join(", ")}.`);
249-
}
250-
});
149+
await buildLocale(dist, namespace, build.fluent);
251150
}
252151

253152
async preparePrefs() {
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import type { BaseNode, Message, MessageReference, Resource } from "@fluent/syntax";
2+
import type { BuildConfig } from "../../types/config.js";
3+
import { readFile, writeFile } from "node:fs/promises";
4+
import { basename, dirname } from "node:path";
5+
import { parse, serialize, Transformer } from "@fluent/syntax";
6+
import { move } from "fs-extra/esm";
7+
import styleText from "node-style-text";
8+
import { glob } from "tinyglobby";
9+
import { logger } from "../../utils/logger.js";
10+
import { toArray } from "../../utils/string.js";
11+
12+
export default async function buildLocale(
13+
dist: string,
14+
namespace: string,
15+
options: BuildConfig["fluent"],
16+
) {
17+
const ignores = toArray(options.ignore);
18+
const localeNames = await getLocales(dist);
19+
const messageManager = new MessageManager(ignores);
20+
21+
// Process FTL files and add messages to the manager
22+
await Promise.all(localeNames.map(async (locale) => {
23+
const ftlPaths = await glob(`${dist}/addon/locale/${locale}/**/*.ftl`);
24+
await Promise.all(ftlPaths.map(async (ftlPath) => {
25+
const originalContent = await readFile(ftlPath, "utf-8");
26+
const { messages, processedContent } = processFTLFile(originalContent, namespace, options.prefixFluentMessages);
27+
28+
// Add FTL messages for the current locale
29+
messageManager.addMessages(locale, messages);
30+
31+
if (options.prefixFluentMessages) {
32+
await writeFile(ftlPath, processedContent);
33+
}
34+
35+
if (options.prefixLocaleFiles) {
36+
const newPath = `${dirname(ftlPath)}/${namespace}-${basename(ftlPath)}`;
37+
await move(ftlPath, newPath);
38+
logger.debug(`Renamed FTL: ${ftlPath}${newPath}`);
39+
}
40+
}));
41+
}));
42+
43+
// Process HTML files and add messages to the manager
44+
const htmlPaths = await glob([`${dist}/addon/**/*.xhtml`, `${dist}/addon/**/*.html`]);
45+
await Promise.all(htmlPaths.map(async (htmlPath) => {
46+
const content = await readFile(htmlPath, "utf-8");
47+
const { processedContent, foundMessages } = processHTMLFile(
48+
content,
49+
namespace,
50+
messageManager.getFTLMessages(),
51+
ignores,
52+
htmlPath,
53+
);
54+
55+
// Add all found HTML messages
56+
messageManager.addMessages("html", foundMessages);
57+
58+
if (options.prefixFluentMessages) {
59+
await writeFile(htmlPath, processedContent);
60+
}
61+
}));
62+
63+
// Validate that all HTML messages exist in all locales
64+
messageManager.validateMessages();
65+
}
66+
67+
export class MessageManager {
68+
private ftlMessages: Map<string, Set<string>> = new Map();
69+
private htmlMessages: Set<string> = new Set();
70+
private ignores: string[];
71+
72+
constructor(ignores: string[]) {
73+
this.ignores = ignores;
74+
}
75+
76+
// Add a set of messages (FTL or HTML) for a specific locale or for HTML globally
77+
addMessages(target: string | "html", messages: string[]) {
78+
if (target === "html") {
79+
messages.forEach(msg => this.htmlMessages.add(msg));
80+
}
81+
else {
82+
let ftlLocaleMessages = this.ftlMessages.get(target);
83+
if (!ftlLocaleMessages) {
84+
ftlLocaleMessages = new Set();
85+
this.ftlMessages.set(target, ftlLocaleMessages);
86+
}
87+
messages.forEach(msg => ftlLocaleMessages.add(msg));
88+
}
89+
}
90+
91+
// Validate that all HTML messages exist in all FTL locales
92+
validateMessages() {
93+
this.htmlMessages.forEach((msg) => {
94+
if (this.ignores.includes(msg))
95+
return;
96+
97+
this.ftlMessages.forEach((messages, locale) => {
98+
if (!messages.has(msg)) {
99+
logger.warn(`Missing message: ${styleText.blue(msg)} in locale: ${locale}`);
100+
}
101+
});
102+
});
103+
}
104+
105+
getFTLMessages(): Set<string> {
106+
const allMessages = new Set<string>();
107+
this.ftlMessages.forEach(messages => messages.forEach(msg => allMessages.add(msg)));
108+
return allMessages;
109+
}
110+
111+
// Get all FTL messages for a specific locale
112+
getFTLMessagesByLocale(locale: string): Set<string> {
113+
return this.ftlMessages.get(locale) || new Set();
114+
}
115+
116+
// Get all HTML messages
117+
getHTMLMessages(): Set<string> {
118+
return this.htmlMessages;
119+
}
120+
}
121+
122+
// Step 1: Extract all locale folder names
123+
async function getLocales(dist: string): Promise<string[]> {
124+
const localePaths = await glob(`${dist}/addon/locale/*`, { onlyDirectories: true });
125+
return localePaths.map(p => basename(p));
126+
}
127+
128+
// Parse and optionally prefix messages in an FTL file
129+
export function processFTLFile(
130+
content: string,
131+
namespace: string,
132+
shouldPrefix: boolean,
133+
) {
134+
const messages = extractMessages(content);
135+
const processed = shouldPrefix ? transformFluent(content, namespace) : content;
136+
return { messages, processedContent: processed };
137+
}
138+
139+
// Scan HTML content for l10n references and apply namespace prefix
140+
export function processHTMLFile(
141+
content: string,
142+
namespace: string,
143+
allMessages: Set<string>,
144+
ignores: string[],
145+
filePath: string,
146+
) {
147+
const foundMessages = new Set<string>();
148+
149+
const L10N_PATTERN = new RegExp(`(data-l10n-id)="((?!${namespace})\\S*)"`, "g");
150+
const processed = content.replace(L10N_PATTERN, (match, attr, id) => {
151+
foundMessages.add(id);
152+
153+
if (ignores.includes(id)) {
154+
logger.debug(`Skipped ignored ID: ${styleText.blue(id)} in ${styleText.gray(filePath)}`);
155+
return match;
156+
}
157+
158+
if (!allMessages.has(id)) {
159+
logger.warn(`Missing FTL: ${styleText.blue(id)} in ${styleText.gray(filePath)}`);
160+
return match;
161+
}
162+
163+
return `${attr}="${namespace}-${id}"`;
164+
});
165+
166+
return { processedContent: processed, foundMessages: [...foundMessages] };
167+
}
168+
169+
// Fluent parsing and serialization helpers
170+
export function parseFluent(source: string): Resource {
171+
return parse(source, {});
172+
}
173+
174+
export function extractMessages(source: string): string[] {
175+
return parseFluent(source)
176+
.body
177+
.filter(entry => entry.type === "Message")
178+
.map(message => message.id.name);
179+
}
180+
181+
export function serializeFluent(resource: Resource): string {
182+
return serialize(resource, {});
183+
}
184+
185+
// Prefix Fluent message IDs using a transformer
186+
export function transformFluent(source: string, prefix: string | false): string {
187+
const resource = parseFluent(source);
188+
new FluentTransformer(prefix).genericVisit(resource);
189+
return serializeFluent(resource);
190+
}
191+
192+
// Custom Fluent AST transformer to apply message ID prefix
193+
class FluentTransformer extends Transformer {
194+
constructor(private readonly prefix: string | false) {
195+
super();
196+
}
197+
198+
private needsPrefix(name: string): boolean {
199+
return !!this.prefix && !name.startsWith(this.prefix);
200+
}
201+
202+
visitMessage(node: Message): BaseNode {
203+
if (this.needsPrefix(node.id.name)) {
204+
node.id.name = `${this.prefix}-${node.id.name}`;
205+
}
206+
return this.genericVisit(node);
207+
}
208+
209+
visitMessageReference(node: MessageReference): BaseNode {
210+
if (this.needsPrefix(node.id.name)) {
211+
node.id.name = `${this.prefix}-${node.id.name}`;
212+
}
213+
return this.genericVisit(node);
214+
}
215+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
test-plugin-meaasge-1 = General Settings
2-
test-plugin-meaasge-2-message-1 =
2+
test-plugin-meaasge-2-message-1 =
33
.label = example

0 commit comments

Comments
 (0)