- Introduced i18n key usage validation with `tests/e2e/i18n.views.spec.ts` for deterministic locale coverage. - Added `viewI18nKeyScanner` utility to scan and validate view translation keys. - Created `PosDepartmentStep1` unit tests for duplicate warnings and booking selection flow. - Enhanced POS mobile popup with `SelectOrderBookingPopupProps` and new header close options. - Updated e2e tests with scenarios to verify booking selections, duplicate handling, and locale alignment. - Added new `test:e2e:i18n:views` npm script for targeted i18n test execution.
269 lines
7.3 KiB
TypeScript
269 lines
7.3 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
export interface ViewTranslationKeyUsage {
|
|
functionName: "$t" | "$tc" | "t" | "te";
|
|
key: string;
|
|
filePath: string;
|
|
relativePath: string;
|
|
line: number;
|
|
column: number;
|
|
}
|
|
|
|
export interface NonLiteralTranslationCall {
|
|
functionName: "$t" | "$tc" | "t" | "te";
|
|
filePath: string;
|
|
relativePath: string;
|
|
line: number;
|
|
column: number;
|
|
snippet: string;
|
|
}
|
|
|
|
export interface ViewTranslationScanResult {
|
|
scannedFiles: string[];
|
|
literalKeyUsages: ViewTranslationKeyUsage[];
|
|
keyUsageByKey: Map<string, ViewTranslationKeyUsage[]>;
|
|
nonLiteralCalls: NonLiteralTranslationCall[];
|
|
}
|
|
|
|
const TRANSLATION_CALL_PATTERN = /(\$t|\$tc|\bte|\bt)\s*\(/g;
|
|
const VIEW_EXTENSIONS = new Set([".vue", ".js", ".ts"]);
|
|
|
|
const toProjectRelativePath = (projectRoot: string, absolutePath: string) => {
|
|
return path.relative(projectRoot, absolutePath).split(path.sep).join("/");
|
|
};
|
|
|
|
const collectFilesRecursively = (directory: string): string[] => {
|
|
const collected: string[] = [];
|
|
|
|
if (!fs.existsSync(directory)) {
|
|
return collected;
|
|
}
|
|
|
|
const walk = (currentPath: string) => {
|
|
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
const absolutePath = path.join(currentPath, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(absolutePath);
|
|
continue;
|
|
}
|
|
|
|
if (VIEW_EXTENSIONS.has(path.extname(entry.name))) {
|
|
collected.push(absolutePath);
|
|
}
|
|
}
|
|
};
|
|
|
|
walk(directory);
|
|
collected.sort();
|
|
return collected;
|
|
};
|
|
|
|
const buildLineStarts = (content: string): number[] => {
|
|
const starts = [0];
|
|
for (let i = 0; i < content.length; i += 1) {
|
|
if (content[i] === "\n") {
|
|
starts.push(i + 1);
|
|
}
|
|
}
|
|
return starts;
|
|
};
|
|
|
|
const findLineIndex = (lineStarts: number[], index: number): number => {
|
|
let low = 0;
|
|
let high = lineStarts.length - 1;
|
|
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const lineStart = lineStarts[mid];
|
|
const nextLineStart = mid + 1 < lineStarts.length ? lineStarts[mid + 1] : Number.POSITIVE_INFINITY;
|
|
|
|
if (index >= lineStart && index < nextLineStart) {
|
|
return mid;
|
|
}
|
|
|
|
if (index < lineStart) {
|
|
high = mid - 1;
|
|
} else {
|
|
low = mid + 1;
|
|
}
|
|
}
|
|
|
|
return lineStarts.length - 1;
|
|
};
|
|
|
|
const getLineAndColumn = (lineStarts: number[], index: number) => {
|
|
const lineIndex = findLineIndex(lineStarts, index);
|
|
const lineStart = lineStarts[lineIndex] ?? 0;
|
|
return {
|
|
line: lineIndex + 1,
|
|
column: index - lineStart + 1,
|
|
};
|
|
};
|
|
|
|
const getLineSnippet = (content: string, lineStarts: number[], index: number): string => {
|
|
const lineIndex = findLineIndex(lineStarts, index);
|
|
const start = lineStarts[lineIndex] ?? 0;
|
|
const nextStart = lineIndex + 1 < lineStarts.length ? lineStarts[lineIndex + 1] : content.length;
|
|
return content.slice(start, nextStart).trim();
|
|
};
|
|
|
|
const skipWhitespace = (content: string, startIndex: number): number => {
|
|
let index = startIndex;
|
|
while (index < content.length && /\s/.test(content[index])) {
|
|
index += 1;
|
|
}
|
|
return index;
|
|
};
|
|
|
|
const parseQuotedStringLiteral = (
|
|
content: string,
|
|
quoteCharacter: "'" | '"',
|
|
quoteStartIndex: number
|
|
): { key: string; endIndex: number } | null => {
|
|
let index = quoteStartIndex + 1;
|
|
let rawValue = "";
|
|
|
|
while (index < content.length) {
|
|
const character = content[index];
|
|
|
|
if (character === "\\") {
|
|
if (index + 1 < content.length) {
|
|
rawValue += content.slice(index, index + 2);
|
|
index += 2;
|
|
continue;
|
|
}
|
|
|
|
rawValue += character;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (character === quoteCharacter) {
|
|
const normalizedValue = rawValue
|
|
.replace(/\\\\/g, "\\")
|
|
.replace(quoteCharacter === "'" ? /\\'/g : /\\"/g, quoteCharacter);
|
|
|
|
return {
|
|
key: normalizedValue,
|
|
endIndex: index,
|
|
};
|
|
}
|
|
|
|
rawValue += character;
|
|
index += 1;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const normalizeFunctionName = (value: string): "$t" | "$tc" | "t" | "te" => {
|
|
if (value === "$t" || value === "$tc" || value === "t" || value === "te") {
|
|
return value;
|
|
}
|
|
return value.endsWith("te") ? "te" : "t";
|
|
};
|
|
|
|
export const scanViewTranslationKeys = (options?: {
|
|
projectRoot?: string;
|
|
viewsDirectory?: string;
|
|
}): ViewTranslationScanResult => {
|
|
const projectRoot = options?.projectRoot ?? process.cwd();
|
|
const viewsDirectory = options?.viewsDirectory ?? path.join(projectRoot, "src", "views");
|
|
|
|
const scannedFiles = collectFilesRecursively(viewsDirectory);
|
|
const literalKeyUsages: ViewTranslationKeyUsage[] = [];
|
|
const nonLiteralCalls: NonLiteralTranslationCall[] = [];
|
|
const keyUsageByKey = new Map<string, ViewTranslationKeyUsage[]>();
|
|
|
|
for (const filePath of scannedFiles) {
|
|
const content = fs.readFileSync(filePath, "utf8");
|
|
const lineStarts = buildLineStarts(content);
|
|
TRANSLATION_CALL_PATTERN.lastIndex = 0;
|
|
|
|
for (const match of content.matchAll(TRANSLATION_CALL_PATTERN)) {
|
|
if (match.index === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const rawFunctionName = match[1] ?? "t";
|
|
const functionName = normalizeFunctionName(rawFunctionName);
|
|
const openParenOffset = match[0].lastIndexOf("(");
|
|
const openParenIndex = match.index + openParenOffset;
|
|
const argumentStart = skipWhitespace(content, openParenIndex + 1);
|
|
const position = getLineAndColumn(lineStarts, openParenIndex);
|
|
const relativePath = toProjectRelativePath(projectRoot, filePath);
|
|
const firstCharacter = content[argumentStart];
|
|
|
|
if (firstCharacter !== "'" && firstCharacter !== '"') {
|
|
nonLiteralCalls.push({
|
|
functionName,
|
|
filePath,
|
|
relativePath,
|
|
line: position.line,
|
|
column: position.column,
|
|
snippet: getLineSnippet(content, lineStarts, openParenIndex),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const parsedLiteral = parseQuotedStringLiteral(content, firstCharacter, argumentStart);
|
|
if (!parsedLiteral) {
|
|
nonLiteralCalls.push({
|
|
functionName,
|
|
filePath,
|
|
relativePath,
|
|
line: position.line,
|
|
column: position.column,
|
|
snippet: getLineSnippet(content, lineStarts, openParenIndex),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const keyPosition = getLineAndColumn(lineStarts, argumentStart);
|
|
const usage: ViewTranslationKeyUsage = {
|
|
functionName,
|
|
key: parsedLiteral.key,
|
|
filePath,
|
|
relativePath,
|
|
line: keyPosition.line,
|
|
column: keyPosition.column,
|
|
};
|
|
|
|
literalKeyUsages.push(usage);
|
|
|
|
const existingUsages = keyUsageByKey.get(usage.key) ?? [];
|
|
existingUsages.push(usage);
|
|
keyUsageByKey.set(usage.key, existingUsages);
|
|
}
|
|
}
|
|
|
|
literalKeyUsages.sort((left, right) => {
|
|
if (left.key !== right.key) {
|
|
return left.key.localeCompare(right.key);
|
|
}
|
|
if (left.relativePath !== right.relativePath) {
|
|
return left.relativePath.localeCompare(right.relativePath);
|
|
}
|
|
return left.line - right.line;
|
|
});
|
|
|
|
nonLiteralCalls.sort((left, right) => {
|
|
if (left.relativePath !== right.relativePath) {
|
|
return left.relativePath.localeCompare(right.relativePath);
|
|
}
|
|
if (left.line !== right.line) {
|
|
return left.line - right.line;
|
|
}
|
|
return left.column - right.column;
|
|
});
|
|
|
|
return {
|
|
scannedFiles,
|
|
literalKeyUsages,
|
|
keyUsageByKey,
|
|
nonLiteralCalls,
|
|
};
|
|
};
|