store: el árbol de categorías, igual que el original (colores y sin fantasmas)

Investigadas las 750 categorías de la BD contra el HTML real del original. Los
ítems estaban perfectos (554 hojas, 3068 ítems, mismo contenido en cada una: no
faltaba ninguno). Todo lo roto era de categorías:

- 45 CATEGORÍAS FANTASMA, todas con nombre de código ("11-1", "23-10"). El seed
  dedujo el árbol de los códigos de las hojas ("11-1-1" => padre "11-1"), pero
  el original NO siempre pinta ese nivel: en 45 hojas el "-1-" del código no
  corresponde a nada y cuelgan directas de su raíz. Ahora se lee el anidamiento
  real (<ul>/<li>): 705 categorías (20 + 131 + 554) en vez de 750.
- El nombre de una categoría NO es texto plano, es HTML, y lo estábamos
  aplanando: 141 nombres perdían su color y 40 cabeceras el suyo. Hay dos
  mecanismos y los dos hacen falta: la clase en la CABECERA, que sustituye a
  first-brown para colorear la categoría entera ("Brujo" en color de brujo), y
  los <span class="no-toggle X"> dentro del nombre (clases y las etiquetas
  [Nivel 232 a 245] / (DPS) en otro tono).
- 21 nombres TRUNCADOS en Tokens de Armadura: guardábamos "Guerrero" donde el
  original dice "Guerrero, Sacerdote, Druida" (solo se quedó el primer span).

El HTML se trocea en el SERVIDOR (`parseCategoryName`) y al cliente le llegan
solo texto + nombre de clase, así que no hay HTML de la BD entrando en el DOM.
El texto sin color va suelto, NO envuelto en <span>: el tema tiene
`span { color: #fff }` y lo pondría blanco en vez de heredar el de la cabecera.

`name` pasa a VARCHAR(512): con el HTML, el más largo mide 198 y no cabía en 191.
El name_en se genera en el propio seed traduciendo solo el TEXTO y respetando
las etiquetas, así que sql/store_category_en.sql sobra y se borra.
gen_store_catalog.py regenera el seed entero (categorías del HTML, ítems de la
BD, que ya estaban verificados) y es reproducible byte a byte.

Verificado en el navegador contra el original: mismo árbol, 0 categorías con
nombre de código, "[Nivel 232 a 245]" en marrón tenue y los colores de clase
oficiales (Guerrero #C79C6E, Sacerdote blanco, Druida #FF7D0A). En inglés
también, conservando los colores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 11:24:19 +00:00
parent fb28e97307
commit 24248bc603
6 changed files with 1068 additions and 1064 deletions
+61 -3
View File
@@ -27,9 +27,25 @@ export interface StoreItem {
preview: string | null
}
/**
* Un trozo del nombre de una categoría: texto y, si lo lleva, la clase de color
* con la que hay que pintarlo (`warrior`, `second-brown`…).
*
* El nombre viene de BD como HTML (así lo hace el original: colorea los nombres
* de clase y pone las etiquetas en otro tono). Se trocea AQUÍ, en el servidor, y
* al cliente le llega ya estructurado: así se pinta con React y no hace falta
* meter HTML de la BD en el DOM.
*/
export interface NamePart {
text: string
cls?: string
}
export interface StoreCategory {
code: string
name: string
name: NamePart[]
/** Clase de la cabecera; a veces sustituye a first-brown para colorearla entera. */
css: string
depth: number
children: StoreCategory[]
items: StoreItem[]
@@ -39,6 +55,7 @@ interface CatRow extends RowDataPacket {
code: string
parent_code: string | null
name: string
css: string
depth: number
sort: number
}
@@ -55,6 +72,40 @@ interface ItemRow extends RowDataPacket {
sort: number
}
/** `<span class="no-toggle X">…</span>` — la única etiqueta que usa el original. */
const NAME_SPAN = /<span class="no-toggle ([a-z0-9- ]+)">([^<]*)<\/span>/g
function unescapeHtml(s: string): string {
return s
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}
/**
* Trocea el nombre HTML de una categoría en partes con su color.
*
* Solo entiende `<span class="no-toggle X">`, que es lo único que usa el
* catálogo. Cualquier otra etiqueta se ignora y queda su texto: si alguien mete
* HTML raro en la BD, aquí NO puede acabar inyectado (al cliente solo le llegan
* texto y un nombre de clase).
*/
export function parseCategoryName(html: string): NamePart[] {
const parts: NamePart[] = []
let last = 0
for (const m of html.matchAll(NAME_SPAN)) {
const plain = html.slice(last, m.index).replace(/<[^>]*>/g, '')
if (plain) parts.push({ text: unescapeHtml(plain) })
if (m[2]) parts.push({ text: unescapeHtml(m[2]), cls: m[1] })
last = m.index + m[0].length
}
const tail = html.slice(last).replace(/<[^>]*>/g, '')
if (tail) parts.push({ text: unescapeHtml(tail) })
return parts.length ? parts : [{ text: unescapeHtml(html.replace(/<[^>]*>/g, '')) }]
}
/**
* Árbol completo de la tienda (categorías anidadas con sus ítems en las hojas).
*
@@ -75,7 +126,7 @@ export async function getStoreCatalog(locale: string): Promise<StoreCategory[]>
let items: ItemRow[] = []
try {
;[cats] = await db(DB.default).query<CatRow[]>(
`SELECT code, parent_code, COALESCE(NULLIF(${catNameCol}, ''), name) AS name, depth, sort
`SELECT code, parent_code, COALESCE(NULLIF(${catNameCol}, ''), name) AS name, css, depth, sort
FROM home_store_category ORDER BY sort`,
)
;[items] = await db(DB.default).query<ItemRow[]>(
@@ -91,7 +142,14 @@ export async function getStoreCatalog(locale: string): Promise<StoreCategory[]>
const nodes = new Map<string, StoreCategory>()
for (const c of cats) {
nodes.set(c.code, { code: c.code, name: c.name, depth: c.depth, children: [], items: [] })
nodes.set(c.code, {
code: c.code,
name: parseCategoryName(c.name),
css: c.css || 'first-brown',
depth: c.depth,
children: [],
items: [],
})
}
const roots: StoreCategory[] = []
for (const c of cats) {