Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
399 lines
12 KiB
JavaScript
399 lines
12 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const projectRoot = path.resolve(__dirname, "..");
|
|
const localesDirectory = path.join(projectRoot, "src", "i18n", "generated");
|
|
const activeLocales = ["da", "en", "sv", "de", "no"];
|
|
|
|
const linkTokenPattern = /@(?:\.[\p{L}]+)?:(?:\x7b'[^']+'\x7d|[\p{L}\p{N}_.-]+)/gu;
|
|
const placeholderPattern = /\{[A-Za-z_][A-Za-z0-9_]*}/g;
|
|
const wordTokenPattern = /[\p{L}\p{N}]+/gu;
|
|
const linkedMessagePattern = /^@(?<modifier>\.[\p{L}]+)?:(?:\{'(?<literal>[^']+)'\}|(?<path>[\p{L}\p{N}_.-]+))$/u;
|
|
|
|
const mode = process.argv.includes("--apply") ? "apply" : "check";
|
|
|
|
const isPlainObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
|
|
const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""));
|
|
|
|
const writeJson = (filePath, value) => {
|
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
};
|
|
|
|
const cloneJson = (value) => JSON.parse(JSON.stringify(value));
|
|
|
|
const flattenStringEntries = (value, prefix = "") => {
|
|
if (typeof value === "string") {
|
|
return [{ key: prefix, value }];
|
|
}
|
|
|
|
if (!isPlainObject(value)) {
|
|
return [];
|
|
}
|
|
|
|
return Object.entries(value).flatMap(([key, entry]) =>
|
|
flattenStringEntries(entry, prefix ? `${prefix}.${key}` : key)
|
|
);
|
|
};
|
|
|
|
const getValueAtPath = (value, keyPath) => {
|
|
let current = value;
|
|
for (const segment of keyPath.split(".")) {
|
|
if (!isPlainObject(current) || !(segment in current)) {
|
|
return undefined;
|
|
}
|
|
current = current[segment];
|
|
}
|
|
return current;
|
|
};
|
|
|
|
const setValueAtPath = (value, keyPath, nextValue) => {
|
|
const segments = keyPath.split(".");
|
|
let current = value;
|
|
for (const segment of segments.slice(0, -1)) {
|
|
current = current[segment];
|
|
}
|
|
current[segments.at(-1)] = nextValue;
|
|
};
|
|
|
|
const compareStringValues = (left, right) => left.localeCompare(right, "en", { sensitivity: "base" });
|
|
|
|
const protectSegments = (value) => {
|
|
const protectedRanges = [];
|
|
for (const pattern of [linkTokenPattern, placeholderPattern]) {
|
|
pattern.lastIndex = 0;
|
|
for (const match of value.matchAll(pattern)) {
|
|
protectedRanges.push({ start: match.index ?? 0, end: (match.index ?? 0) + match[0].length });
|
|
}
|
|
}
|
|
|
|
protectedRanges.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
const mergedRanges = [];
|
|
for (const range of protectedRanges) {
|
|
const previous = mergedRanges.at(-1);
|
|
if (previous && range.start <= previous.end) {
|
|
previous.end = Math.max(previous.end, range.end);
|
|
} else {
|
|
mergedRanges.push({ ...range });
|
|
}
|
|
}
|
|
|
|
const segments = [];
|
|
let offset = 0;
|
|
for (const range of mergedRanges) {
|
|
if (offset < range.start) {
|
|
segments.push({ protected: false, value: value.slice(offset, range.start) });
|
|
}
|
|
segments.push({ protected: true, value: value.slice(range.start, range.end) });
|
|
offset = range.end;
|
|
}
|
|
if (offset < value.length) {
|
|
segments.push({ protected: false, value: value.slice(offset) });
|
|
}
|
|
|
|
return segments;
|
|
};
|
|
|
|
const normalizeToken = (token) => token.normalize("NFKC").toLocaleLowerCase();
|
|
|
|
const isAuditedToken = (token) => /\p{L}/u.test(token);
|
|
|
|
const collectTokens = (value) => {
|
|
const tokens = [];
|
|
for (const segment of protectSegments(value)) {
|
|
if (segment.protected) {
|
|
continue;
|
|
}
|
|
|
|
wordTokenPattern.lastIndex = 0;
|
|
for (const match of segment.value.matchAll(wordTokenPattern)) {
|
|
const token = match[0];
|
|
if (isAuditedToken(token)) {
|
|
tokens.push(token);
|
|
}
|
|
}
|
|
}
|
|
return tokens;
|
|
};
|
|
|
|
const stripMarks = (value) => value.normalize("NFKD").replace(/\p{M}/gu, "");
|
|
|
|
const slugify = (token, usedKeys) => {
|
|
const base =
|
|
stripMarks(token)
|
|
.toLocaleLowerCase()
|
|
.replace(/[^\p{L}\p{N}]+/gu, "_")
|
|
.replace(/^_+|_+$/g, "")
|
|
.replace(/_{2,}/g, "_") || "word";
|
|
const prefixed = /^\p{N}/u.test(base) ? `word_${base}` : base;
|
|
let key = prefixed;
|
|
let suffix = 2;
|
|
while (usedKeys.has(key)) {
|
|
key = `${prefixed}_${suffix}`;
|
|
suffix += 1;
|
|
}
|
|
usedKeys.add(key);
|
|
return key;
|
|
};
|
|
|
|
const linkedWord = (keyPath, modifier = "") => `@${modifier}:{'${keyPath}'}`;
|
|
|
|
const applyTextModifier = (value, modifier) => {
|
|
switch (modifier) {
|
|
case ".capitalize":
|
|
return `${value.charAt(0).toLocaleUpperCase()}${value.slice(1)}`;
|
|
case ".upper":
|
|
return value.toLocaleUpperCase();
|
|
case ".lower":
|
|
return value.toLocaleLowerCase();
|
|
default:
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const modifierForToken = (token, word) => {
|
|
if (normalizeToken(token) !== normalizeToken(word.value)) {
|
|
return null;
|
|
}
|
|
|
|
const candidateModifiers = ["", ".upper", ".capitalize", ".lower"];
|
|
for (const modifier of candidateModifiers) {
|
|
if (applyTextModifier(word.value, modifier) === token) {
|
|
return modifier;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const replaceRepeatedTokens = (value, tokenToWord) =>
|
|
protectSegments(value)
|
|
.map((segment) => {
|
|
if (segment.protected) {
|
|
return segment.value;
|
|
}
|
|
|
|
return segment.value.replace(wordTokenPattern, (token) => {
|
|
const normalized = normalizeToken(token);
|
|
const word = tokenToWord.get(normalized);
|
|
if (!word || !isAuditedToken(token)) {
|
|
return token;
|
|
}
|
|
|
|
const exactVariant = word.variants?.get(token);
|
|
if (exactVariant) {
|
|
return linkedWord(exactVariant.path);
|
|
}
|
|
|
|
const modifier = modifierForToken(token, word);
|
|
if (modifier !== null) {
|
|
return linkedWord(word.path, modifier);
|
|
}
|
|
|
|
return token;
|
|
});
|
|
})
|
|
.join("");
|
|
|
|
const applyLinkedModifier = (value, modifier) => {
|
|
return applyTextModifier(value, modifier);
|
|
};
|
|
|
|
const resolveLinkedMessage = (messages, keyPath, seen = new Set()) => {
|
|
if (seen.has(keyPath)) {
|
|
return undefined;
|
|
}
|
|
seen.add(keyPath);
|
|
|
|
const value = getValueAtPath(messages, keyPath);
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
|
|
const exactLink = value.trim().match(linkedMessagePattern);
|
|
if (exactLink) {
|
|
const resolved = resolveLinkedMessage(messages, exactLink.groups.literal ?? exactLink.groups.path, seen);
|
|
return resolved === undefined ? undefined : applyLinkedModifier(resolved, exactLink.groups.modifier);
|
|
}
|
|
|
|
return value.replace(linkTokenPattern, (token) => {
|
|
const match = token.match(linkedMessagePattern);
|
|
if (!match?.groups) {
|
|
return token;
|
|
}
|
|
|
|
const resolved = resolveLinkedMessage(messages, match.groups.literal ?? match.groups.path, new Set(seen));
|
|
if (resolved === undefined) {
|
|
return token;
|
|
}
|
|
|
|
return applyLinkedModifier(resolved, match.groups.modifier);
|
|
});
|
|
};
|
|
|
|
const reorderLocaleRoot = (localeMessages) => {
|
|
const { words, templates, ...rest } = localeMessages;
|
|
return { words, templates, ...rest };
|
|
};
|
|
|
|
const transformLocale = (locale, originalMessages) => {
|
|
const messages = cloneJson(originalMessages);
|
|
const entries = flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."));
|
|
const tokenStats = new Map();
|
|
|
|
for (const entry of entries) {
|
|
for (const token of collectTokens(entry.value)) {
|
|
const normalized = normalizeToken(token);
|
|
const stat = tokenStats.get(normalized) ?? { count: 0, casings: new Map() };
|
|
stat.count += 1;
|
|
stat.casings.set(token, (stat.casings.get(token) ?? 0) + 1);
|
|
tokenStats.set(normalized, stat);
|
|
}
|
|
}
|
|
|
|
const repeatedTokens = [...tokenStats.entries()]
|
|
.filter(([, stat]) => stat.count > 1)
|
|
.sort(([left], [right]) => compareStringValues(left, right));
|
|
const existingWords = messages.words ?? {};
|
|
const generatedWords = {};
|
|
const tokenToWord = new Map();
|
|
const usedKeys = new Set(Object.keys(existingWords.generated ?? {}));
|
|
|
|
for (const [normalized, stat] of repeatedTokens) {
|
|
const existingWordEntry = flattenStringEntries(existingWords).find(
|
|
(entry) => normalizeToken(entry.value) === normalized
|
|
);
|
|
if (existingWordEntry) {
|
|
const word = {
|
|
path: `words.${existingWordEntry.key}`,
|
|
value: existingWordEntry.value,
|
|
variants: new Map(),
|
|
};
|
|
|
|
const casings = [...stat.casings.keys()].sort(compareStringValues);
|
|
for (const casing of casings) {
|
|
if (modifierForToken(casing, word) !== null) {
|
|
continue;
|
|
}
|
|
|
|
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
|
|
generatedWords[variantKey] = casing;
|
|
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
|
|
}
|
|
|
|
tokenToWord.set(normalized, word);
|
|
continue;
|
|
}
|
|
|
|
const casings = [...stat.casings.entries()].sort(
|
|
([leftToken, leftCount], [rightToken, rightCount]) =>
|
|
rightCount - leftCount || compareStringValues(leftToken, rightToken)
|
|
);
|
|
const lowerCaseToken = casings.find(([token]) => token === normalized)?.[0];
|
|
const canonical = lowerCaseToken ?? casings[0][0];
|
|
const key = slugify(normalized, usedKeys);
|
|
generatedWords[key] = canonical;
|
|
const word = { path: `words.generated.${key}`, value: canonical, variants: new Map() };
|
|
|
|
for (const [casing] of casings) {
|
|
if (modifierForToken(casing, word) !== null) {
|
|
continue;
|
|
}
|
|
|
|
const variantKey = slugify(`${normalized}_${casing}`, usedKeys);
|
|
generatedWords[variantKey] = casing;
|
|
word.variants.set(casing, { path: `words.generated.${variantKey}`, value: casing });
|
|
}
|
|
|
|
tokenToWord.set(normalized, word);
|
|
}
|
|
|
|
messages.words = {
|
|
grammar: messages.words?.grammar ?? {},
|
|
actions: messages.words?.actions ?? {},
|
|
entities: messages.words?.entities ?? {},
|
|
statuses: messages.words?.statuses ?? {},
|
|
domain: messages.words?.domain ?? {},
|
|
services: messages.words?.services ?? {},
|
|
units: messages.words?.units ?? {},
|
|
...(messages.words ?? {}),
|
|
generated: {
|
|
...(messages.words?.generated ?? {}),
|
|
...Object.fromEntries(Object.entries(generatedWords).sort(([left], [right]) => compareStringValues(left, right))),
|
|
},
|
|
};
|
|
|
|
for (const entry of entries) {
|
|
const nextValue = replaceRepeatedTokens(entry.value, tokenToWord);
|
|
if (nextValue !== entry.value) {
|
|
setValueAtPath(messages, entry.key, nextValue);
|
|
}
|
|
}
|
|
|
|
const mismatches = [];
|
|
for (const entry of entries) {
|
|
const previousResolved = resolveLinkedMessage(originalMessages, entry.key);
|
|
const resolved = resolveLinkedMessage(messages, entry.key);
|
|
if (resolved !== previousResolved) {
|
|
mismatches.push(`${entry.key}: expected ${JSON.stringify(previousResolved)}, got ${JSON.stringify(resolved)}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
messages: reorderLocaleRoot(messages),
|
|
generatedWordCount: Object.keys(generatedWords).length,
|
|
repeatedTokenCount: repeatedTokens.length,
|
|
mismatches,
|
|
};
|
|
};
|
|
|
|
const countRepeatedTokensOutsideWords = (messages) => {
|
|
const counts = new Map();
|
|
for (const entry of flattenStringEntries(messages).filter((entry) => !entry.key.startsWith("words."))) {
|
|
for (const token of collectTokens(entry.value)) {
|
|
const normalized = normalizeToken(token);
|
|
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
|
|
}
|
|
}
|
|
return [...counts.entries()].filter(([, count]) => count > 1);
|
|
};
|
|
|
|
let hasErrors = false;
|
|
|
|
for (const locale of activeLocales) {
|
|
const filePath = path.join(localesDirectory, `${locale}-v2.json`);
|
|
const originalMessages = readJson(filePath);
|
|
const result = transformLocale(locale, originalMessages);
|
|
const repeatedAfter = countRepeatedTokensOutsideWords(result.messages);
|
|
|
|
if (result.mismatches.length > 0) {
|
|
hasErrors = true;
|
|
console.error(`${locale}: transformed output changed ${result.mismatches.length} strings`);
|
|
for (const mismatch of result.mismatches.slice(0, 20)) {
|
|
console.error(` ${mismatch}`);
|
|
}
|
|
}
|
|
|
|
if (repeatedAfter.length > 0) {
|
|
hasErrors = true;
|
|
console.error(`${locale}: ${repeatedAfter.length} repeated token groups remain outside words`);
|
|
for (const [token, count] of repeatedAfter.slice(0, 20)) {
|
|
console.error(` ${token}: ${count}`);
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`${locale}: ${result.repeatedTokenCount} repeated token groups, ${result.generatedWordCount} new generated word entries`
|
|
);
|
|
|
|
if (mode === "apply" && !hasErrors) {
|
|
writeJson(filePath, result.messages);
|
|
}
|
|
}
|
|
|
|
if (hasErrors) {
|
|
process.exit(1);
|
|
}
|