Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | 1x 213x 213x 213x 213x 1058x 1058x 74x 85x 85x 984x 112x 1x 18x 16x 18x 3x 1x 20x 20x 20x 13x 13x 19x 19x 19x 41x 16x 16x 16x 16x 19x 16x 213x 213x 213x 213x 193x 20x 14x 6x 5x 5x 5x 5x 11x 11x 11x 11x 11x 11x 5x 5x 18x 18x 18x | import type { Plugin, ModuleInfo, OutputBundle } from "rollup";
/** Shape of the ..translations.json asset emitted into the rollup bundle. */
export interface TranslationsAsset {
messages: Array<string | Record<string, string>>;
}
/** Suffix used for translations assets (e.g. .translations.json, dashboard.translations.json). */
export const TRANSLATIONS_SUFFIX = ".translations.json";
function walk(
node: unknown,
visitor: (node: Record<string, unknown>) => void,
): void {
Iif (!node || typeof node !== "object") return;
const n = node as Record<string, unknown>;
visitor(n);
for (const key of Object.keys(n)) {
const child = n[key];
if (Array.isArray(child)) {
for (const item of child) {
Eif (
item &&
typeof item === "object" &&
(item as { type?: unknown }).type
) {
walk(item, visitor);
}
}
} else if (
child &&
typeof child === "object" &&
(child as { type?: unknown }).type
) {
walk(child, visitor);
}
}
}
/**
* Derive a per-page translations filename from the HTML asset in the bundle.
* e.g. admin/settings.html → admin/settings.translations.json
* Returns undefined when no HTML asset exists (single-entry / non-HTML builds).
*/
const translationsNameFromHtml = (bundle: OutputBundle): string | undefined => {
const html = Object.values(bundle).find(
(o) => o.type === "asset" && o.fileName.endsWith(".html"),
);
if (!html) return undefined;
return html.fileName.replace(/\.html$/, TRANSLATIONS_SUFFIX);
};
/**
* Rollup plugin that collects every string literal passed to `t()` imported
* from `sn-translate` and emits them as a `.translations.json` asset in the
* bundle so downstream plugins (e.g. snTranslateRecords in @servicenow/sdk-repack)
* can consume them without shared closure state.
*
* In multi-entry builds each HTML page gets its own translations file
* (e.g. dashboard.translations.json) so that only that page's messages
* are injected into its HTML. Identical messages across pages naturally
* coalesce via explicitId in the downstream static-content-plugin.
*/
export function collectSnTranslate(): Plugin {
const messages = new Set<string>();
const objectMessages = new Map<string, Record<string, string>>();
return {
name: "collect-sn-translate",
buildStart() {
messages.clear();
objectMessages.clear();
},
moduleParsed(moduleInfo: ModuleInfo) {
const ast = moduleInfo.ast as unknown as {
body: Array<Record<string, unknown>>;
};
// Find local name(s) of `t` imported from sn-translate
const tNames = new Set<string>();
for (const node of ast.body) {
if (
node["type"] === "ImportDeclaration" &&
(node["source"] as { value?: unknown })["value"] === "sn-translate"
) {
const specifiers = node["specifiers"] as Array<
Record<string, unknown>
>;
for (const specifier of specifiers) {
Eif (
specifier["type"] === "ImportSpecifier" &&
(specifier["imported"] as { name?: unknown })["name"] === "t"
) {
tNames.add((specifier["local"] as { name: string })["name"]);
}
}
}
}
if (tNames.size === 0) return;
// Collect every unique first arg to t() in this module
walk(moduleInfo.ast, (node) => {
const args = node["arguments"] as
| Array<Record<string, unknown>>
| undefined;
const firstArg = args?.[0];
const callee = node["callee"] as Record<string, unknown> | undefined;
if (
node["type"] !== "CallExpression" ||
callee?.["type"] !== "Identifier" ||
!tNames.has(callee["name"] as string)
) {
return;
}
if (firstArg?.["type"] === "Literal" && firstArg["value"]) {
messages.add(firstArg["value"] as string);
} else if (firstArg?.["type"] === "ObjectExpression") {
const properties = firstArg["properties"] as
| Array<Record<string, unknown>>
| undefined;
Iif (!properties) return;
const obj: Record<string, string> = {};
for (const prop of properties) {
Iif (prop["type"] !== "Property") continue;
const key = prop["key"] as Record<string, unknown>;
const val = prop["value"] as Record<string, unknown>;
// Identifier keys (unquoted) use .name; Literal keys (quoted) use .value
const k =
key["type"] === "Identifier"
? (key["name"] as string)
: (key["value"] as string);
const v = val["value"] as string;
Eif (k !== undefined && typeof v === "string") obj[k] = v;
}
Eif (Object.keys(obj).length > 0)
objectMessages.set(JSON.stringify(obj), obj);
}
});
},
generateBundle(_, bundle) {
// Derive a per-page filename from the HTML asset when present
// (e.g. admin/settings.html → admin/settings.translations.json).
// Falls back to the generic .translations.json for single-entry builds.
const fileName = translationsNameFromHtml(bundle) ?? TRANSLATIONS_SUFFIX;
const asset: TranslationsAsset = {
messages: [...messages, ...objectMessages.values()],
};
this.emitFile({
type: "asset",
fileName,
source: JSON.stringify(asset, null, 2),
});
},
};
}
|