All files / src/sn-translate index.ts

95.16% Statements 59/62
87.27% Branches 48/55
100% Functions 8/8
100% Lines 56/56

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),
      });
    },
  };
}