@open-xchange/vite-plugin-po2json 1.0.0-pre1
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.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.js +167 -0
- package/dist/lib/dictionary.d.ts +21 -0
- package/dist/lib/dictionary.js +92 -0
- package/dist/lib/extract.d.ts +2 -0
- package/dist/lib/extract.js +176 -0
- package/dist/lib/po2json.d.ts +9 -0
- package/dist/lib/po2json.js +35 -0
- package/dist/util/util.d.ts +13 -0
- package/dist/util/util.js +23 -0
- package/package.json +44 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
All notable changes to this project will be documented in this file.
|
|
3
|
+
|
|
4
|
+
This plugin evolved from [@open-xchange/rollup-plugin-po2json](https://gitlab.com/openxchange/appsuite/web-foundation/tools/-/tree/main/packages/rollup-plugin-po2json), adapted for Vite 8 and Rolldown.
|
|
5
|
+
|
|
6
|
+
## [1.0.0] - 2026-03-13
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- Fork from `@open-xchange/rollup-plugin-po2json` v0.9.5
|
|
11
|
+
- Migrate to Vite 8 / Rolldown
|
|
12
|
+
- Emit translation chunks in `buildStart` (Rolldown suppresses `resolveDynamicImport` for `@vite-ignore` imports)
|
|
13
|
+
- Set `dictionary: true` in `resolveId` (Rolldown doesn't apply meta from `load` hook)
|
|
14
|
+
- Migrate tests from Rollup to Vite build API
|
|
15
|
+
- Add `vite ^8.0.0` as peer dependency
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 OX Software GmbH, Germany. info@open-xchange.com
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
import type { POHeaders } from './util/util.js';
|
|
3
|
+
import { PROJECT_NAME } from './util/util.js';
|
|
4
|
+
import { namespacesFrom, parsePoFile } from './lib/po2json.js';
|
|
5
|
+
export { PROJECT_NAME, parsePoFile, namespacesFrom };
|
|
6
|
+
/**
|
|
7
|
+
* Configuration options for the Vite plugin "vite-plugin-po2json".
|
|
8
|
+
*/
|
|
9
|
+
export interface GettextPluginOptions {
|
|
10
|
+
poFiles: string;
|
|
11
|
+
outFile?: string;
|
|
12
|
+
defaultDictionary?: string;
|
|
13
|
+
defaultLanguage?: string;
|
|
14
|
+
headers?: POHeaders;
|
|
15
|
+
includeFuzzy?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Type shape of the Vite plugin "vite-plugin-po2json".
|
|
19
|
+
*/
|
|
20
|
+
export interface GettextPlugin extends Plugin {
|
|
21
|
+
/** The resolved configuration options passed to the plugin. */
|
|
22
|
+
meta: Required<GettextPluginOptions>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Type shape of internal module metadata used by the gettext plugin.
|
|
26
|
+
*/
|
|
27
|
+
export interface GettextPluginModuleMeta {
|
|
28
|
+
gettext?: {
|
|
29
|
+
dictionary: boolean;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export default function gettextPlugin(options: GettextPluginOptions): GettextPlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { dataToEsm, normalizePath } from '@rollup/pluginutils';
|
|
2
|
+
import PO from 'pofile';
|
|
3
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { PROJECT_NAME, itemKey, AmbiguousPluralFormException } from './util/util.js';
|
|
6
|
+
import { namespacesFrom, render, parsePoFile } from './lib/po2json.js';
|
|
7
|
+
import { extractItems } from './lib/extract.js';
|
|
8
|
+
export { PROJECT_NAME, parsePoFile, namespacesFrom };
|
|
9
|
+
export default function gettextPlugin(options) {
|
|
10
|
+
const resolvedOptions = {
|
|
11
|
+
outFile: 'i18n.pot',
|
|
12
|
+
defaultDictionary: 'i18n',
|
|
13
|
+
defaultLanguage: 'en_US',
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'text/plain; charset=UTF-8',
|
|
16
|
+
'Content-Transfer-Encoding': '8bit'
|
|
17
|
+
},
|
|
18
|
+
includeFuzzy: false,
|
|
19
|
+
...options
|
|
20
|
+
};
|
|
21
|
+
let resolvedConfig;
|
|
22
|
+
const { poFiles, outFile, defaultDictionary, defaultLanguage } = resolvedOptions;
|
|
23
|
+
const dictionaries = new Set([defaultDictionary]);
|
|
24
|
+
const codeMap = new Map();
|
|
25
|
+
let extractedPo;
|
|
26
|
+
return {
|
|
27
|
+
name: PROJECT_NAME,
|
|
28
|
+
meta: resolvedOptions,
|
|
29
|
+
configResolved(config) {
|
|
30
|
+
resolvedConfig = config;
|
|
31
|
+
},
|
|
32
|
+
async buildStart() {
|
|
33
|
+
const poDir = path.dirname(poFiles);
|
|
34
|
+
const [file] = (await readdir(poDir)).filter(f => f.indexOf(defaultLanguage) >= 0).map(f => normalizePath(`${poDir}/${f}`));
|
|
35
|
+
const po = await parsePoFile(file);
|
|
36
|
+
for (const namespace of namespacesFrom(po.items)) {
|
|
37
|
+
if (namespace !== defaultDictionary)
|
|
38
|
+
dictionaries.add(namespace);
|
|
39
|
+
}
|
|
40
|
+
// Emit translation chunks for all dictionaries and languages.
|
|
41
|
+
// In Rollup this was done in resolveDynamicImport, but Rolldown
|
|
42
|
+
// suppresses that hook for @vite-ignore dynamic imports.
|
|
43
|
+
if (resolvedConfig?.mode === 'production') {
|
|
44
|
+
const files = (await readdir(poDir)).map(f => normalizePath(`${poDir}/${f}`));
|
|
45
|
+
for (const file of files) {
|
|
46
|
+
const language = (file.match(/([^/]+).po$/) || [])[1];
|
|
47
|
+
if (!language)
|
|
48
|
+
continue;
|
|
49
|
+
for (const namespace of dictionaries) {
|
|
50
|
+
this.emitFile({
|
|
51
|
+
type: 'chunk',
|
|
52
|
+
id: `${namespace}.${language}.js`,
|
|
53
|
+
fileName: `${namespace}.${language}.js`,
|
|
54
|
+
preserveSignature: 'strict'
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
resolveId(id) {
|
|
61
|
+
if (/^gettext$/.test(id)) {
|
|
62
|
+
const meta = { gettext: { dictionary: true } };
|
|
63
|
+
return { id: `${defaultDictionary}.js`, meta };
|
|
64
|
+
}
|
|
65
|
+
const gettextModuleMatch = id.match(/gettext\?.*dictionary=([^&]+)/);
|
|
66
|
+
if (gettextModuleMatch) {
|
|
67
|
+
dictionaries.add(gettextModuleMatch[1].replace(/\.js$/, ''));
|
|
68
|
+
const meta = { gettext: { dictionary: true } };
|
|
69
|
+
return { id: gettextModuleMatch[1].replace(/(\.js)?$/, '.js'), meta };
|
|
70
|
+
}
|
|
71
|
+
if (dictionaries.has(id.replace(/\.js$/, ''))) {
|
|
72
|
+
const meta = { gettext: { dictionary: true } };
|
|
73
|
+
return { id, meta };
|
|
74
|
+
}
|
|
75
|
+
const match = id.match(/(.*)\.([a-zA-Z]{2}_[a-zA-Z]{2})(\.js)?$/);
|
|
76
|
+
const [, namespace, language] = match || [];
|
|
77
|
+
return (namespace && language) ? id : null;
|
|
78
|
+
},
|
|
79
|
+
transform: {
|
|
80
|
+
order: 'pre',
|
|
81
|
+
handler(code, id) {
|
|
82
|
+
codeMap.set(id, code);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
async load(id) {
|
|
86
|
+
const dictionary = id.replace(/\\/g, '/').replace(/^\.?\//, '').replace(/\.js$/, '');
|
|
87
|
+
if (dictionaries.has(dictionary)) {
|
|
88
|
+
const poDir = path.dirname(poFiles);
|
|
89
|
+
const languages = (await readdir(poDir)).map(f => (f.match(/([^/]+).po$/) || [])[1]).filter(a => Boolean(a));
|
|
90
|
+
const code = (await readFile(new URL('./lib/dictionary.js', import.meta.url))).toString()
|
|
91
|
+
.replaceAll('__dict_module__', `${dictionary}.${defaultLanguage}`)
|
|
92
|
+
.replaceAll('{{namespace}}', dictionary)
|
|
93
|
+
.replaceAll('{{defaultLanguage}}', defaultLanguage)
|
|
94
|
+
.replaceAll(/['"]{{languages}}['"]/g, languages.map(lang => `'${lang}'`).join(', '));
|
|
95
|
+
return { meta: { gettext: { dictionary: true } }, code };
|
|
96
|
+
}
|
|
97
|
+
const match = id.match(/\.?\/?(.*)\.([a-zA-Z]{2}_[a-zA-Z]{2})(\.js)?$/);
|
|
98
|
+
const [, namespace, language] = match || [];
|
|
99
|
+
if (!namespace || !language || !dictionaries.has(namespace))
|
|
100
|
+
return;
|
|
101
|
+
const filename = normalizePath(`${path.dirname(poFiles)}/${language}.po`);
|
|
102
|
+
const chunk = render(await parsePoFile(filename), options);
|
|
103
|
+
return dataToEsm(chunk);
|
|
104
|
+
},
|
|
105
|
+
buildEnd() {
|
|
106
|
+
// optimize for vite development mode, no need to do anything on buildEnd (server stop)
|
|
107
|
+
if (resolvedConfig?.mode === 'development')
|
|
108
|
+
return;
|
|
109
|
+
const po = new PO();
|
|
110
|
+
Object.assign(po.headers, resolvedOptions.headers);
|
|
111
|
+
const gettextImporters = () => {
|
|
112
|
+
const importers = new Set();
|
|
113
|
+
for (const id of this.getModuleIds()) {
|
|
114
|
+
const moduleInfo = this.getModuleInfo(id);
|
|
115
|
+
if (moduleInfo && moduleInfo.meta.gettext?.dictionary) {
|
|
116
|
+
for (const importer of moduleInfo.importers) {
|
|
117
|
+
importers.add(importer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return importers;
|
|
122
|
+
};
|
|
123
|
+
const allItems = new Map();
|
|
124
|
+
for (const importer of gettextImporters()) {
|
|
125
|
+
const moduleInfo = this.getModuleInfo(importer);
|
|
126
|
+
const code = moduleInfo && codeMap.get(moduleInfo.id);
|
|
127
|
+
if (code) {
|
|
128
|
+
for (const [key, msgItem] of extractItems(moduleInfo.id, code)) {
|
|
129
|
+
let item = allItems.get(key);
|
|
130
|
+
if (!item)
|
|
131
|
+
allItems.set(key, item = msgItem);
|
|
132
|
+
// add module reference
|
|
133
|
+
item.references.push(normalizePath(path.relative(process.cwd(), moduleInfo.id)));
|
|
134
|
+
// merge extracted comments
|
|
135
|
+
for (const comment of msgItem.extractedComments) {
|
|
136
|
+
if (item.extractedComments.filter(c => c === comment).length === 0)
|
|
137
|
+
item.extractedComments.push(comment);
|
|
138
|
+
}
|
|
139
|
+
// merge flags
|
|
140
|
+
Object.assign(item.flags, msgItem.flags);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const item of allItems.values()) {
|
|
145
|
+
// detect colliding singular/plural
|
|
146
|
+
if (item.msgid_plural) {
|
|
147
|
+
const collidingItem = allItems.get(itemKey(item, false));
|
|
148
|
+
if (collidingItem)
|
|
149
|
+
throw new AmbiguousPluralFormException(item, collidingItem);
|
|
150
|
+
}
|
|
151
|
+
if (options.defaultDictionary)
|
|
152
|
+
item.references.push(`module:${defaultDictionary}`);
|
|
153
|
+
po.items.push(item);
|
|
154
|
+
}
|
|
155
|
+
extractedPo = po;
|
|
156
|
+
},
|
|
157
|
+
generateBundle() {
|
|
158
|
+
const po = extractedPo;
|
|
159
|
+
this.emitFile({
|
|
160
|
+
name: outFile,
|
|
161
|
+
fileName: outFile,
|
|
162
|
+
type: 'asset',
|
|
163
|
+
source: po.toString()
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { POHeaders, PODictionary } from '../util/util.js';
|
|
2
|
+
type PluralFn = (n: number) => number;
|
|
3
|
+
export declare class Dictionary {
|
|
4
|
+
readonly namespace: string;
|
|
5
|
+
private _headers;
|
|
6
|
+
dict: PODictionary;
|
|
7
|
+
plural: PluralFn;
|
|
8
|
+
constructor(namespace: string, headers: POHeaders, dict: PODictionary);
|
|
9
|
+
set headers(headers: POHeaders);
|
|
10
|
+
get headers(): POHeaders;
|
|
11
|
+
changeLanguage(language: string): Promise<void>;
|
|
12
|
+
gettext(str: string, ...args: unknown[]): string;
|
|
13
|
+
pgettext(context: string, str: string, ...args: unknown[]): string;
|
|
14
|
+
ngettext(singular: string, plural: string, count: number, ...args: unknown[]): string;
|
|
15
|
+
npgettext(context: string, singular: string, plural: string, count: number, ...args: unknown[]): string;
|
|
16
|
+
noI18n(a: string): string;
|
|
17
|
+
}
|
|
18
|
+
export declare const dictionary: Dictionary;
|
|
19
|
+
declare function simpleGt(str: string, ...args: unknown[]): string;
|
|
20
|
+
export declare const gt: typeof simpleGt;
|
|
21
|
+
export default gt;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import defaultDict, { headers as defaultHeaders } from './__dict_module__';
|
|
2
|
+
function printf(str, params) {
|
|
3
|
+
let index = 0;
|
|
4
|
+
return str
|
|
5
|
+
.replace(/%(([0-9]+)\$)?[A-Za-z]/g, (_match, pos, n) => {
|
|
6
|
+
if (pos)
|
|
7
|
+
index = n - 1;
|
|
8
|
+
const val = params[index++];
|
|
9
|
+
return val !== undefined ? String(val) : 'unknown';
|
|
10
|
+
})
|
|
11
|
+
.replace(/%%/g, '%');
|
|
12
|
+
}
|
|
13
|
+
function parsePluralHeader(header) {
|
|
14
|
+
const matches = header?.match(/plural=([^;]+)/);
|
|
15
|
+
return matches ? matches[1] : '(n!=1)';
|
|
16
|
+
}
|
|
17
|
+
const languages = ['{{languages}}'];
|
|
18
|
+
export class Dictionary {
|
|
19
|
+
namespace;
|
|
20
|
+
_headers;
|
|
21
|
+
dict;
|
|
22
|
+
plural;
|
|
23
|
+
constructor(namespace, headers, dict) {
|
|
24
|
+
this.namespace = namespace;
|
|
25
|
+
this.headers = headers;
|
|
26
|
+
this.dict = dict;
|
|
27
|
+
}
|
|
28
|
+
set headers(headers) {
|
|
29
|
+
this._headers = headers;
|
|
30
|
+
const plural = parsePluralHeader(this.headers['Plural-Forms']);
|
|
31
|
+
// eslint-disable-next-line no-new-func
|
|
32
|
+
this.plural = new Function('n', `return Number(${plural});`);
|
|
33
|
+
}
|
|
34
|
+
get headers() {
|
|
35
|
+
return this._headers;
|
|
36
|
+
}
|
|
37
|
+
async changeLanguage(language) {
|
|
38
|
+
if (language === this.headers.Language)
|
|
39
|
+
return;
|
|
40
|
+
if (!languages.includes(language))
|
|
41
|
+
throw new Error(`Language not found: ${language} (in ${languages.join(', ')})`);
|
|
42
|
+
// prevent rollup dynamic import vars plugin from detecting this import
|
|
43
|
+
// this is only needed in case the plugin is in use, but doesn't hurt
|
|
44
|
+
let path;
|
|
45
|
+
try {
|
|
46
|
+
if (typeof window !== 'undefined' && typeof process === 'undefined' && window.location?.href) {
|
|
47
|
+
path = new URL(`${this.namespace}.${language}.js`, window.location.href).href;
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
throw new Error('Not in browser');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
path = `./${this.namespace.replace(/.*\//, '')}.${language}.js`;
|
|
55
|
+
}
|
|
56
|
+
const { default: dict, headers } = await import(/* @vite-ignore */ path);
|
|
57
|
+
headers.Language = language;
|
|
58
|
+
this.headers = headers;
|
|
59
|
+
this.dict = dict;
|
|
60
|
+
}
|
|
61
|
+
gettext(str, ...args) {
|
|
62
|
+
return this.npgettext('', str, '', 1, ...args);
|
|
63
|
+
}
|
|
64
|
+
pgettext(context, str, ...args) {
|
|
65
|
+
return this.npgettext(context, str, '', 1, ...args);
|
|
66
|
+
}
|
|
67
|
+
ngettext(singular, plural, count, ...args) {
|
|
68
|
+
return this.npgettext('', singular, plural, count, ...args);
|
|
69
|
+
}
|
|
70
|
+
npgettext(context, singular, plural, count, ...args) {
|
|
71
|
+
const translations = this.dict[`${context}\x00${singular}\x01${plural}`] || [];
|
|
72
|
+
if (translations.length === 0)
|
|
73
|
+
translations.push(singular, plural);
|
|
74
|
+
const translation = translations[this.plural(Number(count))] || translations[0];
|
|
75
|
+
return args.length ? printf(translation, args) : translation;
|
|
76
|
+
}
|
|
77
|
+
noI18n(a) {
|
|
78
|
+
return a;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
defaultHeaders.Language = '{{defaultLanguage}}';
|
|
82
|
+
export const dictionary = new Dictionary('{{namespace}}', defaultHeaders, defaultDict);
|
|
83
|
+
function simpleGt(str, ...args) {
|
|
84
|
+
return dictionary.npgettext('', str, '', 1, ...args);
|
|
85
|
+
}
|
|
86
|
+
export const gt = new Proxy(simpleGt, {
|
|
87
|
+
get(_target, prop) {
|
|
88
|
+
const value = dictionary[prop];
|
|
89
|
+
return (typeof value === 'function') ? value.bind(dictionary) : value;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
export default gt;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import PO from 'pofile';
|
|
3
|
+
import { itemKey } from '../util/util.js';
|
|
4
|
+
// expected gettext module name in import statements of parsed code
|
|
5
|
+
const IMPORT_MODULE_NAME = 'gettext'; // TODO: make configurable?
|
|
6
|
+
function walk(node, cb) {
|
|
7
|
+
cb(node);
|
|
8
|
+
ts.forEachChild(node, child => walk(child, cb));
|
|
9
|
+
}
|
|
10
|
+
function isImportDeclaration(node) {
|
|
11
|
+
return node.kind === ts.SyntaxKind.ImportDeclaration;
|
|
12
|
+
}
|
|
13
|
+
function isNamedImports(node) {
|
|
14
|
+
return node.kind === ts.SyntaxKind.NamedImports;
|
|
15
|
+
}
|
|
16
|
+
function isCallExpression(node) {
|
|
17
|
+
return node.kind === ts.SyntaxKind.CallExpression;
|
|
18
|
+
}
|
|
19
|
+
function isMemberExpression(node) {
|
|
20
|
+
return node.kind === ts.SyntaxKind.PropertyAccessExpression;
|
|
21
|
+
}
|
|
22
|
+
function isBinaryExpression(node) {
|
|
23
|
+
return node.kind === ts.SyntaxKind.BinaryExpression;
|
|
24
|
+
}
|
|
25
|
+
function isIdentifier(node) {
|
|
26
|
+
return node.kind === ts.SyntaxKind.Identifier;
|
|
27
|
+
}
|
|
28
|
+
function isStringLiteral(node) {
|
|
29
|
+
return node.kind === ts.SyntaxKind.StringLiteral;
|
|
30
|
+
}
|
|
31
|
+
function isString(value) {
|
|
32
|
+
return typeof value === 'string';
|
|
33
|
+
}
|
|
34
|
+
function getStringLiteral(node) {
|
|
35
|
+
return isStringLiteral(node) ? node.text : undefined;
|
|
36
|
+
}
|
|
37
|
+
function resolveStringExpression(node) {
|
|
38
|
+
if (isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
39
|
+
const leftValue = resolveStringExpression(node.left);
|
|
40
|
+
const rightValue = getStringLiteral(node.right);
|
|
41
|
+
return (isString(leftValue) && isString(rightValue)) ? (leftValue + rightValue) : undefined;
|
|
42
|
+
}
|
|
43
|
+
return getStringLiteral(node);
|
|
44
|
+
}
|
|
45
|
+
export function extractItems(id, code) {
|
|
46
|
+
// let TypeScript parse the source code
|
|
47
|
+
const sourceFile = ts.createSourceFile(id, code, ts.ScriptTarget.ESNext);
|
|
48
|
+
// all comments starting with '#', mapped by line number
|
|
49
|
+
const comments = new Map();
|
|
50
|
+
// extracts the text of comments from TS comment ranges
|
|
51
|
+
function processCommentRanges(commentRanges) {
|
|
52
|
+
if (commentRanges) {
|
|
53
|
+
for (const commentRange of commentRanges) {
|
|
54
|
+
const block = commentRange.kind === ts.SyntaxKind.MultiLineCommentTrivia;
|
|
55
|
+
const comment = code.slice(commentRange.pos, commentRange.end);
|
|
56
|
+
const text = comment.replace(block ? /^\/\*|\*\/$/g : /^\/\//, '').trim();
|
|
57
|
+
if (text.startsWith('#')) {
|
|
58
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(commentRange.pos);
|
|
59
|
+
comments.set(line, { text, block });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// names of identifiers used as 'gettext' function names
|
|
65
|
+
const identifiers = new Set();
|
|
66
|
+
// the extracted PO items
|
|
67
|
+
const items = new Map();
|
|
68
|
+
const mapOfSets = new Map();
|
|
69
|
+
walk(sourceFile, node => {
|
|
70
|
+
// collect all comments surrounding the node
|
|
71
|
+
processCommentRanges(ts.getLeadingCommentRanges(code, node.getFullStart()));
|
|
72
|
+
processCommentRanges(ts.getTrailingCommentRanges(code, node.pos));
|
|
73
|
+
// collect all import statements used as 'gt' function
|
|
74
|
+
if (isImportDeclaration(node)) {
|
|
75
|
+
// skip side effect or type imports
|
|
76
|
+
if (node.importClause && !node.importClause.isTypeOnly) {
|
|
77
|
+
const source = getStringLiteral(node.moduleSpecifier);
|
|
78
|
+
// module name may be followed by query string
|
|
79
|
+
if (source === IMPORT_MODULE_NAME || source?.startsWith(IMPORT_MODULE_NAME + '?')) {
|
|
80
|
+
// default import
|
|
81
|
+
const defaultImport = node.importClause.name;
|
|
82
|
+
if (defaultImport && !node.importClause.isTypeOnly)
|
|
83
|
+
identifiers.add(defaultImport.text);
|
|
84
|
+
// named imports
|
|
85
|
+
const bindings = node.importClause.namedBindings;
|
|
86
|
+
if (bindings && isNamedImports(bindings)) {
|
|
87
|
+
for (const element of bindings.elements) {
|
|
88
|
+
if (!element.isTypeOnly)
|
|
89
|
+
identifiers.add(element.name.text);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (isCallExpression(node)) {
|
|
97
|
+
let id;
|
|
98
|
+
let msgctxt;
|
|
99
|
+
let msgid;
|
|
100
|
+
let msgidPlural;
|
|
101
|
+
const callExpression = node.expression;
|
|
102
|
+
const argsCount = node.arguments.length;
|
|
103
|
+
// simple function call, e.g. `gt('string')`
|
|
104
|
+
if (isIdentifier(callExpression) && argsCount >= 1) {
|
|
105
|
+
id = callExpression.text;
|
|
106
|
+
msgid = resolveStringExpression(node.arguments[0]);
|
|
107
|
+
}
|
|
108
|
+
// simple member call, e.g. `gt.pgettext('context', 'string')`
|
|
109
|
+
if (isMemberExpression(callExpression) && isIdentifier(callExpression.expression) && isIdentifier(callExpression.name)) {
|
|
110
|
+
id = callExpression.expression.text;
|
|
111
|
+
switch (callExpression.name.text) {
|
|
112
|
+
case 'pgettext':
|
|
113
|
+
if (argsCount >= 2) {
|
|
114
|
+
msgctxt = getStringLiteral(node.arguments[0]);
|
|
115
|
+
msgid = resolveStringExpression(node.arguments[1]);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
case 'ngettext':
|
|
119
|
+
if (argsCount >= 2) {
|
|
120
|
+
msgid = resolveStringExpression(node.arguments[0]);
|
|
121
|
+
msgidPlural = resolveStringExpression(node.arguments[1]);
|
|
122
|
+
}
|
|
123
|
+
break;
|
|
124
|
+
case 'npgettext':
|
|
125
|
+
if (argsCount >= 3) {
|
|
126
|
+
msgctxt = getStringLiteral(node.arguments[0]);
|
|
127
|
+
msgid = resolveStringExpression(node.arguments[1]);
|
|
128
|
+
msgidPlural = resolveStringExpression(node.arguments[2]);
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (id && msgid) {
|
|
134
|
+
const item = new PO.Item();
|
|
135
|
+
item.msgctxt = msgctxt;
|
|
136
|
+
item.msgid = msgid;
|
|
137
|
+
item.msgid_plural = msgidPlural;
|
|
138
|
+
// extract comments and flags
|
|
139
|
+
const start = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line;
|
|
140
|
+
for (let line = start; true; line--) {
|
|
141
|
+
const comment = comments.get(line);
|
|
142
|
+
comments.delete(line);
|
|
143
|
+
if (!comment && (line === start))
|
|
144
|
+
continue; // comment may be missing on same line
|
|
145
|
+
if (!comment || (comment.block && line < start))
|
|
146
|
+
break; // exit loop if no more line comments available
|
|
147
|
+
const marker = comment.text.slice(0, 2);
|
|
148
|
+
const text = comment.text.slice(2).trim();
|
|
149
|
+
switch (marker) {
|
|
150
|
+
case '#.':
|
|
151
|
+
item.extractedComments.unshift(text);
|
|
152
|
+
break;
|
|
153
|
+
case '#,':
|
|
154
|
+
item.flags[text] = true;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
// automatically add 'c-format' flag when using placeholders in the message
|
|
159
|
+
const regex = /%([idufFgG]|\d+\$)/;
|
|
160
|
+
if (item.msgid.match(regex) || item.msgid_plural?.match(regex)) {
|
|
161
|
+
item.flags['c-format'] = true;
|
|
162
|
+
}
|
|
163
|
+
let itemSet = mapOfSets.get(id);
|
|
164
|
+
if (!itemSet)
|
|
165
|
+
mapOfSets.set(id, itemSet = []);
|
|
166
|
+
itemSet.push(item);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
identifiers.forEach(id => {
|
|
171
|
+
mapOfSets.get(id)?.forEach(item => {
|
|
172
|
+
items.set(itemKey(item), item);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
return items;
|
|
176
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import PO from 'pofile';
|
|
2
|
+
import type { GettextPluginOptions } from '../index.js';
|
|
3
|
+
import type { POHeaders, PODictionary, POItem } from '../util/util.js';
|
|
4
|
+
export type PORenderedChunk = PODictionary & {
|
|
5
|
+
headers: POHeaders;
|
|
6
|
+
};
|
|
7
|
+
export declare function render(po: PO, { includeFuzzy }: GettextPluginOptions): PORenderedChunk;
|
|
8
|
+
export declare function namespacesFrom(items: POItem[]): string[];
|
|
9
|
+
export declare function parsePoFile(file: string): Promise<PO>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import PO from 'pofile';
|
|
2
|
+
import { itemKey } from '../util/util.js';
|
|
3
|
+
function isFuzzy(item) {
|
|
4
|
+
return !item.flags.fuzzy;
|
|
5
|
+
}
|
|
6
|
+
// test if translations are blank/empty
|
|
7
|
+
function hasTranslation(item) {
|
|
8
|
+
return item.msgstr.join('').length > 0;
|
|
9
|
+
}
|
|
10
|
+
export function render(po, { includeFuzzy }) {
|
|
11
|
+
const dict = {};
|
|
12
|
+
for (const item of po.items) {
|
|
13
|
+
if ((includeFuzzy || isFuzzy(item)) && !item.obsolete && hasTranslation(item)) {
|
|
14
|
+
dict[itemKey(item)] = item.msgstr;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return Object.assign({ headers: po.headers }, dict);
|
|
18
|
+
}
|
|
19
|
+
export function namespacesFrom(items) {
|
|
20
|
+
const namespaces = new Set();
|
|
21
|
+
for (const item of items) {
|
|
22
|
+
for (const reference of item.references) {
|
|
23
|
+
const matches = reference.match(/module:([^\s]+)/);
|
|
24
|
+
if (matches) {
|
|
25
|
+
namespaces.add(matches[1]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return Array.from(namespaces);
|
|
30
|
+
}
|
|
31
|
+
export function parsePoFile(file) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
PO.load(file, (err, po) => err ? reject(err) : resolve(po));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type PO from 'pofile';
|
|
2
|
+
export type { PO };
|
|
3
|
+
export type POHeaders = PO['headers'];
|
|
4
|
+
export type PODictionary = Record<string, string[]>;
|
|
5
|
+
export type POItem = InstanceType<typeof PO.Item>;
|
|
6
|
+
export declare const PROJECT_NAME = "gettext";
|
|
7
|
+
export declare function itemKey(item: POItem, plural?: boolean): string;
|
|
8
|
+
export declare class AmbiguousPluralFormException extends Error {
|
|
9
|
+
readonly poItem: POItem;
|
|
10
|
+
readonly collidingPoItem: POItem;
|
|
11
|
+
constructor(poItem: POItem, collidingPoItem: POItem);
|
|
12
|
+
toString(): string;
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export const PROJECT_NAME = 'gettext';
|
|
2
|
+
export function itemKey(item, plural = true) {
|
|
3
|
+
return `${item.msgctxt || ''}\x00${item.msgid}\x01${(plural && item.msgid_plural) || ''}`;
|
|
4
|
+
}
|
|
5
|
+
export class AmbiguousPluralFormException extends Error {
|
|
6
|
+
poItem;
|
|
7
|
+
collidingPoItem;
|
|
8
|
+
constructor(poItem, collidingPoItem) {
|
|
9
|
+
super();
|
|
10
|
+
this.poItem = poItem;
|
|
11
|
+
this.collidingPoItem = collidingPoItem;
|
|
12
|
+
this.message = this.toString();
|
|
13
|
+
}
|
|
14
|
+
toString() {
|
|
15
|
+
return `
|
|
16
|
+
Usage of singular and plural form of "${this.poItem.msgid}" in the same context:
|
|
17
|
+
${(this.collidingPoItem.msgctxt || '[no context]')}
|
|
18
|
+
\n\nReferences:\n${this.poItem.references.join(', ')}\n
|
|
19
|
+
\n${this.collidingPoItem.references.join(', ')}
|
|
20
|
+
\n\nChange the context of at least one of the strings (pgettext) or use ngettext/npgettext in both cases.
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-xchange/vite-plugin-po2json",
|
|
3
|
+
"version": "1.0.0-pre1",
|
|
4
|
+
"description": "A Vite plugin to load po files into our gettext based dictionary implementation.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://gitlab.com/openxchange/appsuite/web-foundation/tools",
|
|
11
|
+
"directory": "packages/vite-plugin-po2json"
|
|
12
|
+
},
|
|
13
|
+
"author": "App Suite Web Foundation <app-suite-web-foundation@open-xchange.com>",
|
|
14
|
+
"contributors": [
|
|
15
|
+
"Daniel Rentz <daniel.rentz@open-xchange.com>",
|
|
16
|
+
"David Bauer <david.bauer@open-xchange.com>",
|
|
17
|
+
"Julian Bäume <julian.baeume@open-xchange.com>",
|
|
18
|
+
"Maik Schäfer <maik.schaefer@open-xchange.com>",
|
|
19
|
+
"Richard Petersen"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@rollup/pluginutils": "^5.3.0",
|
|
24
|
+
"pofile": "^1.1.4",
|
|
25
|
+
"typescript": "^5.9.3"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"vite": "^8.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@rollup/plugin-dynamic-import-vars": "^2.1.5",
|
|
32
|
+
"@types/node": "^24.10.3",
|
|
33
|
+
"cross-env": "^10.1.0",
|
|
34
|
+
"vite": "^8.0.0",
|
|
35
|
+
"vitest": "^4.0.15",
|
|
36
|
+
"@open-xchange/lint": "0.2.1"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"lint": "eslint .",
|
|
40
|
+
"build": "tsc",
|
|
41
|
+
"dev": "pnpm dlx nodemon --watch src --ext ts --exec tsc",
|
|
42
|
+
"test": "tsc && vitest"
|
|
43
|
+
}
|
|
44
|
+
}
|