# Catálogo de slugs + historial SEO

> **Estado: IMPLEMENTADO COMPLETO (pasos 0 a 4) + `slugs` como FUENTE ÚNICA. 2026-08-20.**
> Las 6 tablas de contenido ya NO tienen columna de slug: ver §13.
> Diseñado el 2026-08-19 sobre la rama `pivot/editor-web`. Todos los datos de este documento
> fueron verificados contra la DB `itg_edit_web_temporal` y el código real de esa rama.
> Leer completo antes de empezar: el orden de los pasos importa.
>
> **👉 Qué cambió al implementar (correcciones al diseño, hallazgos y archivos): §10 y §11 al final.**

---

## 0. Reglas de uso — resumen operativo

Reglas que rigen cualquier trabajo futuro sobre contenido enrutable (movidas aquí desde
`CLAUDE.md`, que solo conserva el resumen de una línea):

- **`slugs` es la FUENTE ÚNICA.** Las 6 tablas de contenido no tienen columna de slug. **Nunca**
  hacer `where('slug', …)` sobre esas tablas: usar `Sluggables::resolvePath()`, `slugLabel()` (nombre
  en PHP) o el route binding de `HasPublicPath`.
- **La forma de una URL se declara UNA sola vez, en `publicPathPattern()` del modelo.**
  `routes/web.php` no declara ninguna forma de URL de contenido: el catch-all
  (`Route::get('/{path}', ContentController::class)`) resuelve el path completo contra `slugs` →
  `slug_history` (301) → 404. Cambiar una forma de URL es una línea en `publicPathPattern()` +
  `slugs:sync` (reproyecta y escribe los paths viejos en `slug_history`, los 301 salen solos).
  **Regla: si no está en `slugs`, es ruta; si está, lo resuelve el catálogo.**
- **Agregar un séptimo modelo enrutable** = agregarlo a `Sluggables::classes()` **y** al `match` de
  `ContentController`; si falta lo segundo, su path da 404.
- **Formato guardado en los selects/TipTap:** `slug:42` (referencia) · `login` (path literal) ·
  `https://…` · `mailto:`. Lo resuelve `App\Support\Slugs\SlugReference` (helper `slug_path()`).
- ⚠️ **Correr `links:audit --strict` después de cualquier cambio de forma de URL en `routes/web.php`
  o en `publicPathPattern()`.** Los 3 destrozos históricos del proyecto (`/page/{slug}`,
  `machinery/category/{x}`, `products/sector/{x}`) fueron exactamente eso.
- `deploy.sh` ya corre `slugs:sync` después de `migrate` (idempotente, nunca corta el deploy).
  `sitemap:generate` tiene sus propias trampas (URLs absolutas por división, qué no listar) →
  `docs/CLAUDE-SEO-RASTREO.md` §4.

## 1. El problema

Los redirects del page builder guardan **el path como texto**, no una referencia. Si cambia el slug
de una página, blog, máquina o producto, todos los enlaces que apuntaban ahí siguen apuntando al
path viejo → el catch-all `/{slug}` no lo encuentra → **404 silencioso**.

Nadie se entera: no hay error en logs, el enlace simplemente deja de funcionar.

### Dónde viven los redirects hoy (verificado)

| Ubicación | Columna | Con redirect |
|---|---|---|
| `nav_menu_items` | `redirect` (string) | **47** filas |
| `nav_menu_items` | `sub_items` (JSON anidado, 2 niveles) | **31** |
| `content_block_pages` | `content` (JSON) | **11** de 48 |
| `footer_blocks` | `content` (JSON) | **8** de 8 |
| `header_blocks` | `content_blocks` (JSON) | **8** de 8 |
| `shared_sections` | `content` (JSON) | **1** de 6 |
| TipTap (`href` embebido en el HTML del contenido rico) | — | **2** |

≈ **108 puntos de almacenamiento** dispersos en 6 tablas + HTML embebido.

⚠️ Las estructuras JSON **no son uniformes**. Formas reales encontradas en las vistas:

```
['redirect']['value']
['button_link']['redirect']
['button_wrapper']['button']['redirect']
['redirection_settings']['redirect']
['redirect']['redirect']
```

Por eso se descartó la vía de "buscar y reemplazar el slug viejo en los JSON": es frágil
(un slug corto como `itg` aparece dentro de otros textos) y un bloque nuevo que anide distinto
quedaría fuera del recorrido en silencio.

### El dato que hace todo esto viable

**El render ya está centralizado.** 12 vistas usan `<x-redirect-link>` y **ninguna** construye la URL
a mano. Todo pasa por una sola línea de `resources/views/components/redirect-link.blade.php`:

```blade
$href = url((string) $redirectValue);
```

Ese es el único punto donde hay que resolver la referencia → path.

---

## 2. La solución: DOS tablas con roles distintos

| Tabla | Guarda | Al renombrar | Quién la lee |
|---|---|---|---|
| `slugs` | lo que existe **ahora** | se **actualiza** (UPDATE) | el select del panel + `<x-redirect-link>` |
| `slug_history` | lo que existió **antes** | se **acumula** (INSERT) | **solo** el router, justo antes de dar 404 |

**Importante (fue el punto que más confusión generó):** `slug_history` **nunca** alimenta el select.
El desplegable del panel lee `slugs`, que tiene una sola fila por contenido, así que es imposible que
aparezcan dos opciones (`about-us` y `nosotros`) para la misma página.

---

## 3. Estructura de las tablas

### `slugs` — catálogo de lo actual

```
slugs
─────────────────────────────────────────────────────────────────────
  id                bigint, PK
  sluggable_type    string             "App\Models\Page"     (polimórfico)
  sluggable_id      unsigned bigint    4
  slug              string(190)        "nosotros"            ← se ACTUALIZA
  path              string(255)        "nosotros"            ← path público ya armado
  is_active         boolean, default true
  created_at / updated_at

  UNIQUE (sluggable_type, sluggable_id)   ← UNA fila por contenido (clave del diseño)
  INDEX  (path)
  INDEX  (slug)
```

Decisiones y su motivo:

- **`UNIQUE (sluggable_type, sluggable_id)`**: garantiza una sola fila por contenido. Es lo que hace
  físicamente imposible que el select muestre slugs viejos o duplicados.
- **`path` guardado, no calculado**: el render resuelve con **una query a `slugs`**, sin cargar el
  modelo ni tocar las 6 tablas de contenido. El observer lo regenera al cambiar el slug.
- **`label`: ELIMINADA** (migración `000003`). El nombre que ve el editor se lee **en vivo** del
  contenido, no se copia: ver §14.
- **`is_active`**: refleja `is_published` / `status` del modelo, para no ofrecer contenido
  despublicado (hoy sí se cuela).

### `slug_history` — historial para SEO

```
slug_history
─────────────────────────────────────────────────────────────────────
  id                bigint, PK
  old_path          string(255), UNIQUE   "about-us"        ← se ACUMULA
  sluggable_type    string                "App\Models\Page"
  sluggable_id      unsigned bigint       4
  hits              unsigned int, default 0
  last_hit_at       timestamp, null
  created_at / updated_at

  UNIQUE (old_path)
  INDEX  (sluggable_type, sluggable_id)
```

- **No guarda el path nuevo a propósito.** Guarda de *quién* era la URL. El destino se pregunta al
  catálogo en el momento de la petición. Así, si el slug cambia 3 veces, todas las URLs viejas
  resuelven al path actual **en un solo salto** (Google penaliza las cadenas de redirects).
- **`hits` / `last_hit_at`**: para poder purgar. Google recomienda mantener un 301 ~12 meses, no
  para siempre. A los 12 meses, las filas sin hits se borran (comando de prune en el cron, junto al
  `notifications:prune` de las 03:10).
  ✅ **Verificado el 2026-09-18** (`config/slugs.php` + `PruneSlugHistory::handle()`): el borrado
  exige **las dos condiciones a la vez** — `created_at` con más de `retention_months` (12, sin
  override en `.env`) **Y** (`last_hit_at` es null **O** también tiene más de 12 meses). Una fila que
  sigue recibiendo clics no se borra sin importar la antigüedad. 12 meses no es corto — coincide con
  la propia recomendación de Google, y no hay override activo que lo acote.

---

## 4. Cómo se engancha la lógica

### Al renombrar (un solo observer, 4 momentos)

El mismo observer sirve para los 6 modelos (de ahí que se reutilice el código):

```php
// created
Slug::create([...]);                                  // nueva fila en el catálogo

// updated y cambió el slug
SlugHistory::firstOrCreate(                           // firstOrCreate, NO create:
    ['old_path' => $pathAnterior],                    // si renombras A→B→A→B no debe petar
    ['sluggable_type' => ..., 'sluggable_id' => ...]
);
$model->slugRow->update(['slug' => $nuevo, 'path' => $nuevoPath]);   // UPDATE, no delete+create

// updated y cambió is_published / status
$model->slugRow->update(['is_active' => ...]);

// deleted
$model->slugRow->delete();
```

Detalle: **si un slug vuelve a usarse, borrar su fila de `slug_history`.** El orden de resolución ya
evita el conflicto, pero deja la tabla honesta.

### Al servir una petición (orden de resolución — IMPORTANTE)

```
GET /about-us
  1º  ¿existe contenido con ese path?      → SÍ  → se sirve (fin)
  2º  ¿está en slug_history?               → SÍ  → 301 al path actual del catálogo
  3º  404
```

El paso 1 va **primero** siempre. Eso resuelve el caso borde de **reutilizar un slug viejo**: si
renombras `about-us` → `nosotros` y meses después creas una página nueva con slug `about-us`, la
nueva gana y la fila del historial queda inerte, sin secuestrar la URL.

Resolución del 301, con las dos tablas (dos queries):

```sql
SELECT sluggable_type, sluggable_id FROM slug_history WHERE old_path = 'about-us';
   → App\Models\Page , 4
SELECT path FROM slugs WHERE sluggable_type = 'App\Models\Page' AND sluggable_id = 4;
   → 'nosotros'
-- responder 301 → /nosotros
```

### Al pintar un enlace

```
Editor elige en el select   →  guarda  "slug:1"
<x-redirect-link>           →  Slug::find(1)->path  →  "nosotros"
```

Renombrar la página actualiza la fila 1 → los ~108 enlaces internos siguen correctos **sin tocarlos**.

---

## 5. `publicPath()` — la pieza compartida (paso 1)

Cada tipo arma su URL distinto. Verificado contra `routes/web.php` de `pivot/editor-web`:

| Modelo | Columna slug | Flag publicado | Path público | Ruta (línea) |
|---|---|---|---|---|
| `Page` | `slug` | `is_published` | `{slug}` | `page.show` (337) |
| `Blog` | `slug` | `status` | `blogs/{slug}/detail` | `blog.show` (75) |
| `BlogCategory` | `name` | — | `blogs/{name}` | `blog.category` (74) |
| `Machine` | `slug` | `status` | `machinery/{slug}/detail` | `machinery.show` |
| `MachineCategory` | `name` | — | `machinery/{name}/category` | `machinery.category` |
| `ProductSector` | `slug` | `status` | `products/{slug}` | `products.show` (49) |

⚠️ Ojo: **`BlogCategory` y `MachineCategory` usan `name`, no `slug`**, y no tienen flag de publicado.
El `publicPath()` y el observer deben contemplarlo (columna configurable por modelo, p. ej. una
constante o un método `slugColumn()`).

Conteo actual → el catálogo arranca con **73 filas**:

```
pages                4        machines              26
blogs               27        machine_categories     7
blog_categories      3        product_sectors        6
```

`publicPath()` lo necesitan **las tres** cosas: catálogo, historial y `sitemap:generate`. Por eso va primero.

---

## 6. Formato del valor guardado ✅ IMPLEMENTADO (ver §7)

El select **no** solo ofrece contenido del catálogo. También permite:

- rutas custom escritas a mano vía "Create option" (`login`, `machinery/custom-machine`)
- URLs externas (`https://…`)
- `mailto:` / `tel:`

Propuesta: **prefijo `slug:`**

```
"slug:42"                → referencia al catálogo → resolver por path actual
"login"                  → path literal, tal cual (compatibilidad + rutas custom)
"https://ejemplo.com"    → externa, tal cual
"mailto:a@b.com"         → tal cual
```

`<x-redirect-link>` mira el prefijo y decide. **Los ~108 redirects existentes siguen funcionando como
texto** y se migran a referencia a medida que alguien los edite — no hace falta migración masiva de datos.

---

## 7. Plan de implementación (en orden, cada paso deja el sitio funcionando)

| # | Paso | Estado | Notas |
|---|---|---|---|
| 0 | **Comando de auditoría** `links:audit` | ✅ **HECHO** | Recorre los 6 almacenes y lista los redirects internos que apuntan a slugs inexistentes. **Cero riesgo.** Dice si el problema ya está pasando y sirve para validar los pasos siguientes. |
| 1 | **`publicPath()`** en los 6 modelos | ✅ **HECHO** | Pieza compartida. No cambia comportamiento. |
| 2 | **Tabla `slugs`** + observer + comando para poblar los 73 registros | ✅ **HECHO** | Todavía no cambia el comportamiento del sitio: solo mantiene el catálogo sincronizado. Reversible. |
| 3 | **`InternalSlugSelect`** lee del catálogo + **`<x-redirect-link>`** resuelve la referencia | ✅ **HECHO** | Los 4 formatos del punto 6 probados. Detalle en §12. |
| 4 | **Tabla `slug_history`** + 301 + `slugs:prune-history` | ✅ **HECHO** | No toca datos existentes. El 301 NO va en el catch-all: ver §10.2. |

Se ejecutó **0 → 1 → 2 → 4 → 3**, dejando el 3 para el final porque es el único que cambia el
comportamiento del panel. Los dos mecanismos se complementan:

- **Enlace nuevo (referencia):** renombrar actualiza la fila del catálogo → el enlace apunta al
  path nuevo **sin redirect**, en un solo salto.
- **Enlace viejo (texto) y URLs ya indexadas en Google:** el path viejo queda en `slug_history` y
  responde **301** al actual.

Los ~108 redirects guardados como texto **siguen funcionando** (nunca dejaron de resolverse) y se
convierten en referencia solos: `dehydrateStateUsing` en el select reescribe el valor la próxima vez
que alguien guarde ese formulario.

✅ **2026-09-18: se hizo la migración masiva de todos modos**, para los que quedaban sin tocar
(48 links en 24 filas, confirmado con `links:audit --strict` en 0 rotos antes y después). Motivo:
depender de que alguien vuelva a guardar ese formulario puntual, o de que `slug_history` no pode la
entrada que los salva con un 301, es un riesgo silencioso — ver §"Retención de 12 meses" abajo.
`links:repair` ganó el flag `--to-references` (reusa el mismo recorrido de las 6 tablas que ya usan
`links:audit`/`links:repair`, no es lógica aparte), invocado por
`2026_09_18_165640_upgrade_working_links_to_slug_references` — mismo patrón que
`media:brand-images-to-storage`: un comando invocado desde una migración para que corra solo en el
próximo deploy.

Sugerencia: hacer el **paso 0 primero**. Si devuelve 0 enlaces roto y los slugs casi nunca cambian,
quizá conviene posponer todo lo demás; si devuelve 15, ya sabes que la inversión se justifica.

---

## 8. Recordatorios del proyecto que aplican aquí

- **Migraciones idempotentes y pensadas para prod**: guards `Schema::hasTable` / `hasColumn`, como en
  `2026_08_19_000000_add_has_preloader_to_pages_table.php`. Ver `CLAUDE.md → Base de datos / entorno`.
- **La DB local NO es producción.** No basar la lógica en sus conteos; el comando que puebla el
  catálogo tiene que funcionar con los datos reales de prod.
- **Nada de `hasRole()`**: si alguna pantalla nueva necesita gate, va por permiso.
- **Textos de usuario en `__()`** (labels del select, mensajes del comando que ve el usuario).
  Los `Log::` y las excepciones técnicas quedan en inglés.
- Al terminar, si se toca `page.css`, respetar la regla de **editar solo lo estrictamente necesario y
  acotado por bloque** (`CLAUDE.md → Stack de UI`).

---

## 9. Alternativas descartadas (y por qué, para no volver sobre ellas)

- **Dejarlo como está**: enlaces roto silenciosos, y se pierde el posicionamiento SEO de la URL vieja.
- **Buscar y reemplazar el slug en los JSON al renombrar** (observer de sincronización): frágil por las
  5 formas de anidamiento distintas + `sub_items` a 2 niveles; un bloque nuevo quedaría fuera en
  silencio, y un `LIKE` textual corrompería contenido con slugs cortos.
- **Guardar `page:12` con el nombre de clase directamente en el redirect**: son 6 tipos distintos; el
  catálogo polimórfico los unifica en una sola referencia y da además `label` e `is_active`.
- **Guardar `new_path` en el historial en vez de la referencia al modelo**: genera cadenas de
  redirects al renombrar dos veces (`viejo → medio → actual`), penalizadas por Google, y las filas
  quedan obsoletas.
- **Borrar la columna `slug` de las 6 tablas y dejar solo `slugs`**: no. Las dos cosas hacen
  trabajos distintos y la columna **se queda**:
  1. **`slugs` es un índice derivado, no la fuente de verdad.** El dueño del slug es el contenido;
     el catálogo lo espeja en una forma barata de referenciar y resolver. `slugs:sync` puede
     reconstruirlo desde cero en cualquier momento **precisamente porque la columna sigue ahí**. Si
     se borra, el catálogo pasa a ser la única copia: un bug del observer o una fila perdida se
     lleva la URL del contenido sin vuelta atrás.
  2. **El route-model binding lo necesita.** `/machinery/{machine:slug}/detail`,
     `/blogs/{blog:slug}/detail`, `/products/{productSector:slug}` resuelven consultando la columna
     del propio modelo. Sin ella habría que escribir `resolveRouteBinding()` en los 6 modelos
     pasando por el catálogo, y tocar además los forms, tablas, filtros y validaciones de unicidad
     de Filament, el `firstOrFail()` de `PageController` y `GeneralUtil`. Mucho más código para
     ganar nada.
  3. **La unicidad y la validación viven con el dato.** "el slug de una máquina no se repite" es una
     regla de `machines`, no del catálogo.

  Duplicación asumida a conciencia: el slug queda en dos lugares y se sincroniza (observer +
  `slugs:sync` para reparar + `pruneOrphans` para borrados), el mismo trato que se le da a cualquier
  índice denormalizado. Lo que aporta `slugs` **no** es normalizar el slug: es la **indirección**
  que permite que un enlace apunte a un `id` estable en vez de a una cadena de texto.

---

## 10. Verificación del diseño (2026-08-20) — correcciones y hallazgos

Todo lo del diseño se comprobó contra el código y la DB antes de escribir una línea. El diseño se
sostiene: las dos tablas, el orden de resolución, guardar el owner en vez del path nuevo y el
`UNIQUE (sluggable_type, sluggable_id)` quedaron tal cual. Lo que **cambió** o **faltaba**:

### 10.1 `routes/web.php` ya tenía un `Route::fallback()` — y eso cambia el paso 0 y el 4

`Route::fallback(fn () => response()->view('errors.404', [], 404))` (línea ~86) **matchea
cualquier path**, así que el router nunca lanza `NotFoundHttpException` por "no hay ruta": el 404
lo produce el propio fallback. Consecuencias:

- `links:audit` tuvo que aprender a mirar `$route->isFallback`: sin eso daba por buenos 94 enlaces
  roto ("los sirve una ruta sin nombre" → era el fallback pintando el 404).
- El fallback es además **el sitio natural para el 301**, mejor que el catch-all `/{slug}`.

### 10.2 El 301 NO puede vivir en el catch-all `/{slug}` (corrección al paso 4)

`/{slug}` solo matchea **un** segmento, y los 404 de blogs, máquinas, productos y categorías no
pasan por ahí: los produce el **route-model binding** (`/blogs/{blog:slug}/detail`) o el
`firstOrFail()` del controlador, que se convierten en `NotFoundHttpException` sin tocar el
catch-all ni el fallback. Por eso el chequeo del historial se enganchó en **dos** puntos, ambos
delegando en el mismo `SlugRedirector`:

1. `Route::fallback()` en `routes/web.php` → paths que no matchean ninguna ruta (`page/about`).
2. `$exceptions->render(NotFoundHttpException ...)` en `bootstrap/app.php` → fallos de binding y
   `firstOrFail` (`/machinery/water-looms`, `/blogs/x/detail`, `/products/x`).

Ambos devuelven `null` si el historial no conoce el path, así que el 404 de siempre sigue igual.
Probado con curl: página renombrada → 301; máquina renombrada (binding) → 301.

### 10.3 El JSON de los redirects **sí es uniforme** en el almacenamiento

El diseño descartaba el buscar-y-reemplazar por las "5 formas de anidamiento". Verificado sobre los
datos reales: esas 5 formas son cómo **las vistas** leen el dato; en la DB **siempre** es
`"redirect": {"type": …, "value": …}`, a cualquier profundidad (y `href` dentro de los marks de
TipTap). Por eso `LinkStores` busca **por nombre de clave a cualquier nivel** en vez de hardcodear
los wrappers: un bloque nuevo que anide distinto igual aparece. No cambia la decisión de fondo
(referencia > texto), solo hace la auditoría fiable.

### 10.4 La rama había cambiado las URLs de maquinaria (⚠️ el hallazgo más grave, ya corregido)

`pivot/editor-web` reescribió las rutas públicas de maquinaria **sin dejar alias**, así que al
desplegar habría 404eado **todas** las URLs de maquinaria ya indexadas en Google:

| Contenido | Producción (URL viva) | Lo que tenía esta rama | Estado |
|---|---|---|---|
| Máquina | `/machinery/{name}/detail` | `/machinery/{slug}` | ❌ sin alias → **restaurado** |
| Categoría de máquina | `/machinery/{name}/category` | `/machinery/category/{name}` | ❌ sin alias → **restaurado** |
| Producto | `/products/{name}/sector` | `/products/{slug}` | ✅ con alias 301, se dejó así |
| Blog | `/blogs/{name}/detail` | `/blogs/{slug}/detail` | ✅ misma forma |
| Categoría de blog | `/blogs/{name}` | `/blogs/{name}` | ✅ igual |

Se restauró **la forma de producción** en maquinaria (el binding sigue siendo `{machine:slug}`,
porque las migraciones del editor renombraron `machines.name` → `machines.slug`; lo que se revirtió
es la **forma del path**, no la columna):

```php
Route::get('/machinery/{category:name}/category', 'index')->name('machinery.category');
Route::get('/machinery/{machine:slug}/detail',    'show')->name('machinery.show');
```

Dos consecuencias:

- **El select nunca tuvo el bug** que se le atribuyó: generaba `machinery/{slug}/detail`, que es lo
  correcto en producción. Lo que estaba mal eran las rutas de la rama.
- **El paso 4 absorbió el cambio de rutas solo:** al correr `slugs:sync`, las 33 filas viejas
  (`machinery/x`, `machinery/category/x`) pasaron a `slug_history` y **responden 301** a la forma
  nueva. Ni un enlace hubo que editar. En producción el catálogo se crea directamente con la forma
  correcta, así que no genera historial.
- El **sitemap** ya no arma los paths a mano: usa `publicPath()` de cada modelo. Antes había seis
  concatenaciones distintas y por eso publicaba URLs que el router había dejado de servir.

⚠️ **Producto queda inconsistente a propósito** (`/products/{slug}` en vez de
`/products/{slug}/sector`): ahí la rama **sí** dejó un alias 301, así que no hay nada roto. Si se
quiere alinear con producción es un cambio de una línea en `routes/web.php` +
`ProductSector::publicPathPattern()` + `slugs:sync`.

### 10.5 `<x-redirect-link>` no es el único punto de render (✅ resuelto en el paso 3)

`resources/views/frontend/layouts/navbar.blade.php:36` construye el href **a mano**
(`url($item->redirect)`) para el enlace padre de los dropdowns. Si el select empieza a guardar
`slug:42`, ese enlace imprime `/slug:42`. En el paso 3 hay que pasarlo por `<x-redirect-link>`
(los otros 12 usos ya lo hacen).

### 10.6 Los enlaces internos de TipTap no pasan por el componente (✅ resuelto: opción (a))

`CustomTiptapLinkAction::buildHref()` guarda el path **crudo dentro del HTML** (`/about-us`), y ese
HTML se pinta con `tiptap_converter()->asHTML(...)` en **21 sitios**, ninguno pasando por
`<x-redirect-link>`. Si el select devuelve `slug:42`, quedaría `href="/slug:42"` muerto. Opciones:

- **(a)** en el action, resolver `slug:N` → path actual **al guardar** (el HTML sigue con texto;
  esos enlaces se rompen al renombrar, igual que hoy — son 2 en toda la DB). Coste: casi nulo.
- **(b)** resolver al renderizar, envolviendo el converter en un helper (`rich_text()`) que
  reemplace `href="/slug:N"`. Coste: tocar los 21 llamados o interceptar el converter.

Recomendación: **(a)** ahora, y (b) solo si aparecen muchos enlaces internos en texto rico.

### 10.7 `slug_history.old_path` UNIQUE: `updateOrCreate`, no `firstOrCreate`

El diseño proponía `firstOrCreate` para no petar al renombrar A→B→A→B. Se implementó
`updateOrCreate`: tampoco peta y además cubre el caso de que **otro** contenido abandone después
ese mismo path (con `firstOrCreate` el 301 seguiría apuntando al dueño antiguo). El "si un slug
vuelve a usarse, borrar su fila del historial" quedó automático dentro de `SlugCatalogue::sync()`.

### 10.8 Los slugs son globales, no por división (pero `path` no puede ser UNIQUE)

Las páginas se comparten entre divisiones vía `division_page` (la página `about-us` está en las 8),
y blogs/máquinas/productos se resuelven con binding global, sin filtrar por división. Hoy **no hay
ni un slug duplicado** en ninguna de las 6 tablas. Aun así `slugs.path` se dejó **sin UNIQUE**,
porque `PageController::show()` filtra por división y nada impide dos páginas con el mismo slug en
divisiones distintas; `SlugCatalogue::findByPath()` prefiere la fila activa.

### 10.9 El problema ya está pasando: 103 enlaces roto en la DB local

`links:audit` sobre `itg_edit_web_temporal` (389 enlaces guardados en total):

```
  103   broken       (nada sirve el path)
  136   ok
  133   external     (http, mailto, tel)
   17   placeholder  (vacío o solo ancla)
```

Los roto, por valor:

```
  15  page/about                  8  page/new-machinery        7  page/main-home
   8  products/general            8  page/contacts             7  page/about#history
   8  page/used-machines-for-sale 7  page/partnets             7  page/about#people
   6  /products/sector/itg-line   6  /page/contact             7  page/about#organization
   3  page/contact#contactform    2  page/home                 1  page/partners
   1  machinery/used-macihne-for-sale (typo)  1  page/dfd      1  page/about-us
```

Dos familias: el prefijo **`page/`** de un esquema de URLs anterior (hoy solo existe bajo
`/sandbox/page/{slug}`, con auth) y **typos** (`partnets`, `used-macihne-for-sale`). ⚠️ **La DB
local es de pruebas, no es prod** (`CLAUDE.md → Base de datos / entorno`): el número real hay que
sacarlo corriendo `links:audit` en producción. Pero confirma que el 404 silencioso no es teórico.

---

## 11. Lo que quedó implementado (archivos)

**Paso 1 — `publicPath()`**

- `app/Contracts/PubliclyRoutable.php` — el contrato de los 6 modelos.
- `app/Traits/HasPublicPath.php` — implementación por defecto: cada modelo solo sobreescribe lo que
  difiere (`publicPathPattern()`, `slugColumn()`, `publishedColumn()`, `slugLabel()`). Añade también
  `slugRow()` (morphOne al catálogo) y `syncSlugRow()`.
- `app/Support/Slugs/Sluggables.php` — la lista de las 6 clases + `matchPath()` / `resolvePath()`.
  **`Page` va última a propósito**: su patrón es `{slug}` y matchearía cualquier segmento, igual que
  el catch-all tiene que ir último en `routes/web.php`.

**Paso 0 — auditoría**

- `app/Support/Slugs/LinkStores.php` — lee los 6 almacenes buscando `redirect` / `href` a cualquier
  profundidad (ver §10.3).
- `app/Support/Slugs/FoundLink.php` — DTO con tabla, id, ruta JSON, valor, tipo y contexto.
- `app/Support/Slugs/PathChecker.php` — reproduce lo que hace una petición real: primero el router
  (es quien decide la precedencia), y solo si la ruta tiene parámetro pregunta si el contenido existe.
- `app/Console/Commands/AuditLinks.php` — `links:audit [--all] [--unpublished] [--strict]`.
  **Solo lee.** Salida en inglés (herramienta de operaciones, como `notifications:prune`).

**Paso 2 — catálogo**

- `database/migrations/2026_08_20_000000_create_slugs_table.php` — idempotente (`hasTable`).
- `app/Models/Slug.php`, `app/Support/Slugs/SlugCatalogue.php` (`sync` / `forget` / `for` /
  `findByPath` / `pruneOrphans`), `app/Observers/SluggableObserver.php` (created / updated /
  deleted / restored, un observer para los 6; los fallos se loguean, **nunca** rompen el guardado).
- Registro del observer en `AppServiceProvider` recorriendo `Sluggables::classes()`.
- `app/Console/Commands/SyncSlugCatalogue.php` — `slugs:sync [--model=] [--no-prune] [--dry-run]`.
  Poblado inicial: **73 filas**, exactamente las que predecía el diseño. Segunda corrida: 73
  `unchanged`, 0 escrituras (idempotente).

**Paso 4 — historial + 301**

- `database/migrations/2026_08_20_000001_create_slug_history_table.php` — idempotente.
- `app/Models/SlugHistory.php` — `remember()` (updateOrCreate, §10.7), `forgetPath()`, `recordHit()`.
- `app/Support/Slugs/SlugRedirector.php` — solo GET y no-JSON; ignora la fila si el contenido está
  inactivo o sigue en el mismo path (evita el bucle 301→301); conserva el query string; si la
  consulta falla, loguea y deja pasar el 404.
- Enganches: `Route::fallback()` en `routes/web.php` + `render(NotFoundHttpException)` en
  `bootstrap/app.php` (§10.2).
- `config/slugs.php` — retención (12 meses), chunk y `redirects_enabled` (kill switch).
- `app/Console/Commands/PruneSlugHistory.php` — `slugs:prune-history`, agendado a las **03:20** en
  `routes/console.php` (detrás de `notifications:prune` de las 03:10). Borra solo lo que es **viejo
  Y sin uso**: un 301 con tráfico no se borra por antiguo.

### Cómo se probó (sin dejar rastro en la DB)

- Observer (created / rename / unpublish / delete) dentro de una **transacción con rollback**:
  el catálogo quedó en las mismas 73 filas.
- 301 end-to-end con `curl` renombrando de verdad y **restaurando** los slugs después
  (`about-us`, `machinery/water-looms`), incluyendo:
  - rename de página → `/about-us` responde **301** a `/about-us-tmp301`, con `?utm_source=test`
    conservado;
  - rename de máquina (404 de binding, otro enganche) → **301** correcto;
  - **dos renames seguidos** → la URL original salta al path actual **en un solo hop** (no cadena);
  - **reutilizar el slug viejo** → la fila del historial se borra sola y la URL vuelve a servir 200.
- `links:audit` antes y después: mismos 103 roto (el paso 4 no cambia lo que ya estaba roto sin
  historial; los arregla de aquí en adelante).
- Humo sobre `/`, `/about-us`, `/blogs`, `/machinery`, `/machinery/water-looms`, `/products/itg-line`,
  `/machinery/category/weaving`, `/blogs/textile-industry`, `/admin` → todos como antes.

### Comandos nuevos

```bash
php artisan links:audit                 # qué enlaces internos no llevan a ninguna parte
php artisan links:audit --all           # también los que sí resuelven
php artisan slugs:sync                  # poblar / reparar el catálogo (idempotente)
php artisan slugs:sync --dry-run
php artisan slugs:prune-history         # limpiar 301 viejos y sin uso (agendado 03:20)
```

### En producción

`deploy/deploy.sh` corre **`slugs:sync` justo después de `migrate`**, con `log_warning` en vez de
`log_error`: si falla, el deploy sigue (sin catálogo los enlaces resuelven como texto, igual que
antes) y se reejecuta a mano. Es idempotente, así que sirve igual para el primer deploy —crea todas
las filas— que para los siguientes, donde repara lo que se haya desincronizado si alguien tocó la DB
por fuera del panel.

**No hay import, seed ni fixture de datos:** el comando recorre las 6 tablas de contenido con
`chunkById(200)` y le pregunta el path a cada modelo (`publicPath()`). No hay ids, conteos ni slugs
escritos en el código, así que el estado de la DB local es irrelevante: si prod tiene 900 blogs crea
900 filas; los registros sin slug se cuentan como `skipped`; las filas de contenido borrado las
limpia `pruneOrphans`. Preparado para volumen de prod: las traducciones se cargan con eager loading
(una query por chunk, no por registro) y el prune de huérfanos hace una query por cada 500 filas.

⚠️ Correr `links:audit` una vez en prod, para saber cuántos enlaces están roto de verdad.

---

## 12. Paso 3 — el select guarda la referencia y el render la resuelve

### Formato del valor guardado (el punto 6, ya confirmado)

```
"slug:42"                → referencia al catálogo → se resuelve al path ACTUAL
"slug:42#people"         → idem, conservando el ancla
"login"                  → path literal, tal cual (rutas custom + los ~108 redirects viejos)
"https://ejemplo.com"    → externa, tal cual
"mailto:a@b.com"         → tal cual
```

`App\Support\Slugs\SlugReference` es el único que sabe de esto:

- `toPath()` resuelve una referencia al path actual; **cualquier otra cosa pasa intacta**, así que
  nada de lo que ya está guardado cambia de comportamiento. Devuelve `null` solo si la referencia
  apunta a contenido borrado → el render pinta un enlace inerte, nunca uno equivocado.
- `fromLiteral()` hace el camino inverso (`about-us` → `slug:70`, conservando `#ancla`) y es lo que
  permite que los enlaces viejos se migren solos al guardar.
- El mapa `id → path` se carga **una vez por request** (el catálogo es una tabla chica): un navbar
  con 40 enlaces cuesta **una** query, no 40. `flush()` para tests y procesos largos.
- Helper Blade `slug_path($valor)` en `app/Helpers/GlobalHelper.php`.

### Dónde se resuelve al pintar

Eran **tres** puntos, no uno (el diseño solo contaba el componente):

1. `resources/views/components/redirect-link.blade.php` — los 12+ usos normales.
2. `resources/views/frontend/layouts/navbar.blade.php` — el enlace **padre** de los dropdowns
   armaba el href a mano; ahora usa el componente. Ojo: ahí `$item->type` vale `list` (es lo que lo
   hace dropdown), así que se le pasa `type="internal"` explícito — que es justo lo que asumía el
   `url($item->redirect)` anterior.
3. `resources/views/frontend/content-section/icon-info-cards.blade.php` — arma el `<a>` partido en
   dos `@if`, no se puede envolver con el componente; usa `slug_path()` directo. Si la referencia
   está rota, la card se pinta **sin** enlace.

### El select (`InternalSlugSelect`)

- Lee **`slugs`**, no las 6 tablas de contenido: **1 query** para buscar (antes 6) y ordenada por
  `is_active` primero.
- Guarda `slug:{id}`. Etiqueta = badge de tipo + `label` del catálogo + path
  (`[Page] About us — about-us`), con el path completo en el `title` para el hover.
- Sugerencias al abrir: home + hasta 5 por tipo, 20 en total, **solo contenido publicado**, con
  páginas primero.
- Buscando **sí** aparece lo despublicado, marcado `· Unpublished`: un editor arma el menú antes de
  publicar, pero tiene que verlo. (El diseño decía "no ofrecer despublicado"; esto respeta el motivo
  —que no se cuele sin avisar— sin bloquear el caso legítimo.)
- `getOptionLabelUsing` entiende **las tres** formas: referencia, `/` (home) y path literal viejo
  (mantiene las heurísticas por prefijo que ya tenía). Una referencia colgada se muestra como
  `[Missing content] slug:42` en vez de un valor crudo sin explicación.
- `createOptionUsing` (ruta a mano) también pasa por `fromLiteral()`: si escribes `about-us`, se
  guarda la referencia, no el texto.
- Textos nuevos en `__()` + los 6 JSON (12 keys: `Internal page`, `Custom path`, `Unpublished`,
  `Missing content`, `Page`, `Blog category`, `Machinery category`, …).

### TipTap (§10.6, opción (a))

`CustomTiptapLinkAction` resuelve la referencia **al guardar**: en el `href` del HTML queda el path
real, no `slug:42` (el texto rico se pinta con `tiptap_converter()->asHTML()` en ~21 sitios, ninguno
por el componente). Al reabrir el modal, `parseHref()` convierte el path a referencia para que el
select preseleccione la opción correcta. Si después se renombra el slug, **el 301 del paso 4 cubre
el enlace** — un salto, sin 404.

### `links:audit` entiende las referencias

`PathChecker` resuelve `slug:N` antes de comprobar, así que una referencia sana sale `ok` y solo se
reporta `broken` cuando el contenido ya no existe. Sin esto, cada enlace migrado habría aparecido
como roto.

### Cómo se probó el paso 3

- `fromLiteral` / `toPath` con los 4 formatos + anclas + referencia colgada + `/`.
- `InternalSlugSelect::make()` construido de verdad: 20 opciones por defecto, 4 resultados para
  "weav", labels y `searchPrompt` traducidos.
- **La promesa central, con `Blade::render` dentro de una transacción con rollback:** valor guardado
  `slug:70` → href `/about-us`; se renombra la página a `nosotros`; **el valor guardado no cambia**
  y el href pasa a `/nosotros`. Con ancla: `slug:70#people` → `/nosotros#people`.
- Navbar por HTTP: se convirtió un item de menú a referencia, se comprobó que el `<a>` padre del
  dropdown sale `http://localhost/about-us` (y los items de texto plano, idénticos a antes), y se
  **restauró el valor original**.
- Humo del sitio y `links:audit` sin cambios. DB igual que al empezar (73 filas, 0 historial).

### Lo único que quedó fuera a propósito

El select genera ahora el path desde el catálogo, así que el bug de `machinery/{slug}/detail`
(§10.4) **no puede repetirse en enlaces nuevos**. Los que ya están guardados con `/detail` siguen
roto en la DB: se arreglan editándolos (o con `links:audit` como lista de tareas).

---

## 13. `slugs` como fuente única (columnas de contenido eliminadas)

Decisión tomada el 2026-08-20 sobre §9: el slug **deja de estar duplicado**. Las columnas
`pages.slug`, `blogs.slug`, `machines.slug`, `product_sectors.slug`, `blog_categories.name` y
`machine_categories.name` se borran; el valor vive **solo** en `slugs`. Lo que se gana:

- `UNIQUE(path)` **aplicable a toda la web** por primera vez: antes nada impedía que una página y un
  producto pelearan por la misma URL, porque la unicidad era por tabla.
- Resolver una petición es **un lookup indexado** para los 6 tipos, en vez de 6 bindings distintos.
- `slug_history` **podría** apuntar a `slugs.id` con FK real en vez de la pareja polimórfica
  (`sluggable_type`+`sluggable_id`) que ya usa hoy. **Evaluado y descartado el 2026-09-18**: no hay
  ganancia real de performance (ambas formas están indexadas, y la tabla es chica — el orden de los
  ~108 redirects vistos en este doc), y se pierde legibilidad — hoy una fila dice sola de qué modelo
  viene (`"App\Models\Page"`), con un FK habría que hacer join para saberlo. Confirmado además que
  `slugs.id` es estable (`SlugCatalogue::remember()` usa `firstOrNew` por `sluggable_type`+
  `sluggable_id`, nunca borra y recrea), así que un FK tampoco arreglaría nada que hoy sea frágil.
  Se queda como está.
- Imposible que las dos copias divergan, porque hay una sola.

### El slug sigue siendo `$model->slug` (atributo virtual)

Nada del código que lee o escribe el slug cambió de forma. `HasPublicPath` + los traits
`SlugColumnIsVirtual` / `NameColumnIsVirtual` exponen un **accessor + mutator** normal de Eloquent:

- **Leer** (`$machine->slug`, `route('blog.category', $cat)`, `attributesToArray()`) sale del
  catálogo, cacheado **una vez por request** (`SlugReference::forOwner`), así que un menú de 40
  enlaces cuesta 1 query, no 40.
- **Escribir** (`$page->slug = 'x'`, `Page::create([...])`, el form de Filament) queda en cola y lo
  persiste el observer.

Dos trampas que hubo que resolver, y por qué el código está así:

1. **`getAttribute`/`setAttribute` NO se pueden sobreescribir en el trait:** spatie
   `HasTranslations` ya declara `setAttribute()` y dos traits con el mismo método en una clase es un
   *fatal error*. Por eso se usa la API de mutators (`getSlugAttribute`), que sí compone: el
   `setAttribute` de spatie cae en el de Laravel, y ese llama al mutator.
2. **El observer escucha `saved`, no `updated`:** si lo único que cambia es el slug, el modelo no
   queda *dirty* y Eloquent **no dispara `updated`** — el rename se perdería. `saved` sí dispara
   siempre.

`attributesToArray()` añade el slug a mano porque Filament llena sus formularios de edición desde
ahí: sin eso el campo abriría vacío y guardar lo borraría.

### Migración `2026_08_20_000002_move_slugs_to_catalogue`

El orden es el punto entero, y **aborta antes de tocar el esquema** si los datos no dan:

1. sincroniza cada registro al catálogo — leyendo la columna **cruda** (`getRawOriginal`), porque el
   accessor ya responde desde el catálogo, que es justo lo que está vacío;
2. verifica que **ningún** registro con slug quedó sin fila → si no, `RuntimeException` y no se borra
   nada (la migración queda sin aplicar y se reintenta tras arreglar los datos);
3. verifica que **no hay dos contenidos con el mismo path** (si los hay, los lista);
4. borra las columnas (y sus índices únicos);
5. añade `UNIQUE(path)`.

`down()` reconstruye las columnas desde el catálogo: el rollback no pierde datos.

**Probado en dos copias de la DB** (`mysqldump` → base nueva), no en la de trabajo:

- copia **sin catálogo** (el escenario de un dump de producción): 73 filas creadas, 6 columnas
  borradas, `slugs_path_unique` creado;
- copia con **un path duplicado a propósito**: abortó con el mensaje `/about-us (2 contents)`,
  **las columnas siguieron intactas** y la migración quedó sin registrar.

Después se aplicó a la DB local y se verificó: leer/renombrar/crear/borrar contenido, binding de
rutas (`/machinery/water-looms/detail` solo resuelve por catálogo), `route()` con binding field,
buscar y **ordenar** por slug en las tablas de Filament, la regla de unicidad, `links:audit`,
`slugs:sync`, `sitemap:generate` y humo de las 15 URLs públicas.

### Consecuencias operativas (importantes)

- **`slugs:sync` ya no puede reconstruir el catálogo**: sin columna no hay de dónde. Pasa a ser
  mantenimiento (refresca `label`, `is_active` y `path` cuando cambia un patrón de URL) y sigue en el
  deploy con ese rol.
- **Al mover tablas por SQL entre entornos, `slugs` viaja con el contenido.** Importar `machines` sin
  importar `slugs` deja esas máquinas **sin URL**.
- Crear contenido con `INSERT` crudo ya no le da URL: hay que insertar también en `slugs` (o crearlo
  por el panel / Eloquent, que lo hace solo).
- Filament: campo y columna centralizados en `App\Filament\Components\SlugInput` y `SlugColumn`
  (búsqueda con `whereHas`, orden con subconsulta correlacionada). La unicidad la valida
  `App\Rules\UniquePublicPath` **por path y global**, y por eso es más laxa que antes en un caso: una
  máquina y una categoría **sí** pueden llamarse `weaving`, porque sus paths difieren
  (`machinery/weaving/detail` vs `machinery/weaving/category`). Se borró `App\Rules\UniqueMachinerySlug`,
  que quedó sin uso.
- **Los 5 seeders de contenido antiguos** (`MachineSeeder`, `BlogSeeder`, `ProductSectorSeeder`,
  `BlogCategorySeeder`, `MachineCategorySeeder`) hacen `DB::table()->insert()` crudo y **ya estaban
  roto en esta rama antes de este cambio** (escriben `machines.name` y `machines.division_id`, que las
  migraciones del editor renombraron y movieron). Se dejaron como estaban.

---

## 14. El nombre del contenido no se guarda en el catálogo (columna `label` eliminada)

`slugs.label` era una **copia** de algo que el modelo ya sabe, y además **congelada al guardar**: con
el idioma que hubiera activo en ese momento, por eso las máquinas quedaron etiquetadas en español.
Se borró (migración `2026_08_20_000003`) y el desplegable lo pregunta al contenido:

- `PubliclyRoutable::slugLabel()` sigue existiendo, pero ya **no lo escribe nadie**: lo lee el select.
  Página y sector → su `title`; blog, máquina y las dos categorías → el `name` de su tabla de
  traducciones.
- **Sigue el idioma:** `translatedName()` usa `resolveByLanguage()` (locale actual → idioma por
  defecto → primera fila), que filtra la relación **ya cargada**, así que no cuesta una query extra.
- **Sin N+1:** `InternalSlugSelect` carga los dueños con `with(['sluggable' => morphWith(...)])`,
  usando `slugLabelRelations()` de cada modelo. Un desplegable completo (20 opciones de 6 tipos) son
  ~13 queries.
- **La búsqueda por nombre humano se mantuvo**, que era lo único que se perdía al borrar la columna:
  el `LIKE` sobre `label` se reemplazó por `orWhereHasMorph` — `title` para páginas y sectores,
  `translations.name` para el resto. Escribir "telares" sigue encontrando `machinery/air-looms`.
- `slugs:sync` y su `plannedOutcome` ya no comparan ni escriben label; la migración `000002` tampoco
  lo necesita.

### La página principal de la división no se ofrece en el desplegable

`HomeController` sirve la página marcada `is_main` en `division_page`, así que ofrecerla otra vez
como `home-main` listaba **el mismo destino dos veces**, y la URL canónica del home es `/`. El select
la oculta (`withoutMainPages()`, ids cacheados por request) y deja solo la opción `[Home]`. Se filtra
**solo el desplegable**: un valor ya guardado que apunte a esa página sigue resolviendo y conserva su
etiqueta.

### La ruta escrita a mano ahora se valida (y el slug del contenido también)

El select ofrece el catálogo, pero también deja **escribir una ruta a mano** para lo que no es
contenido catalogado (`login`, `machinery`, un ancla como `about#history`). Esa puerta no validaba
**nada**, y de ahí salen los 404 silenciosos: `page/about` se guardaba feliz.

| Se escribe | Resultado |
|---|---|
| `login`, `machinery`, `sitemap` | ✅ una ruta estática lo sirve |
| `about#history` | ✅ contenido del catálogo → se guarda como `slug:70#history` |
| `contact-us` (slug viejo) | ✅ el historial responde 301 |
| `page/about`, `machinery/custom/detail` | ⚠️ *"Nada sirve /page/about"* → se puede **confirmar** y guardar igual |
| `https://otra.com/x` | ❌ *"Es un enlace externo, usa el tipo External URL"* |
| `https://midominio.com/about` | ✅ se normaliza a `about` (quita el dominio propio y el `/` inicial) |

Piezas: `App\Rules\ValidInternalPath` (usa `PathChecker`, que ya sabía distinguir ruta estática /
contenido / nada) y `SlugReference::normalizeInternal()`.

**No bloquea, avisa.** Enlazar a algo que todavía no existe es un caso legítimo (la página se va a
crear después), así que cuando el path no lleva a ninguna parte aparece un checkbox —*"esa ruta
todavía no existe, agregarla de todos modos"*, con el aviso de que dará 404 hasta entonces— y con eso
se guarda. El checkbox **lo revela el propio rechazo**: se intenta *Create*, la validación lo refusa
con *"Nada sirve /…, revisa la ruta, elige una página de la lista o marca la casilla de abajo"*, y en
ese re-render aparece la casilla. Una URL **externa** se rechaza igual aunque se confirme: ahí el
problema no es que falte el destino, es que ese es el campo equivocado. Lo que se guarde así sale en
`links:audit` como roto, para que no se olvide.

⚠️ **NO poner `live()` en `custom_path` ni en `confirm_missing`.** Se intentó con
`->live(onBlur: true)` para mostrar el checkbox de forma proactiva, y dejó el modal **inservible: había
que clicar *Create* dos o tres veces**. El motivo no es la validación, es la combinación de dos cosas:
el blur se dispara en el **mousedown**, y el botón submit del modal de Filament trae
`wire:loading.attr="disabled"` **sin `wire:target`**, así que *cualquier* petición en vuelo lo
deshabilita. Medido en el navegador: tras el blur el botón quedaba `disabled` **de 838 ms a 1834 ms**,
y el mouseup caía dentro de esa ventana sobre un botón deshabilitado → el navegador **no emite `click`**
y el submit nunca ocurría. Sin `live()` el blur causa **0 peticiones** y el primer clic siempre entra.
Vale para cualquier campo que viva en el mismo modal que un submit: si necesita round trip, no puede
ser `onBlur`.

**El modal "+" abre con el valor actual.** Antes abría en blanco aunque el campo ya tuviera una ruta
escrita a mano, así que corregir una letra obligaba a teclearlo todo de nuevo
(`createOptionAction(...)->fillForm(...)`, leyendo el estado del componente al montar). Una
referencia del catálogo se precarga como **su path actual**, que además es la forma de ponerle un
ancla a una página de la lista (`about` → `about#history`): al guardar vuelve a convertirse en
referencia, ancla incluida. Va envuelto en try/catch: si no puede leer el estado abre en blanco, que
es el comportamiento anterior, en vez de romper el modal. ⚠️ No se puede probar desde consola —un
`Select` de Filament necesita un form container vivo—, se verifica clicando en el panel.

El ancla y el query string se respetan siempre y se ignoran al validar: `machinery/water-looms/detail#specs`
se guarda como `slug:31#specs`, y `…?utm=x` como `slug:31?utm=x`. Lo que tiene que existir es el
contenido, no el ancla.

**La etiqueta dejó de mentir.** Antes adivinaba el tipo por la forma del path: `login` salía como
`[Page] login` y `machinery/custom-thing` como `[Machinery] custom-thing`, y ninguno de los dos es
contenido. Ahora se le pregunta al catálogo: si el path **es** contenido lleva su badge real (y se
convierte en referencia al siguiente guardado), y si no, es honestamente `[Custom path]`.

**Y el slug del contenido tampoco puede chocar con una ruta estática.** Las rutas estáticas se
declaran antes del catch-all, así que ganan ellas: una página llamada `about` **no se abre nunca**,
el `AboutUsController` responde esa URL. `UniquePublicPath` ahora lo rechaza
(`PathChecker::staticRouteFor`).

⚠️ **Efecto en los datos actuales:** las páginas que ya se llaman `about`, `contact` y `partners`
están en ese caso, así que **el panel no las deja guardar hasta renombrarlas**. Es a propósito —hoy
son páginas inalcanzables—, pero si estorba, la regla se puede limitar a validar solo cuando el slug
cambia.

**De paso, `Str::slug()` se movió a `SlugInput`** (estaba repetido en los 6 resources) porque la
validación tiene que comprobar **exactamente lo que se va a guardar**: validar `"About Us"` mientras
se guardaba `about-us` dejaba pasar una colisión.

---

## 15. Auditoría post-migración: qué seguía consultando la columna

Revisado todo el módulo activo (controladores, vistas en uso, resources, comandos, helpers) buscando
consultas por la columna que ya no existe. Lo que había:

1. **`Sluggables::resolvePath()`** hacía `$class::query()->where($class::slugColumn(), …)` — es decir
   consultaba `blogs`/`machines` por su slug y **reventaba** con *Unknown column 'slug'*. Ahora
   resuelve por el catálogo: `SlugCatalogue::findByPath($path)?->sluggable`.
2. **Los `Select` de categoría** de `BlogResource` y `MachineResource`
   (`relationship('blogCategory', 'name')` + `pluck('name','id')`) consultaban `name` por SQL: las
   pantallas de editar/crear blog y máquina daban **500**. El nombre se resuelve ahora en PHP con
   `slugLabel()` (y así respeta el idioma).

Lo que **no** hacía falta tocar, y por qué:

- **Las vistas activas** (`blogs/index`, `blogs/show`, `machinery/index`, `machinery/show`,
  `products/show`, `frontend/page` y sus partials) no consultan nada: leen `->translation->name` de
  las tablas de traducciones y construyen URLs con `route('blog.show', $blog)`, que usa el atributo
  virtual. (Hay blades antiguas fuera de uso que quedaron como referencia: no se tocan.)
- **El guardado de los forms** ya iba por el mutator: `$model->slug = 'x'` / `create(['slug' => 'x'])`
  encolan el valor y el observer lo persiste en `saved`. Verificado end-to-end dentro de una
  transacción con rollback: crear blog, `resolvePath()`, `route()`, renombrar (con su 301), crear
  categoría de maquinaria (columna `name`) y crear sector.
- **Los comandos** (`slugs:sync`, `links:audit`, `slugs:prune-history`, `sitemap:generate`) usan el
  catálogo o `publicPath()`.

### Bug ajeno encontrado de paso

El preview del panel (`/sandbox/machinery/{slug}`) daba **500** por
`load('machineDetails.translations')`: esos modelos traducen con spatie (columnas JSON), donde
`translations` es un **accessor**, no una relación. Nada que ver con los slugs —venía de antes— pero
estaba roto en este módulo, así que se quitó el `.translations` de los dos eager loads.

### Humo tras los arreglos

Público: `/blogs`, `/blogs/{categoría}`, `/blogs/{slug}/detail`, `/machinery`,
`/machinery/{cat}/category`, `/machinery/{slug}/detail`, `/products/{slug}` → **200**.
Panel: listados y crear/editar de las 6 entidades → **200**. Preview: page, blog, machinery y
products → **200**.

ℹ️ `/about-us` da 404 **y está bien**: en producción esa página es `about`, y `/about` la sirve la
**ruta estática** `about.index` (el `AboutUsController`), no el builder.

Por eso el validador de choque con rutas estáticas **solo salta cuando el slug cambia**: bloquear el
guardado de las páginas que ya viven en `about`, `contact` o `partners` impediría editar el resto de
la página. Lo que evita es **crear** una página nueva inalcanzable o renombrar una a un path que ya
sirve el sitio. Comprobado: guardar `about` tal cual pasa; una página nueva con `about` se rechaza;
renombrar a `blogs` se rechaza citando `blog.index`.

---

## 16. El catálogo pasa a enrutar: `ContentController` (2026-08-28)

El último sitio donde la forma de una URL seguía escrita a mano era `routes/web.php`. Ahí estaba
declarada **por segunda vez** — la primera es `publicPathPattern()` en el modelo — y nada obligaba a
que ambas dijeran lo mismo:

```php
routes/web.php   Route::get('/machinery/{machine:slug}/detail', …)
Machine.php      publicPathPattern() => 'machinery/{slug}/detail'
```

Los **tres destrozos históricos** de la §1 (`/page/{slug}`, `machinery/category/{x}`,
`products/sector/{x}`) fueron exactamente esa deriva: alguien cambió la ruta y el modelo —y por lo
tanto `slugs.path`, el sitemap y los enlaces guardados— siguió creyendo en la forma vieja. La
advertencia "correr `links:audit --strict` después de tocar `routes/web.php`" era la cicatriz de esa
duplicación, no una buena práctica.

### 16.1 Cómo queda

Una sola dirección de dependencia:

```
publicPathPattern()  (el modelo declara la forma)
        ↓  slugs:sync / el observer
slugs.path           (la proyección: la URL pública real, UNIQUE)
        ↓  ContentController
la petición
```

`routes/web.php` termina con:

```php
Route::get('/{path}', ContentController::class)
    ->where('path', '^(?!admin$|admin/|livewire$|livewire/).*$')
    ->name('content.show');
```

y `ContentController::__invoke()` hace las tres preguntas en el mismo orden que ya documentaba
`SlugRedirector`:

1. `SlugCatalogue::findByPath($path)?->sluggable` → despacha al controller del tipo.
2. `SlugRedirector::redirectFor($request)` → **301** si el path está en `slug_history`.
3. `errors.404`.

El `match (true)` del despacho (por `instanceof`, no por `::class`, para no depender del morph map):

| modelo            | destino                            |
|-------------------|------------------------------------|
| `Page`            | `PageController@show($path)`       |
| `Blog`            | `BlogController@show($req, $blog)` |
| `BlogCategory`    | `BlogController@index($req, $cat)` |
| `Machine`         | `MachineryController@show($m)`     |
| `MachineCategory` | `MachineryController@index($cat)`  |
| `ProductSector`   | `ProductSectorController@show($s)` |

⚠️ **Un séptimo modelo enrutable hay que agregarlo en DOS sitios**: `Sluggables::classes()` (para el
catálogo, el sitemap y la auditoría) **y** este `match` (para que su path se sirva). Si falta el
segundo, la fila existe en `slugs` y la URL da 404.

### 16.2 Qué se borró de `routes/web.php` y qué se quedó

**Borradas (6 formas de contenido):** `machinery/{machine:slug}/detail`,
`machinery/{category:name}/category`, `blogs/{blog:slug}/detail`, `blogs/{blogCategory:name}`,
`products/{productSector:slug}` y el viejo catch-all `page.show`.

**Borradas también, y esto merece explicación: `/about`, `/contact` y `/partners`.** Eran pages del
builder con una ruta estática encima cuyo único propósito era que `route('contact.index')` siguiera
resolviendo en ~37 llamadas. **No protegían nada**: `->defaults('path', 'contact')` alimentaba a
`PageController::show` con el literal `contact`, así que al renombrar el slug de esa page la ruta
buscaba un path que ya no existía y el enlace 404eaba igual — con el agravante de que, al ser ruta
estática, `PathChecker::staticRouteFor()` las reportaba como colisión y `UniquePublicPath` tenía un
caso especial ("solo cuando el slug cambia") para no bloquear la edición de esas tres pages. Hoy
`staticRouteFor('about')` devuelve `null` y la unicidad la decide `UNIQUE(path)`, que es donde debe
decidirse.

**Se quedan como rutas estáticas** (regla: *si no está en `slugs`, es ruta; si está, lo resuelve el
catálogo*):

- los **listados**: `/machinery`, `/blogs`, `/products` — no son contenido catalogado, son vistas;
- `used-machines-for-sale/{id}/detail` — va por **id**, `sale_used_machines` no tiene fila en `slugs`;
- el alias 301 `products/{slug}/sector`, que ahora construye el destino con
  `ProductSector::publicPathFor($slug)` en vez de `route('products.show', …)`, así que sigue la forma
  del modelo si vuelve a cambiar;
- el sandbox (`sandbox/*`, con route-model binding propio) y toda la aplicación (dashboard, tickets,
  auth, Filament).

### 16.3 Generación de URLs: `publicUrl()` y `page_url()`

Sin rutas nombradas de contenido, `route('blog.show', $blog)` deja de existir. Los reemplazos:

- **con un registro cargado** → `$blog->publicUrl()`, `$category->publicUrl()` (`HasPublicPath`, que
  es lo que el sitemap ya usaba);
- **con un slug literal** → `url(ProductSector::publicPathFor('weaving'))` — el caso del navbar en
  `GeneralUtil`, que arma los enlaces de sector sin cargar el modelo;
- **una page conocida** → **`page_url('contact')`** (helper nuevo en `app/Helpers/GlobalHelper.php`),
  que resuelve **1)** el catálogo, **2)** `slug_history` (`targetFor(..., recordHit: false)`: armar un
  enlace no es visitar la URL vieja), **3)** el literal como último recurso, para que el `href` nunca
  quede vacío en una instalación recién sembrada. El fragmento se puede pasar aparte
  (`page_url('contact', '#contactform')`) o dejarlo fuera de la interpolación, como estaba.

Se barrieron **~60 llamadas a `route()` en 31 archivos** (incluidas las blades legacy que ya no se
sirven, para que ningún nombre de ruta borrado quede referenciado). Las URLs renderizadas son
**idénticas** a las de antes: se verificó comparando los `href` de `/machinery`, `/blogs` y el detalle
de una máquina.

### 16.4 `PathChecker`

Cuando la ruta que matcheó es el resolver (`content.show`), el veredicto ya **no** se deduce con
`Sluggables::matchPath()`: se pregunta al catálogo por el **path completo** (`cataloguePaths()`, una
sola consulta por corrida, cacheada en la instancia). Es más simple y, sobre todo, no puede
discrepar de lo que sirve el resolver, porque leen la misma columna.

La rama vieja (`matchPath()` + `slugsFor()`) sigue viva para las rutas con parámetro que **no** son
del catálogo: el detalle de usadas, el alias `products/{slug}/sector` y el sandbox. Esas devuelven
`unchecked`, igual que antes.

### 16.5 Lo que NO cambió (a propósito)

- **Los guards de visibilidad y de división siguen en cada controller.** `BlogController::show()` y
  `ProductSectorController::show()` filtran; `MachineryController::show()` **no** filtra por división
  ni por `status` — deuda **preexistente**, no introducida aquí: con route-model binding tampoco lo
  hacía. Se dejó igual para que el cambio sea de enrutamiento puro y no altere qué se ve.
- **El gancho de `NotFoundHttpException` en `bootstrap/app.php` se conserva.** Ya no hay 404 de
  route-model binding, pero `PageController::show()` sigue usando `firstOrFail()` (filtro por
  división), así que el gancho sigue siendo la red que contesta el historial en ese caso.
- **`config/slugs.php → canonical_static_paths`** sigue leyéndose en `PageController::show`. Está
  vacío hoy; el mecanismo queda para la próxima page que tenga que vivir detrás de una ruta con
  controlador propio.

### 16.6 Verificación (2026-08-28)

- `links:audit --strict` → 589 enlaces, **0 rotos** (370 ok, 209 externos, 10 placeholder).
- 18 URLs por HTTP: **200** en `/`, `/about`, `/contact`, `/partners`, `/machinery`, `/blogs`,
  `/products`, `/used-machines-for-sale`, `/login`, `machinery/{slug}/detail`,
  `machinery/{name}/category`, `blogs/{slug}/detail`, `blogs/{name}`, `products/{slug}`,
  `machinery/new-machinery`; **301** en `/new-machinery` → `/machinery/new-machinery` y
  `/products/weaving/sector` → `/products/weaving`; **404** en un blog no publicado y en un path
  inexistente.
- `sitemap:generate` → las 8 divisiones, sin cambios de forma.

---

## 17. Preview de sandbox roto para los homes (2026-09-17)

**El síntoma:** el botón "preview" del Editor Web funcionaba para páginas normales (about/partners/
contact/machinery) pero daba **404** para las páginas marcadas como home de una división
(`home-mexico`, `home-argentina`, etc). Solo `home-main` (la de la división `main`) previsualizaba bien.

**La causa — no era la ruta ni el botón.** `PageResource` genera el mismo link (`route('sandbox.page.show',
$record->slug)`) para cualquier `Page`, sin distinguir home de página normal; la ruta `sandbox.page.show`
también es una sola. El problema estaba en `PageController::preview()`:

```php
$page = $division->pages()
    ->whereHas('slugRow', fn ($query) => $query->where('path', trim((string) $path, '/')))
    ->firstOrFail();
```

`$division = currentDivision()` se resuelve por el **subdominio del host actual**
(`SetLanguageAndDivisionMiddleware` → `SubdomainUtil`). Una página normal está atada a las **8**
divisiones (`division_page`), así que cualquier host sirve. Un home, por diseño, está atado a **una
sola**: `home-mexico` solo cuelga de `mexico`. El admin entra al panel de Filament por el dominio raíz
(no por el subdominio de cada división), así que `currentDivision()` cae en `main` — y `main->pages()`
nunca incluye `home-mexico`. `firstOrFail()` tira 404. `home-main` funcionaba de pura casualidad: es el
único home cuya división coincide con el fallback.

Un segundo efecto, más silencioso: aunque se arreglara el 404, `HasGroupedSections::visibleInCurrentDivision()`
(que decide qué bloques mostrar según "solo para estas divisiones", ver §12) **también** lee
`currentDivision()` — así que un home encontrado a la fuerza igual habría mostrado los bloques de `main`
en vez de los suyos.

**El fix**, en `PageController::preview()`: si la búsqueda escopada por `currentDivision()` no encuentra
nada, se reintenta **sin escopar por división** (`Page::whereHas('slugRow', ...)->firstOrFail()`) y, si
la página aparece, se rebindea `currentDivision()` a la propia división de esa página
(`$page->divisions()->first()`) antes de armar los bloques — así `visibleInCurrentDivision()` filtra
correcto también. `PageController::show()` (producción) **no se tocó**: ahí `currentDivision()` siempre
coincide porque el visitante entra por el subdominio real, y el filtro por división es el que impide que
una página de otra división se sirva bajo el subdominio equivocado — no hace falta (ni conviene) el mismo
fallback ahí.

Se dejaron igual las rutas de sandbox de blog/machinery/product-sector: ese contenido no tiene el
concepto de "vive en una sola división", así que no le aplica este bug.

**Verificado:** con la DB local, simulando `currentDivision() = main` (el caso real del admin), la
consulta escopada no encontraba `home-mexico` (confirmado también reproduciendo el 404 real por HTTP
antes del fix); tras el fix, `/sandbox/page/home-mexico`, `/sandbox/page/home-argentina` y
`/sandbox/page/home-main` devuelven 200, igual que `/sandbox/page/about` (sin regresión).
- `route:list`: del contenido solo queda `{path} → content.show`.
