forked from mirrors/misskey
Follow up per locale bundle (#16381)
* fix docker build * enable check spdx license id in frontend-builder * fix eslint config * run eslint for frontend-builder in ci * fix eslint * add license headers * fix unnecessary comments * update changelog * fix generateDts * fix tsx
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MagicString from 'magic-string';
|
||||
import type { Locale } from '../../../locales/index.js';
|
||||
import { assertNever } from '../utils.js';
|
||||
import type { Locale, ILocale } from '../../../locales/index.js';
|
||||
import type { TextModification } from '../locale-inliner.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
@@ -13,16 +18,16 @@ export function applyWithLocale(
|
||||
) {
|
||||
for (const modification of modifications) {
|
||||
switch (modification.type) {
|
||||
case "delete":
|
||||
case 'delete':
|
||||
sourceCode.remove(modification.begin, modification.end);
|
||||
break;
|
||||
case "insert":
|
||||
case 'insert':
|
||||
sourceCode.appendRight(modification.begin, modification.text);
|
||||
break;
|
||||
case "replace":
|
||||
case 'replace':
|
||||
sourceCode.update(modification.begin, modification.end, modification.text);
|
||||
break;
|
||||
case "localized": {
|
||||
case 'localized': {
|
||||
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
|
||||
if (accessed == null) {
|
||||
fileLogger.warn(`Cannot find localization key ${modification.localizationKey.join('.')}`);
|
||||
@@ -30,7 +35,7 @@ export function applyWithLocale(
|
||||
sourceCode.update(modification.begin, modification.end, JSON.stringify(accessed));
|
||||
break;
|
||||
}
|
||||
case "parameterized-function": {
|
||||
case 'parameterized-function': {
|
||||
const accessed = getPropertyByPath(localeJson, modification.localizationKey);
|
||||
let replacement: string;
|
||||
if (typeof accessed === 'string') {
|
||||
@@ -44,33 +49,33 @@ export function applyWithLocale(
|
||||
sourceCode.update(modification.begin, modification.end, replacement);
|
||||
break;
|
||||
|
||||
function formatFunction(accessed: string): string {
|
||||
function formatFunction(format: string): string {
|
||||
const params = new Set<string>();
|
||||
const components: string[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of accessed.matchAll(/\{(.+?)}/g)) {
|
||||
for (const match of format.matchAll(/\{(.+?)}/g)) {
|
||||
const [fullMatch, paramName] = match;
|
||||
if (lastIndex < match.index) {
|
||||
components.push(JSON.stringify(accessed.slice(lastIndex, match.index)));
|
||||
components.push(JSON.stringify(format.slice(lastIndex, match.index)));
|
||||
}
|
||||
params.add(paramName);
|
||||
components.push(paramName);
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
components.push(JSON.stringify(accessed.slice(lastIndex)));
|
||||
components.push(JSON.stringify(format.slice(lastIndex)));
|
||||
|
||||
// we replace with `(({name,count})=>(name+count+"some"))`
|
||||
const paramList = Array.from(params).join(',');
|
||||
let body = components.filter(x => x != '""').join('+');
|
||||
if (body == '') body = '""'; // if the body is empty, we return empty string
|
||||
let body = components.filter(x => x !== '""').join('+');
|
||||
if (body === '') body = '""'; // if the body is empty, we return empty string
|
||||
return `(({${paramList}})=>(${body}))`;
|
||||
}
|
||||
}
|
||||
case "locale-name": {
|
||||
case 'locale-name': {
|
||||
sourceCode.update(modification.begin, modification.end, modification.literal ? JSON.stringify(localeName) : localeName);
|
||||
break;
|
||||
}
|
||||
case "locale-json": {
|
||||
case 'locale-json': {
|
||||
// locale-json is inlined to place where initialize module-level variable which is executed only once.
|
||||
// In such case we can use JSON.parse to speed up the parsing script.
|
||||
// https://v8.dev/blog/cost-of-javascript-2019#json
|
||||
@@ -84,14 +89,14 @@ export function applyWithLocale(
|
||||
}
|
||||
}
|
||||
|
||||
function getPropertyByPath(localeJson: any, localizationKey: string[]): string | object | null {
|
||||
function getPropertyByPath(localeJson: ILocale, localizationKey: string[]): string | object | null {
|
||||
if (localizationKey.length === 0) return localeJson;
|
||||
let current: any = localeJson;
|
||||
let current: ILocale | string = localeJson;
|
||||
for (const key of localizationKey) {
|
||||
if (typeof current !== 'object' || current === null || !(key in current)) {
|
||||
if (typeof current !== 'object' || !(key in current)) {
|
||||
return null; // Key not found
|
||||
}
|
||||
current = current[key];
|
||||
}
|
||||
return current ?? null;
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import type { AstNode, ProgramNode } from 'rollup';
|
||||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { parseAst } from 'vite';
|
||||
import * as estreeWalker from 'estree-walker';
|
||||
import { assertNever, assertType } from '../utils.js';
|
||||
import type { AstNode, ProgramNode } from 'rollup';
|
||||
import type * as estree from 'estree';
|
||||
import type { LocaleInliner, TextModification } from '../locale-inliner.js';
|
||||
import type { Logger } from '../logger.js'
|
||||
import { assertNever, assertType } from '../utils.js';
|
||||
import type { Logger } from '../logger.js';
|
||||
|
||||
// WalkerContext is not exported from estree-walker, so we define it here
|
||||
interface WalkerContext {
|
||||
@@ -15,12 +20,12 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
let programNode: ProgramNode;
|
||||
try {
|
||||
programNode = parseAst(sourceCode);
|
||||
} catch (e) {
|
||||
fileLogger.error(`Failed to parse source code: ${e}`);
|
||||
} catch (err) {
|
||||
fileLogger.error(`Failed to parse source code: ${err}`);
|
||||
return [];
|
||||
}
|
||||
if (programNode.sourceType !== 'module') {
|
||||
fileLogger.error(`Source code is not a module.`);
|
||||
fileLogger.error('Source code is not a module.');
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -32,7 +37,7 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
// 3) replace all `await window.fetch(`/assets/locales/${d}.${x}.json`).then(u=>u.json())` with `localeJson` variable
|
||||
estreeWalker.walk(programNode, {
|
||||
enter(this: WalkerContext, node: Node) {
|
||||
assertType<AstNode>(node)
|
||||
assertType<AstNode>(node);
|
||||
|
||||
if (node.type === 'Literal' && typeof node.value === 'string' && node.raw) {
|
||||
if (node.raw.substring(1).startsWith(inliner.scriptsDir)) {
|
||||
@@ -46,7 +51,7 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
localizedOnly: true,
|
||||
});
|
||||
}
|
||||
if (node.raw.substring(1, node.raw.length - 1) == `${inliner.scriptsDir}/${inliner.i18nFileName}`) {
|
||||
if (node.raw.substring(1, node.raw.length - 1) === `${inliner.scriptsDir}/${inliner.i18nFileName}`) {
|
||||
// we find `scripts/i18n.ts` literal.
|
||||
// This is tipically in depmap and replace with this file name to avoid unnecessary loading i18n script
|
||||
fileLogger.debug(`${lineCol(sourceCode, node)}: found ${inliner.i18nFileName} path literal ${node.raw}`);
|
||||
@@ -81,17 +86,17 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
localizedOnly: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
const importSpecifierResult = findImportSpecifier(programNode, inliner.i18nFileName, 'i18n');
|
||||
|
||||
switch (importSpecifierResult.type) {
|
||||
case 'no-import':
|
||||
fileLogger.debug(`No import of i18n found, skipping inlining.`);
|
||||
fileLogger.debug('No import of i18n found, skipping inlining.');
|
||||
return modifications;
|
||||
case 'no-specifiers':
|
||||
fileLogger.debug(`Importing i18n without specifiers, removing the import.`);
|
||||
fileLogger.debug('Importing i18n without specifiers, removing the import.');
|
||||
modifications.push({
|
||||
type: 'delete',
|
||||
begin: importSpecifierResult.importNode.start,
|
||||
@@ -115,17 +120,18 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
let isSupported = true;
|
||||
estreeWalker.walk(programNode, {
|
||||
enter(node) {
|
||||
if (node.type == 'VariableDeclaration') {
|
||||
if (node.type === 'VariableDeclaration') {
|
||||
assertType<estree.VariableDeclaration>(node);
|
||||
for (let id of node.declarations.flatMap(x => declsOfPattern(x.id))) {
|
||||
if (id == localI18nIdentifier) {
|
||||
for (const id of node.declarations.flatMap(x => declsOfPattern(x.id))) {
|
||||
if (id === localI18nIdentifier) {
|
||||
isSupported = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!isSupported) {
|
||||
fileLogger.error(`Duplicated identifier "${localI18nIdentifier}" in variable declaration. Skipping inlining.`);
|
||||
return modifications;
|
||||
@@ -141,8 +147,8 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
toSkip.add(i18nImport);
|
||||
estreeWalker.walk(programNode, {
|
||||
enter(this: WalkerContext, node, parent, property) {
|
||||
assertType<AstNode>(node)
|
||||
assertType<AstNode>(parent)
|
||||
assertType<AstNode>(node);
|
||||
assertType<AstNode>(parent);
|
||||
if (toSkip.has(node)) {
|
||||
// This is the import specifier, skip processing it
|
||||
this.skip();
|
||||
@@ -150,23 +156,23 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
}
|
||||
|
||||
// We don't care original name part of the import declaration
|
||||
if (node.type == 'ImportDeclaration') this.skip();
|
||||
if (node.type === 'ImportDeclaration') this.skip();
|
||||
|
||||
if (node.type === 'Identifier') {
|
||||
assertType<estree.Identifier>(node)
|
||||
assertType<estree.Property | estree.MemberExpression | estree.ExportSpecifier>(parent)
|
||||
if (parent.type === 'Property' && !parent.computed && property == 'key') return; // we don't care 'id' part of { id: expr }
|
||||
if (parent.type === 'MemberExpression' && !parent.computed && property == 'property') return; // we don't care 'id' part of { id: expr }
|
||||
if (parent.type === 'ExportSpecifier' && property == 'exported') return; // we don't care 'id' part of { id: expr }
|
||||
if (node.name == localI18nIdentifier) {
|
||||
assertType<estree.Identifier>(node);
|
||||
assertType<estree.Property | estree.MemberExpression | estree.ExportSpecifier>(parent);
|
||||
if (parent.type === 'Property' && !parent.computed && property === 'key') return; // we don't care 'id' part of { id: expr }
|
||||
if (parent.type === 'MemberExpression' && !parent.computed && property === 'property') return; // we don't care 'id' part of { id: expr }
|
||||
if (parent.type === 'ExportSpecifier' && property === 'exported') return; // we don't care 'id' part of { id: expr }
|
||||
if (node.name === localI18nIdentifier) {
|
||||
fileLogger.error(`${lineCol(sourceCode, node)}: Using i18n identifier "${localI18nIdentifier}" directly. Skipping inlining.`);
|
||||
preserveI18nImport = true;
|
||||
}
|
||||
} else if (node.type === 'MemberExpression') {
|
||||
assertType<estree.MemberExpression>(node);
|
||||
const i18nPath = parseI18nPropertyAccess(node);
|
||||
if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] == 'ts') {
|
||||
if (parent.type === 'CallExpression' && property == 'callee') return; // we don't want to process `i18n.ts.property.stringBuiltinMethod()`
|
||||
if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'ts') {
|
||||
if (parent.type === 'CallExpression' && property === 'callee') return; // we don't want to process `i18n.ts.property.stringBuiltinMethod()`
|
||||
if (i18nPath.at(-1)?.startsWith('_')) fileLogger.debug(`found i18n grouped property access ${i18nPath.join('.')}`);
|
||||
else fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n property access ${i18nPath.join('.')}`);
|
||||
// it's i18n.ts.propertyAccess
|
||||
@@ -179,7 +185,7 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
localizedOnly: true,
|
||||
});
|
||||
this.skip();
|
||||
} else if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] == 'tsx') {
|
||||
} else if (i18nPath != null && i18nPath.length >= 2 && i18nPath[0] === 'tsx') {
|
||||
// it's parameterized locale substitution (`i18n.tsx.property(parameters)`)
|
||||
// we expect the parameter to be an object literal
|
||||
fileLogger.debug(`${lineCol(sourceCode, node)}: found i18n function access (object) ${i18nPath.join('.')}`);
|
||||
@@ -197,11 +203,12 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
// If there is 'i18n' in the parameters, we care interior of the function
|
||||
if (node.params.flatMap(param => declsOfPattern(param)).includes(localI18nIdentifier)) this.skip();
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!preserveI18nImport) {
|
||||
fileLogger.debug(`removing i18n import statement`);
|
||||
fileLogger.debug('removing i18n import statement');
|
||||
modifications.push({
|
||||
type: 'delete',
|
||||
begin: i18nImport.start,
|
||||
@@ -211,7 +218,7 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
}
|
||||
|
||||
function parseI18nPropertyAccess(node: estree.Expression | estree.Super): string[] | null {
|
||||
if (node.type === 'Identifier' && node.name == localI18nIdentifier) return []; // i18n itself
|
||||
if (node.type === 'Identifier' && node.name === localI18nIdentifier) return []; // i18n itself
|
||||
if (node.type !== 'MemberExpression') return null;
|
||||
// super.*
|
||||
if (node.object.type === 'Super') return null;
|
||||
@@ -219,7 +226,6 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
// i18n?.property is not supported
|
||||
if (node.optional) return null;
|
||||
|
||||
|
||||
let id: string | null = null;
|
||||
if (node.computed) {
|
||||
if (node.property.type === 'Literal' && typeof node.property.value === 'string') {
|
||||
@@ -243,10 +249,10 @@ export function collectModifications(sourceCode: string, fileName: string, fileL
|
||||
|
||||
function declsOfPattern(pattern: estree.Pattern | null): string[] {
|
||||
if (pattern == null) return [];
|
||||
switch (pattern?.type) {
|
||||
case "Identifier":
|
||||
switch (pattern.type) {
|
||||
case 'Identifier':
|
||||
return [pattern.name];
|
||||
case "ObjectPattern":
|
||||
case 'ObjectPattern':
|
||||
return pattern.properties.flatMap(prop => {
|
||||
switch (prop.type) {
|
||||
case 'Property':
|
||||
@@ -254,16 +260,16 @@ function declsOfPattern(pattern: estree.Pattern | null): string[] {
|
||||
case 'RestElement':
|
||||
return declsOfPattern(prop.argument);
|
||||
default:
|
||||
assertNever(prop)
|
||||
assertNever(prop);
|
||||
}
|
||||
});
|
||||
case "ArrayPattern":
|
||||
case 'ArrayPattern':
|
||||
return pattern.elements.flatMap(p => declsOfPattern(p));
|
||||
case "RestElement":
|
||||
case 'RestElement':
|
||||
return declsOfPattern(pattern.argument);
|
||||
case "AssignmentPattern":
|
||||
case 'AssignmentPattern':
|
||||
return declsOfPattern(pattern.left);
|
||||
case "MemberExpression":
|
||||
case 'MemberExpression':
|
||||
// assignment pattern so no new variable is declared
|
||||
return [];
|
||||
default:
|
||||
@@ -375,15 +381,15 @@ type SpecifierResult =
|
||||
|
||||
function findImportSpecifier(programNode: ProgramNode, i18nFileName: string, i18nSymbol: string): SpecifierResult {
|
||||
const imports = programNode.body.filter(x => x.type === 'ImportDeclaration');
|
||||
const importNode = imports.find(x => x.source.value === `./${i18nFileName}`) as estree.ImportDeclaration;
|
||||
const importNode = imports.find(x => x.source.value === `./${i18nFileName}`) as estree.ImportDeclaration | undefined;
|
||||
if (!importNode) return { type: 'no-import' };
|
||||
assertType<AstNode>(importNode);
|
||||
|
||||
if (importNode.specifiers.length == 0) {
|
||||
if (importNode.specifiers.length === 0) {
|
||||
return { type: 'no-specifiers', importNode };
|
||||
}
|
||||
|
||||
if (importNode.specifiers.length != 1) {
|
||||
if (importNode.specifiers.length !== 1) {
|
||||
return { type: 'unexpected-specifiers', importNode };
|
||||
}
|
||||
const i18nImportSpecifier = importNode.specifiers[0];
|
||||
|
||||
Reference in New Issue
Block a user