Skip to content

Commit 16f3ad0

Browse files
authored
fix(scaffold/builder): namespace MessageReference in ftl (#115)
* refactor: migrate to ``@fluent/syntax` * refactor: merge parse, serialize, etc * test: add cases * fix: regress logger
1 parent 86bf702 commit 16f3ad0

6 files changed

Lines changed: 434 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: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
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 paths = await glob(`${dist}/addon/locale/${locale}/**/*.ftl`);
24+
await Promise.all(paths.map(async (path) => {
25+
const fm = new FluentManager();
26+
await fm.read(path);
27+
messageManager.addMessages(locale, fm.getMessages());
28+
29+
if (options.prefixFluentMessages) {
30+
fm.prefixMessages(namespace);
31+
await fm.write(path);
32+
}
33+
34+
if (options.prefixLocaleFiles) {
35+
const newPath = `${dirname(path)}/${namespace}-${basename(path)}`;
36+
await move(path, newPath);
37+
logger.debug(`Renamed FTL: ${path}${newPath}`);
38+
}
39+
}));
40+
}));
41+
42+
// Process HTML files and add messages to the manager
43+
const htmlPaths = await glob([`${dist}/addon/**/*.xhtml`, `${dist}/addon/**/*.html`]);
44+
await Promise.all(htmlPaths.map(async (htmlPath) => {
45+
const content = await readFile(htmlPath, "utf-8");
46+
const { processedContent, foundMessages } = processHTMLFile(
47+
content,
48+
namespace,
49+
messageManager.getFTLMessages(),
50+
ignores,
51+
htmlPath,
52+
);
53+
54+
// Add all found HTML messages
55+
messageManager.addMessages("html", foundMessages);
56+
57+
if (options.prefixFluentMessages) {
58+
await writeFile(htmlPath, processedContent);
59+
}
60+
}));
61+
62+
// Validate that all HTML messages exist in all locales
63+
messageManager.validateMessages();
64+
}
65+
66+
async function getLocales(dist: string): Promise<string[]> {
67+
const localePaths = await glob(`${dist}/addon/locale/*`, { onlyDirectories: true });
68+
return localePaths.map(p => basename(p));
69+
}
70+
71+
export class FluentManager {
72+
private source?: string;
73+
private resource?: Resource;
74+
public readonly messages: string[] = [];
75+
76+
constructor() {}
77+
78+
// Parse Fluent source into an AST and extract messages
79+
public parse(source: string) {
80+
this.source = source;
81+
this.resource = parse(source, {});
82+
}
83+
84+
// Read a file, parse its content, and extract messages
85+
public async read(path: string) {
86+
const content = await readFile(path, "utf-8");
87+
this.parse(content);
88+
}
89+
90+
// Extract message IDs from the parsed resource
91+
public getMessages(): string[] {
92+
if (!this.resource) {
93+
throw new Error("Resource must be parsed first.");
94+
}
95+
this.messages.length = 0; // Clear the previous messages
96+
this.messages.push(
97+
...this.resource.body.filter(entry => entry.type === "Message")
98+
.map(message => message.id.name),
99+
);
100+
return this.messages;
101+
}
102+
103+
// Apply namespace prefix to message IDs in the resource
104+
public prefixMessages(namespace: string) {
105+
if (!this.resource) {
106+
throw new Error("Resource must be parsed before applying prefix.");
107+
}
108+
new FluentTransformer(namespace).genericVisit(this.resource);
109+
}
110+
111+
// Serialize the resource back into a string
112+
public serialize(): string {
113+
if (!this.resource) {
114+
throw new Error("Resource not parsed. Cannot serialize.");
115+
}
116+
return serialize(this.resource, {});
117+
}
118+
119+
// Write the serialized resource to a file
120+
public async write(path: string) {
121+
const result = this.serialize();
122+
if (result !== this.source)
123+
await writeFile(path, this.serialize());
124+
}
125+
}
126+
// Custom Fluent AST transformer to apply message ID prefix
127+
class FluentTransformer extends Transformer {
128+
constructor(private readonly prefix: string | false) {
129+
super();
130+
}
131+
132+
private needsPrefix(name: string): boolean {
133+
return !!this.prefix && !name.startsWith(this.prefix);
134+
}
135+
136+
visitMessage(node: Message): BaseNode {
137+
if (this.needsPrefix(node.id.name)) {
138+
node.id.name = `${this.prefix}-${node.id.name}`;
139+
}
140+
return this.genericVisit(node);
141+
}
142+
143+
visitMessageReference(node: MessageReference): BaseNode {
144+
if (this.needsPrefix(node.id.name)) {
145+
node.id.name = `${this.prefix}-${node.id.name}`;
146+
}
147+
return this.genericVisit(node);
148+
}
149+
}
150+
151+
export class MessageManager {
152+
private ftlMessages: Map<string, Set<string>> = new Map();
153+
private htmlMessages: Set<string> = new Set();
154+
private ignores: string[];
155+
156+
constructor(ignores: string[]) {
157+
this.ignores = ignores;
158+
}
159+
160+
// Add a set of messages (FTL or HTML) for a specific locale or for HTML globally
161+
addMessages(target: string | "html", messages: string[]) {
162+
if (target === "html") {
163+
messages.forEach(msg => this.htmlMessages.add(msg));
164+
}
165+
else {
166+
let ftlLocaleMessages = this.ftlMessages.get(target);
167+
if (!ftlLocaleMessages) {
168+
ftlLocaleMessages = new Set();
169+
this.ftlMessages.set(target, ftlLocaleMessages);
170+
}
171+
messages.forEach(msg => ftlLocaleMessages.add(msg));
172+
}
173+
}
174+
175+
validateMessages() {
176+
// Check miss 1: Cross check in diff locale - seems no need
177+
// messagesByLocale.forEach((messageInThisLang, lang) => {
178+
// // Needs Nodejs 22
179+
// const diff = allMessages.difference(messageInThisLang);
180+
// if (diff.size)
181+
// this.logger.warn(`FTL messages '${Array.from(diff).join(", ")}' don't exist the locale '${lang}'`);
182+
// });
183+
184+
// Check miss 2: Check ids in HTML but not in ftl
185+
this.htmlMessages.forEach((msg) => {
186+
if (this.ignores.includes(msg))
187+
return;
188+
189+
const missingLocales = [...this.ftlMessages.entries()]
190+
.filter(([_, messages]) => !messages.has(msg))
191+
.map(([locale]) => locale);
192+
if (missingLocales.length > 0)
193+
logger.warn(`I10N id ${styleText.blue(msg)} missing in locale: ${missingLocales.join(", ")}`);
194+
});
195+
}
196+
197+
getFTLMessages(): Set<string> {
198+
const allMessages = new Set<string>();
199+
this.ftlMessages.forEach(messages => messages.forEach(msg => allMessages.add(msg)));
200+
return allMessages;
201+
}
202+
203+
// Get all FTL messages for a specific locale
204+
getFTLMessagesByLocale(locale: string): Set<string> {
205+
return this.ftlMessages.get(locale) || new Set();
206+
}
207+
208+
// Get all HTML messages
209+
getHTMLMessages(): Set<string> {
210+
return this.htmlMessages;
211+
}
212+
}
213+
214+
// Scan HTML content for l10n references and apply namespace prefix
215+
export function processHTMLFile(
216+
content: string,
217+
namespace: string,
218+
allMessages: Set<string>,
219+
ignores: string[],
220+
filePath: string,
221+
) {
222+
const foundMessages = new Set<string>();
223+
224+
const L10N_PATTERN = new RegExp(`(data-l10n-id)="((?!${namespace})\\S*)"`, "g");
225+
const processed = content.replace(L10N_PATTERN, (match, attr, id) => {
226+
foundMessages.add(id);
227+
228+
if (ignores.includes(id)) {
229+
logger.debug(`Skipped ignored ID: ${styleText.blue(id)} in ${styleText.gray(filePath)}`);
230+
return match;
231+
}
232+
233+
if (!allMessages.has(id)) {
234+
logger.warn(`I10N id ${styleText.blue(id)} in path ${styleText.gray(filePath)} does not exist in any locale, skip renaming it.`);
235+
return match;
236+
}
237+
238+
return `${attr}="${namespace}-${id}"`;
239+
});
240+
241+
return { processedContent: processed, foundMessages: [...foundMessages] };
242+
}
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)