Ctrl K
ComunidadGuías…

scripts/i18n-sweep.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// AST-based raw-string detector for JSX/TSX.
// Finds user-visible string literals NOT going through t()/tX(...).
// Usage: node scripts/i18n-sweep.js [dir1 dir2 ...]
import ts from "typescript";
import fs from "node:fs";
import path from "node:path";

const roots = process.argv.slice(2).length
  ? process.argv.slice(2)
  : ["src/app/(admin)", "src/components/admin"];

// strings that are code, not UI copy
const CODE_PAT = /^(use client|use server|[a-z0-9-]+([.:/][a-zA-Z0-9_~-]+)*|\/[^\s]*|\{[0-9]+\}|#[0-9a-fA-F]{3,8}|--[\w-]+|[\w-]+=[\w-]+)$/;

const findings = [];

function isTCall(node) {
  // t("x"), t.rich("x"), tCommon("x") — Identifier or PropertyAccess callee whose name starts with t
  let callee = node.expression;
  if (ts.isPropertyAccessExpression(callee)) callee = callee.expression;
  return ts.isIdentifier(callee) && /^t[A-Z]?/.test(callee.text);
}

function checkString(node, ctx) {
  const text = node.text;
  if (!text) return;
  if (CODE_PAT.test(text)) return;
  if (text.length < 2) return;
  if (!/[a-zA-Z]/.test(text)) return;
  // skip purely-template/technical strings
  if (/^[A-Z_$][\w$]*$/.test(text)) return;
  if (/^(https?:|mailto:|tel:)/.test(text)) return;
  if (text.startsWith("/") && !text.includes(" ")) return;
  if (/^@[\w.-]+$/.test(text)) return;
  // raw HTML entities alone (not copy)
  if (/^&[a-z]+;$/i.test(text.trim())) return;
  // whitespace-only JSXText (formatting between expressions)
  if (!text.trim()) return;
  // Tailwind/utility-class-shaped strings (kebab tokens, slashes, colons)
  if (/^[a-z0-9:\/\[\]_.\- ]+([a-z]+-){2,}/.test(text.trim()) && !/\s[A-Z]/.test(text)) return;
  if (/^[a-z]([a-z0-9:\/\[\]_%-]*\s)*[a-z0-9:\/\[\]_%-]*$/.test(text.trim()) && text.includes("-") && !/[A-Z]/.test(text)) return;
  // i18n translation keys (consumed by t() at render sites) — not UI copy
  if (/^(lt|ph|bg|ls|shadow|hover|angle|weight|size|align|density|reveal|av|border|layout|textAnim|tab|focal|mock)[A-Z]/.test(text)) return;
  // font brand names (deliberately untranslated)
  if (/^(Inter|Poppins|Playfair Display|JetBrains Mono|Space Grotesk|DM Sans|Lora|Bebas Neue|Sora|Outfit|Nunito|Press Start 2P|Montserrat|Caveat|Pacifico|Abril Fatface)$/.test(text)) return;
  // parent chain: if inside t(...) call args → translated
  let p = node.parent;
  let translated = false;
  while (p) {
    if (ts.isCallExpression(p) && isTCall(p)) { translated = true; break; }
    p = p.parent;
  }
  if (translated) return;
  findings.push({ file: ctx.file, line: ctx.line, text });
}

function walk(sourceFile, file) {
  function visit(node) {
    const line = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
    const ctx = { file, line };
    if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
      // Unattached string literal: user-visible ONLY if the ancestor chain up
      // to the nearest function boundary contains a JSX node (render position).
      // Otherwise it's a comparison/id/icon name/etc. — logic, not copy.
      let p = node.parent;
      let inRender = false;
      while (p) {
        if (ts.isJsxChild(node) || ts.isJsxElement(p) || ts.isJsxFragment(p) || ts.isJsxExpression(p)) { inRender = true; break; }
        if (ts.isFunctionDeclaration(p) || ts.isFunctionExpression(p) || ts.isArrowFunction(p) || ts.isSourceFile(p)) break;
        p = p.parent;
      }
      const isJsxAttrValue = ts.isJsxAttribute(node.parent);
      const isJsxChildExpr = ts.isJsxExpression(node.parent);
      if (inRender || isJsxAttrValue || isJsxChildExpr) {
        if (isJsxAttrValue) {
          const name = node.parent.name.text;
          if (/^(title|placeholder|alt|label|hint|description|summary|subtitle|caption)$/.test(name)) {
            checkString(node, ctx);
          }
        } else if (isJsxChildExpr) {
          checkString(node, ctx);
        } else if (ts.isPropertyAssignment(node.parent) && ts.isIdentifier(node.parent.name) && /^(label|title|text|description|hint|placeholder|summary|subtitle|name)$/.test(node.parent.name.text)) {
          checkString(node, ctx);
        } else if (inRender) {
          checkString(node, ctx);
        }
      }
    } else if (ts.isJsxText(node)) {
      // Raw text children like <span>Live Preview</span>
      checkString(node, ctx);
    }
    node.forEachChild(visit);
  }
  visit(sourceFile);
}

function collect(dir) {
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, e.name);
    if (e.isDirectory()) collect(full);
    else if (/\.(tsx|ts)$/.test(e.name)) {
      const code = fs.readFileSync(full, "utf8");
      const sf = ts.createSourceFile(full, code, ts.ScriptTarget.Latest, true);
      walk(sf, full);
    }
  }
}
roots.forEach(collect);

const uniq = [];
const dedupe = new Set();
for (const f of findings) {
  const k = `${f.file}:${f.line}:${f.text}`;
  if (dedupe.has(k)) continue;
  dedupe.add(k);
  uniq.push(f);
}
console.log(`TOTAL: ${uniq.length}`);
for (const f of uniq) {
  console.log(`${f.file}:${f.line}: ${JSON.stringify(f.text)}`);
}
if (uniq.length === 0) console.log("CLEAN");