Move the console onto ReUI PRO
Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel, Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame (DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale. Design only: behaviour, texts and roles are unchanged. Tests that asserted the old DOM were updated; the tests now format numbers in English wherever they run. docs/reui-migration-plan.md records the plan and its decisions. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
commit
f3531c64f9
680 files changed
+139836
No files matched your search
@@ -0,0 +1,379 @@
|
||||
/**
|
||||
* The guard that keeps the repository in English.
|
||||
*
|
||||
* `CONVENTIONS.md` has said since the first day that "everything is in ENGLISH:
|
||||
* the interface, the code, the comments and the tests", and the visual redesign
|
||||
* broke it from the inside over three weeks: the interface stayed English and the
|
||||
* code did not. By 2026-09-07 there were 60 files with Spanish comments, 19 with
|
||||
* Spanish names and eighteen Spanish identifiers, and **nothing was measuring
|
||||
* it**. This is what measures it.
|
||||
*
|
||||
* It started with `src/` and, since 2026-10-02, it reads every tracked text file:
|
||||
* the Markdown at the root and in `docs/`, the tools in `dev/`, the workflows,
|
||||
* the Docker files, the hooks, `install.sh` and the config files. It looks at:
|
||||
*
|
||||
* - **File names.** A path segment that is not a word this project writes in
|
||||
* English. It is a blacklist of Spanish stems and not a dictionary: it cannot
|
||||
* catch a name nobody has thought of yet.
|
||||
* - **Comments** in code (`.ts`, `.tsx`, `.css`, `.js`, `.mjs`): any `/* *\/`,
|
||||
* `//` or `{/* *\/}` that carries Spanish. Two signals: the characters
|
||||
* `á é í ó ú ü ñ ¿ ¡`, which Spanish prose cannot avoid, and a list of
|
||||
* accent-free words that are unambiguously Spanish: `porque`, `cuando`, `hay`,
|
||||
* `del`… None of them is an English word, which is why the list can be short
|
||||
* and still catch prose.
|
||||
* - **Identifiers** in code: the same word list, outside comments and outside
|
||||
* string literals, so a Spanish variable or a Spanish `describe()` is caught
|
||||
* too.
|
||||
* - **Every line** of every other text file (Markdown, shell, YAML, JSON,
|
||||
* HTML, the Dockerfile, the hooks), read as prose: the same two signals, with
|
||||
* only inline code (between backticks) taken out.
|
||||
*
|
||||
* What it deliberately does NOT look at:
|
||||
*
|
||||
* - Its own word lists, between the `language-data` markers below. They are
|
||||
* the data that detects Spanish, and they would flag themselves.
|
||||
* - Anything quoted inside a code comment. A comment that quotes a value is
|
||||
* quoting data and not writing prose. Quoted text between a matched pair of
|
||||
* backticks or `"` is stripped before the check.
|
||||
* - Files that are not ours to write: `package-lock.json` (generated by npm)
|
||||
* and `public/json/` (upstream's built-in lists, copied as they are). Binary
|
||||
* files (images, fonts) are skipped.
|
||||
*
|
||||
* Exit code is the number of findings, so it can sit in the gate. Run by
|
||||
* `npm test` through `check-language.test.mjs`, and on its own with
|
||||
* `npm run lint:language`.
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
/* language-data:start */
|
||||
const ACCENTS = /[áéíóúüñ¿¡]/i
|
||||
/* Every one of these is a word Spanish prose can hardly avoid and English never
|
||||
uses. Short ones that ARE English (`no`, `es`, `la`, `se`) are left out on
|
||||
purpose: they would flag English paragraphs and a guard that cries wolf gets
|
||||
switched off. */
|
||||
const WORDS = /\b(porque|cuando|pero|sino|para|hay|una|unos|unas|los|las|del|que|con|por|sin|entre|desde|cada|donde|como|aunque|mientras|siempre|nunca|tambien|entonces|segun|asi|solo|mas|esta|estan|habia|era|fue|tiene|hace|puede|debe|esto|este|eso|esa|ese|sus|nada|todo|toda|algo|otra|otro|misma|mismo|aqui|alli|ademas|ahora|antes|despues|dentro|fuera|arriba|abajo|encima|debajo)\b/i
|
||||
const NAMES = /(ranura|caducado|crudo|senal|avisador|aviso|externo|boton|maestro|bloque|fallo|leyenda|interaccion|rotulo|pantalla|tarjeta|hueco|prueba|nodo|vacio|columna|fila|ayuda|derecha|izquierda)/i
|
||||
|
||||
/* Spanish that hides inside an identifier rather than in prose: `setTexto`,
|
||||
`compacto`, `nombreRelativo`, `.caja`. The word list above does not see these
|
||||
(they are not words on their own), so the stems go in a list of their own, and
|
||||
it is checked against identifiers and class names, never against comments,
|
||||
where an English sentence may legitimately quote one. */
|
||||
const STEMS = /\b[A-Za-z]*(?:ranura|caducad|senal|avisad|externo|boton|maestro|bloque|fallo|leyenda|interaccion|rotulo|pantalla|tarjeta|hueco|vacio|columna|fila|ayuda|derecha|izquierda|texto|abiert|compacto|compacta|guardar|cargar|borrar|elegid|marcad|cerrad|nombre|caja|pastilla|salto|ultimo|arbol|secciones|orden(?![a-z])|prueba)[A-Za-z]*\b/
|
||||
|
||||
/*
|
||||
Spanish that hides INSIDE an identifier: `esCache`, `pideTotp`, `firmaBase`.
|
||||
|
||||
Substring stems get this wrong in both directions (`firma` sits inside
|
||||
`confirmation`, which is English, and `orden` misses `ordenar`), so the line is
|
||||
broken into its camelCase pieces first and each piece is looked up WHOLE. That is
|
||||
what catches `esCache` without flagging `confirmation`, and it is why `es` can be
|
||||
on the list at all: as a whole word it is Spanish, as a substring it is in half
|
||||
the English dictionary.
|
||||
|
||||
It runs alongside the stem list, not instead of it: the stems still catch the
|
||||
inflections a whole-word lookup cannot see.
|
||||
*/
|
||||
/*
|
||||
Two lists in one: the whole Spanish words of `WORDS` (which as camelCase pieces
|
||||
are just as Spanish as they are in prose, and `setPorDesinstalar` proved it: `por`
|
||||
is on that list and the regex missed it because there is no word boundary inside
|
||||
an identifier) plus the nouns this codebase actually reached for.
|
||||
|
||||
It grows by finding, and that is the honest way to keep it: every entry below was
|
||||
added because something got through. `por`, `desinstalar`, `hay`, `nota`, `tienda`
|
||||
and `anotacion` are the batch of 2026-09-07, from Apps.
|
||||
*/
|
||||
const PIECES = new Set(
|
||||
(
|
||||
'porque cuando pero sino para hay una unos unas los las del que con por sin entre desde cada ' +
|
||||
'donde como aunque mientras siempre nunca tambien entonces segun asi solo mas esta estan habia ' +
|
||||
'era fue tiene hace puede debe esto este eso esa ese sus nada todo toda algo otra otro misma ' +
|
||||
'mismo aqui alli ademas ahora antes despues dentro fuera arriba abajo encima debajo ' +
|
||||
'es firma firmas firmar pedir pide piden confirmar desinstalar instalar texto nodo nodos arbol ' +
|
||||
'caja cajas vacio vacia senal senial aviso avisos rotulo rotulos pantalla pantallas prueba ' +
|
||||
'pruebas orden ultimo ultima salto hueco huecos fila filas columna columnas ayuda derecha ' +
|
||||
'izquierda abierto abierta compacto compacta guardar cargar borrar marcado cerrado nombre ' +
|
||||
'nombres pastilla boton maestro bloque fallo fallos leyenda caducado ranura externo nota notas ' +
|
||||
'tienda anotacion reemplazar reemplaza tabla ficha fichas ' +
|
||||
'fecha fechas hora horas dia dias mes meses tiempo valor valores clave claves llave lista listas ' +
|
||||
'listar buscar busca filtro filtros filtrar crear editar mostrar ocultar enviar recibir contar ' +
|
||||
'cuenta cuentas ancho anchos alto largo corto nuevo nueva viejo antiguo activo activa apagado ' +
|
||||
'encendido cerrar abrir volver seguir contiene devuelve llama espera ' +
|
||||
'carga cargas bloques cero dos tres cuatro cinco seis siete ocho nueve diez digito digitos ' +
|
||||
'usuario usuarios conserva pegado golpe flotante foco hueco fuerte medio cajon curso ajustes enlaces seccion contenido iteraciones mirar estado ' +
|
||||
/*
|
||||
2026-09-30, the fifth hole. The gate was green and a census (every identifier
|
||||
split into its camelCase pieces, each piece looked up in an English corpus and
|
||||
the leftovers read by hand) found about 140 Spanish identifiers in 90 files:
|
||||
`PropiedadesDnssec`, `EnElCromo`, `servidoresPrimariosObligatorios`, `setOlvido`,
|
||||
`.testigo`, `data-testid="hueco"`, a dozen Spanish test descriptions. None of
|
||||
them used a word on this list, and that is the whole lesson: a blacklist only
|
||||
sees the words someone has already met. It cannot be completed by thinking
|
||||
harder; the census is what finds the next batch, and its words come here.
|
||||
|
||||
These are the pieces of that sweep, renamed and now refused. Left out on
|
||||
purpose, although they were Spanish where they appeared: `el` and `en`, which
|
||||
this code uses in English as the usual shorthand for an element and for the
|
||||
enablement rules, and the one-letter `a` / `o` of `aSlug` and `.oLogin`, which
|
||||
as whole pieces are in every English identifier.
|
||||
*/
|
||||
'agente alta anadiendo apagada aplanar archivo automatico cambiar campo casilla casos catalogo ' +
|
||||
'catalogos celda clases coincidencias conteo cromo crudas cruda cuerpo cuerpos curvas datos depende ' +
|
||||
'dependiente descarte destino detalle dialogo dibujo disponibles ejemplo elemento elementos entero ' +
|
||||
'entrada escapar eventos explicadas fallar fallido fijo forma formulario hacer hola huellas icono ' +
|
||||
'ignorar inicial inicializar inicio instaladas interfaz inverso lanzar llamada llegar mandar manda ' +
|
||||
'marca miembros modo muestra obligatorio obligatorios observador ocultas ocultos olvido otras ' +
|
||||
'partido pestana pintadas pintar preparar previo previa primario primarios propiedades protocolo ' +
|
||||
'protocolos punto raiz recuento refrescar respuesta resultado roto salida secciones sectores ' +
|
||||
'selectores separados serie servidor servidores sobrescribir sujetos tamanos testigo tipo tira ' +
|
||||
'todos tono unico usos visibles visto fin ir ya de al par fase grafica ninguna lecturas transporte ' +
|
||||
'completos propaga tal cual vaciar importar exportar dominios unirse uno existente construir ' +
|
||||
'piezas puras sesiones activas modales sobra esperaba falta'
|
||||
).split(' '),
|
||||
)
|
||||
/* language-data:end */
|
||||
|
||||
/*
|
||||
The description of a test is prose, and it was the one piece of prose this file
|
||||
could not see: code lines have their single-quoted strings stripped (that is what
|
||||
keeps a Spanish DATA literal from being reported), and a test description is a
|
||||
single-quoted string like any other.
|
||||
|
||||
So it is read back on purpose, and only from the three functions that take one.
|
||||
`CONVENTIONS.md` says the tests are in English too; this is what enforces it.
|
||||
*/
|
||||
const DESCRIPTION = /\b(?:it|test|describe)(?:\.\w+)?\(\s*(?:'([^']*)'|`([^`]*)`|"([^"]*)")/g
|
||||
|
||||
/*
|
||||
Names that live inside a string and are still names: a test id and a `data-`
|
||||
attribute. Stripping string literals is what keeps Spanish DATA quiet, and it also
|
||||
hid `data-testid="hueco"` and a selector for `[data-activa="true"]` that matched
|
||||
nothing because the attribute had been renamed to `data-active` everywhere else
|
||||
(2026-09-30, the fifth hole). These are read back from the raw line.
|
||||
*/
|
||||
const NAMED_IN_STRING = /(?:data-testid=\{?\s*|ByTestId\(\s*)['"`]([^'"`]*)['"`]|\bdata-([a-z][a-z-]*)/g
|
||||
|
||||
/** Every camelCase piece of every identifier on the line, lower-cased. */
|
||||
function pieces(line) {
|
||||
const out = []
|
||||
for (const id of line.match(/[A-Za-z][A-Za-z0-9]*/g) ?? []) {
|
||||
for (const w of id.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/\s+/)) {
|
||||
if (w) out.push(w.toLowerCase())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* Proper names are not prose: `Adrián` carries an accent and appears in a dozen
|
||||
decisions that are written in English around it. `Archivo` is the typeface the
|
||||
interface is set in (`theme/base.css`), capitalised as its foundry writes it; the
|
||||
lower-case `archivo` is Spanish and stays refused. */
|
||||
/* language-data:start */
|
||||
const NAMES_ALLOWED = /Adri[áa]n|\bArchivo\b/g
|
||||
/* language-data:end */
|
||||
function stripQuoted(s) {
|
||||
return s
|
||||
.replace(NAMES_ALLOWED, ' ')
|
||||
.replace(/`[^`\n]*`/g, ' ')
|
||||
.replace(/"[^"\n]*"/g, ' ')
|
||||
.replace(/«[^»\n]*»/g, ' ')
|
||||
}
|
||||
|
||||
/* Comments and code, told apart the cheap way: this is a linter for prose, not a
|
||||
parser. A `//` inside a string literal is the known false positive, and the
|
||||
quoted-text strip above is what keeps it quiet. */
|
||||
function split(text) {
|
||||
const comments = []
|
||||
const code = []
|
||||
let i = 0
|
||||
const lines = text.split('\n')
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]
|
||||
if (l.includes('/*')) {
|
||||
let j = i
|
||||
while (j < lines.length && !lines[j].includes('*/')) j++
|
||||
for (let k = i; k <= Math.min(j, lines.length - 1); k++) comments.push([k + 1, lines[k]])
|
||||
i = j + 1
|
||||
} else if (l.trim().startsWith('//') || l.trim().startsWith('*')) {
|
||||
comments.push([i + 1, l]); i++
|
||||
} else {
|
||||
const at = l.indexOf('//')
|
||||
if (at >= 0) { comments.push([i + 1, l.slice(at)]); code.push([i + 1, l.slice(0, at)]) }
|
||||
else code.push([i + 1, l])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return { comments, code }
|
||||
}
|
||||
|
||||
/*
|
||||
The files it reads: every tracked file, so a new folder is covered the day it is
|
||||
committed. `git ls-files` and not a walk of the disk, so `node_modules/`, `dist/`
|
||||
and an untracked local file are never read.
|
||||
*/
|
||||
const NOT_OURS = [
|
||||
/^package-lock\.json$/,
|
||||
/^public\/json\//,
|
||||
/*
|
||||
Responses captured from a real server and kept byte for byte as test fixtures:
|
||||
the domains and records in them are data the server returned (a block list's
|
||||
`t15-lista.com`, a base64 signature), not text anyone here wrote.
|
||||
*/
|
||||
/(^|\/)real-sample\.json$/,
|
||||
/*
|
||||
The ReUI registry code, exactly as `shadcn add` wrote it. It is vendored, so its
|
||||
comments are not ours to reword (one of them trips the word lists).
|
||||
*/
|
||||
/^src\/components\//,
|
||||
]
|
||||
const BINARY = /\.(png|gif|ico|jpe?g|webp|woff2?|ttf|otf|zip|gz)$/i
|
||||
const CODE = /\.(ts|tsx|css|js|mjs|cjs)$/
|
||||
|
||||
export function checkedFiles(root = ROOT) {
|
||||
return execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
|
||||
.split('\0')
|
||||
.filter(Boolean)
|
||||
.filter((f) => !NOT_OURS.some((re) => re.test(f)) && !BINARY.test(f))
|
||||
}
|
||||
|
||||
/*
|
||||
The lines of its own word lists, which are data and would flag themselves, and
|
||||
the Spanish samples its test feeds it. Only those two files carry the markers,
|
||||
and only the lines between them are dropped.
|
||||
*/
|
||||
function withoutLanguageData(text) {
|
||||
const lines = text.split('\n')
|
||||
let inside = false
|
||||
return lines
|
||||
.map((l) => {
|
||||
if (l.includes('/* language-data:start */')) inside = true
|
||||
const out = inside ? '' : l
|
||||
if (l.includes('/* language-data:end */')) inside = false
|
||||
return out
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
/* A line of prose with its inline code and the allowed names taken out. Quoted
|
||||
text stays: in a document, a quotation is still something a reader reads. */
|
||||
function proseOf(line) {
|
||||
return line.replace(NAMES_ALLOWED, ' ').replace(/`[^`\n]*`/g, ' ')
|
||||
}
|
||||
|
||||
/*
|
||||
In a document a word is only a word between word boundaries: `es2023` in a
|
||||
tsconfig is a target and not the Spanish `es`. Code comments keep the looser
|
||||
lookup they always had.
|
||||
*/
|
||||
const isSpanishProse = (clean, { whole = false } = {}) =>
|
||||
ACCENTS.test(clean) ||
|
||||
WORDS.test(clean) ||
|
||||
(clean.toLowerCase().match(whole ? /\b[a-z]+\b/g : /[a-z]+/g) ?? []).some((w) => PIECES.has(w))
|
||||
|
||||
/*
|
||||
`Math.sin` is the sine, not the Spanish `sin`: the colour tools in `dev/` compute
|
||||
CIEDE2000 with it. The standard library's own members are taken out of a code
|
||||
line before the word lists see it.
|
||||
*/
|
||||
const STDLIB = /\bMath\.\w+/g
|
||||
|
||||
/* The string literals of a code line: in `dev/` they are what a tool prints. */
|
||||
const STRINGS = /'([^'\n]*)'|"([^"\n]*)"|`([^`\n]*)`/g
|
||||
|
||||
/** The findings of one file, given its path relative to the root and its text. */
|
||||
export function checkText(rel, raw) {
|
||||
const findings = []
|
||||
const segments = rel.split('/')
|
||||
if (segments.some((seg) => NAMES.test(seg))) findings.push(`${rel} file name is not English`)
|
||||
|
||||
const text = withoutLanguageData(raw)
|
||||
if (!CODE.test(rel)) {
|
||||
text.split('\n').forEach((line, i) => {
|
||||
if (isSpanishProse(proseOf(line), { whole: true })) findings.push(`${rel}:${i + 1} Spanish in the text: ${line.trim().slice(0, 70)}`)
|
||||
})
|
||||
return findings
|
||||
}
|
||||
|
||||
const { comments, code } = split(text)
|
||||
|
||||
for (const [n, line] of comments) {
|
||||
const clean = stripQuoted(line)
|
||||
/*
|
||||
The comments were checked against WORDS only, and prose without an accent or
|
||||
one of those connectives walked through: «Cuatro bloques.», «Tabla»,
|
||||
«/* Fechas *\/», «cerrar no avisa» (2026-09-30, the fourth hole). The whole-
|
||||
word PIECES lookup is applied here too; on the day it was added it flagged
|
||||
nothing that was not Spanish.
|
||||
*/
|
||||
if (isSpanishProse(clean)) findings.push(`${rel}:${n} Spanish in a comment: ${line.trim().slice(0, 70)}`)
|
||||
}
|
||||
/*
|
||||
Outside `src/` the string literals are read too. In `src/` a string is mostly
|
||||
upstream's data and stays quiet; in a tool it is the message the tool prints,
|
||||
and a Spanish one walked through for weeks (`dev/row-colours.mjs` reporting
|
||||
"COLISIÓN", `dev/parity-login.mjs` reporting "PARIDAD DE MENSAJES").
|
||||
*/
|
||||
const readStrings = !rel.startsWith('src/')
|
||||
for (const [n, line] of code) {
|
||||
const clean = stripQuoted(line).replace(/'[^'\n]*'/g, ' ').replace(STDLIB, ' ')
|
||||
if (readStrings)
|
||||
for (const m of line.matchAll(STRINGS)) {
|
||||
const str = (m[1] ?? m[2] ?? m[3] ?? '').replace(NAMES_ALLOWED, ' ')
|
||||
if (isSpanishProse(str, { whole: true })) findings.push(`${rel}:${n} Spanish in a string: ${str.slice(0, 70)}`)
|
||||
}
|
||||
const spanishPiece = pieces(clean).find((w) => PIECES.has(w))
|
||||
if (ACCENTS.test(clean) || WORDS.test(clean) || STEMS.test(clean) || spanishPiece != null)
|
||||
findings.push(`${rel}:${n} Spanish in code: ${line.trim().slice(0, 70)}`)
|
||||
|
||||
for (const m of line.matchAll(DESCRIPTION)) {
|
||||
const what = m[1] ?? m[2] ?? m[3] ?? ''
|
||||
const piece = pieces(what).find((w) => PIECES.has(w))
|
||||
if (ACCENTS.test(what) || WORDS.test(what) || piece != null)
|
||||
findings.push(`${rel}:${n} Spanish in a test description: ${what.slice(0, 70)}`)
|
||||
}
|
||||
|
||||
for (const m of line.matchAll(NAMED_IN_STRING)) {
|
||||
const name = m[1] ?? m[2] ?? ''
|
||||
const piece = pieces(name.replace(/-/g, ' ')).find((w) => PIECES.has(w))
|
||||
if (ACCENTS.test(name) || piece != null)
|
||||
findings.push(`${rel}:${n} Spanish in a test id or data attribute: ${name.slice(0, 70)}`)
|
||||
}
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
export function check(root = ROOT) {
|
||||
const findings = []
|
||||
for (const rel of checkedFiles(root)) {
|
||||
const file = path.join(root, rel)
|
||||
if (!fs.existsSync(file)) continue
|
||||
const buf = fs.readFileSync(file)
|
||||
if (buf.includes(0)) continue
|
||||
findings.push(...checkText(rel, buf.toString('utf8')))
|
||||
}
|
||||
return findings
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const findings = check()
|
||||
if (findings.length === 0) {
|
||||
console.log(`\n The repository is in English: no Spanish in the ${checkedFiles().length} tracked text files.\n`)
|
||||
} else {
|
||||
const byFile = new Map()
|
||||
for (const f of findings) {
|
||||
const k = f.split(':')[0].split(' ')[0]
|
||||
byFile.set(k, (byFile.get(k) ?? 0) + 1)
|
||||
}
|
||||
const sorted = [...byFile].sort((a, b) => b[1] - a[1])
|
||||
for (const [f, n] of sorted) console.log(` ${String(n).padStart(4)} ${f}`)
|
||||
console.log(`\n ${findings.length} findings in ${byFile.size} files\n`)
|
||||
if (process.env.DETAIL) for (const f of findings) console.log(' ' + f)
|
||||
}
|
||||
process.exit(Math.min(findings.length, 250))
|
||||
}
|
||||
Reference in new issue
Block a user