Temas Twig, widgets e internacionalización
Temas Twig, widgets e internacionalización
En el capítulo 13 montaste un módulo con su Service.php, su controlador y sus plantillas en templates/admin/. En el capítulo 14 lo conectaste con el exterior por la API JSON y por hooks. Falta la capa que el cliente ve realmente: el HTML.
El problema es concreto. Quieres cambiar el pie del área de cliente, meter un banner en el dashboard o poner el panel en español, y la tentación inmediata es abrir src/themes/huraga/html/layout_default.html.twig y editarlo. Eso funciona hasta la siguiente actualización, que te lo pisa. Y si decides copiar el tema entero, te encuentras con que la carpeta html_custom/ de la que habla la documentación no existe en la instalación, con que strict_variables está activado y una variable ausente devuelve un 500 en blanco, y con que la plantilla de email que acabas de retocar deja de enviarse sin decir por qué.
Este capítulo recorre el mecanismo completo verificado contra FOSSBilling 0.8.5: cómo TwigLoader decide qué fichero renderiza y en qué orden, los cinco entornos Twig y sus globals, el sandbox de las plantillas de email, el sistema de widgets introducido en 0.8.0 con sus slots, y la internacionalización con gettext sin la extensión gettext, incluyendo cuánto del panel está realmente traducido al español. Al terminar tendrás un tema propio compilando con esbuild, sobrescrituras que sobreviven a las actualizaciones del core, un widget tuyo pintándose en un slot, y es_ES instalado con expectativas realistas sobre su cobertura.
Los dos temas incluidos: admin_default sobre Tabler y huraga sobre Bootstrap 5
ls src/themes/ devuelve exactamente tres entradas: admin_default/, huraga/ y un index.html vacío. No hay más temas en el core y el catálogo de terceros es escaso, así que en la práctica todo tema propio nace de copiar uno de estos dos.
| Tema | Área | Framework CSS | Ficheros en html/ | Nota de su manifiesto |
|---|---|---|---|---|
admin_default | administración | Tabler.io sobre Bootstrap 5 | 26 | ”Copy this theme and create your own admin area theme” |
huraga | cliente | Bootstrap 5 directo | 12 más partials/ | ”Default client area theme” |
La diferencia no es solo estética. admin_default trae layouts que el área de cliente no necesita (layout_blank.html.twig, layout_login.html.twig, mod_index_dashboard.html.twig, la familia partial_admin_*.html.twig, partial_search.html.twig, partial_batch_delete.html.twig) y huraga trae los suyos (layout_default.html.twig, layout_public.html.twig, partial_pricing.html.twig, partial_pending_messages.html.twig, mobile_menu.html.twig). Si tu objetivo es maquillar el área de cliente, copias huraga; si vas a rehacer el panel de administración, copias admin_default. No los mezcles: son árboles de plantillas incompatibles porque las plantillas de módulo que carga cada área son distintas.
La regla del prefijo admin_ y cómo se detecta el área de un tema
FOSSBilling no guarda en ningún sitio “este tema es de administración”: lo deduce del nombre de la carpeta. En src/modules/Theme/Model/Theme.php:
public function isAdminAreaTheme(): bool
{
return str_contains($this->name, 'admin_');
}
Eso es todo. Un tema de administración DEBE llevar admin_ en el nombre de su carpeta y uno de cliente no debe llevarlo nunca. La comprobación es str_contains, no str_starts_with, así que mi_admin_tema también cuenta como tema de administración.
Gotcha: si copias admin_default a admin_default_v2 todo va bien, pero si lo copias a default_v2 porque te pareció más limpio, el tema desaparece del selector de administración sin ningún mensaje de error.
Estructura real de un tema: html/, config/, assets/ y esbuild.mts
Árbol real de src/themes/huraga/ en 0.8.5:
src/themes/huraga/
assets/
css/ js/ scss/
huraga.ts
build/
config/
settings.html.twig
settings_data.json.example
custom-icons/
html/
layout_default.html.twig
layout_public.html.twig
error.html.twig
macro_functions.html.twig
mobile_menu.html.twig
partial_menu.html.twig
partial_pagination.html.twig
partial_pricing.html.twig
partial_company_logo.html.twig
partial_message.html.twig
partial_pending_messages.html.twig
partials/
esbuild.mts
icon-manifest.json
manifest.json
package.json
screenshot.jpg
Uno. assets/ guarda las fuentes (scss/, css/, js/) y el entrypoint TypeScript del tema, huraga.ts. assets/build/ es la salida de esbuild y no está versionada en admin_default, así que un clon limpio del repositorio no tiene assets compilados hasta que ejecutas el build. Dos. config/settings.html.twig es el formulario de ajustes del tema y config/settings_data.json.example son los presets por defecto; el fichero real, settings_data.json, hay que crearlo copiando el .example. Tres. html/ son las plantillas Twig del tema, lo que el cargador mira en segundo lugar.
Cuatro. esbuild.mts es el script de compilación, y aquí hay una discrepancia que cuesta tiempo: la documentación oficial habla de esbuild.mjs, pero los ficheros reales en 0.8.5 y en main son .mts, tanto frontend/esbuild.mts como src/themes/*/esbuild.mts. Lo mismo pasa con frontend/core/api.js, que en el repositorio es api.ts. Si un tutorial te manda ejecutar node ./esbuild.mjs, obtendrás un error de fichero no encontrado.
Cinco. html_custom/ no aparece en el árbol y no es un olvido del listado: no existe en ninguno de los dos temas incluidos. Hay que crearla a mano, y el cargador solo la añade a las rutas de búsqueda si existe en disco.
TwigLoader y el orden de prioridad: html_custom, html y los módulos
src/library/FOSSBilling/Twig/TwigLoader.php extiende Twig\Loader\FilesystemLoader y fija el orden de búsqueda en su constructor:
$paths = [];
$customPath = Path::join($themePath, 'html_custom');
if ($this->filesystem->exists($customPath)) { $paths[] = $customPath; }
$defaultPath = Path::join($themePath, 'html');
if ($this->filesystem->exists($defaultPath)) { $paths[] = $defaultPath; }
$paths = array_merge($paths, $this->getModuleTemplatePaths($appArea));
$this->setPaths($paths);
// namespace "symbol" para el sprite SVG
$symbolPath = Path::join($themePath, 'assets', 'build', 'symbol');
if ($this->filesystem->exists($symbolPath)) { $this->prependPath($symbolPath, 'symbol'); }
El orden de prioridad, de más a menos, es: 1) themes/<tema>/html_custom/, 2) themes/<tema>/html/, 3) modules/*/templates/<admin|client>/. Los directorios de módulo se descubren con Symfony\Component\Finder a depth('== 2'), quedándose con los llamados admin o client cuyo directorio padre se llame literalmente templates:
if ($this->filesystem->exists($moduleTemplatePath) && basename($parentDir) === 'templates') {
$paths[] = $moduleTemplatePath;
}
Esa línea explica por qué los módulos escritos para 0.7.x dejaron de funcionar: en 0.7 las plantillas vivían en html_admin/, html_client/ y html_email/, y en 0.8 el cargador solo mira dentro de templates/. En el tag 0.8.5, grep -rn "html_admin" src/ --include='*.php' no devuelve ni un resultado. El example-module oficial sigue publicado con la estructura vieja, así que no copies su layout de plantillas.
flowchart TD
REQ["render de plantilla X"] --> C1{"existe en themes tema html_custom"}
C1 -- si --> R1["renderiza el override del tema"]
C1 -- no --> C2{"existe en themes tema html"}
C2 -- si --> R2["renderiza la plantilla del tema"]
C2 -- no --> C3{"existe en algun modulo templates area"}
C3 -- si --> R3["renderiza la plantilla del modulo"]
C3 -- no --> C4{"el nombre casa con mod Modulo fichero svg"}
C4 -- si --> R4["sirve PATH_MODS Modulo fichero svg"]
C4 -- no --> ERR["Twig LoaderError"]
La última rama es una particularidad útil: findTemplate() está sobrescrito para resolver iconos de módulo por convención, de forma que un nombre con la forma mod_<Modulo>_<fichero>.svg se traduce a PATH_MODS/<Modulo>/<fichero>.svg. Por eso desde una plantilla puedes escribir mod_News_icon.svg y te sirve src/modules/News/icon.svg sin que exista ninguna copia en el tema.
Sobrescribir la plantilla de un módulo sin tocar el core
La consecuencia directa del orden de prioridad es la funcionalidad más útil del sistema de temas: para reemplazar la vista de cualquier módulo basta con crear un fichero con el mismo nombre en html_custom/ del tema activo. No hay registro, ni configuración, ni hook; el cargador encuentra el tuyo antes y para. La ficha de una noticia en el área de cliente la sirve src/modules/News/templates/client/mod_news_post.html.twig, así que para cambiarla:
cd /var/www/fossbilling
mkdir -p themes/huraga/html_custom
cp modules/News/templates/client/mod_news_post.html.twig \
themes/huraga/html_custom/mod_news_post.html.twig
# editar themes/huraga/html_custom/mod_news_post.html.twig
rm -rf data/cache/*
A partir de esa copia, el módulo News puede actualizarse y tu versión sigue en pie. Ese es el punto fuerte y también el riesgo: tu copia queda congelada. Si una actualización añade un bloque a la plantilla original o renombra una variable, tu override no lo recibe y, con strict_variables activado, puede empezar a lanzar errores. Anota qué has sobrescrito y revísalo en cada actualización menor. La misma técnica sirve para plantillas del propio tema: copias html/layout_default.html.twig a html_custom/layout_default.html.twig y editas ahí.
Gotcha: el nombre de fichero es la clave y es global, no hay espacio de nombres por módulo. Si dos módulos tuvieran una plantilla llamada igual, gana el primero que aparezca en el orden de Finder. Por eso el core prefija todas las plantillas de módulo con mod_<modulo>_, y tu módulo propio debe hacer lo mismo.
manifest.json del tema: icon, no icon_url, y markdown_attributes
El manifiesto de un tema no tiene el mismo esquema que el de un módulo. Este es el de huraga:
{
"name": "FOSSBilling Huraga",
"version": "1.0.0",
"description": "Default client area theme",
"icon": "screenshot.jpg",
"author": "FOSSBilling",
"author_url": "https://www.fossbilling.org/",
"markdown_attributes": {
"\\League\\CommonMark\\Extension\\Table\\Table": { "class": "table table-hover" },
"\\League\\CommonMark\\Extension\\CommonMark\\Node\\Inline\\Image": { "class": "img-fluid" },
"\\League\\CommonMark\\Extension\\CommonMark\\Node\\Block\\BlockQuote": { "class": "blockquote" }
}
}
Tres diferencias respecto al manifiesto de un módulo del capítulo 13. Uno. La clave de la imagen es icon, no icon_url: un tema que declare icon_url sale sin captura en el selector. Dos. No lleva id ni type; en los módulos esas dos claves se fuerzan siempre desde el código, en los temas directamente no se leen.
Tres. markdown_attributes es específico de los temas y es el gancho más útil del manifiesto: mapea clases de nodo de League CommonMark a atributos HTML que se inyectan en la salida del filtro |markdown_to_html. Es la forma de conseguir que las tablas de un artículo de la base de conocimiento salgan con class="table table-hover" de Bootstrap sin tocar el core ni las plantillas de los módulos. La lista exhaustiva de nodos aceptados no está documentada: los tres del ejemplo están verificados y el resto tendrías que comprobarlos contra las clases de nodo de la versión de league/commonmark que traiga tu instalación, en src/vendor/league/commonmark/src/Extension/.
Ajustes del tema: settings.html.twig, el saneado y los assets subibles
config/settings.html.twig es el formulario que se pinta en Settings → Themes → (tema) → Settings. En huraga ocupa 22,9 KB: de ahí salen los colores, el texto del showcase, los enlaces del pie y los interruptores de las secciones. Antes de renderizarlo, Theme\Service::getSettingsPageHtml() lo sanea: elimina los bloques <script> y <style> completos, quita todos los atributos style="..." de cualquier elemento y cierra los <textarea/> sueltos.
No es una restricción menor. Significa que no puedes meter JavaScript ni CSS en línea en la página de ajustes de tu tema: si necesitas comportamiento, tiene que venir de un asset compilado que cargue el panel. Si tu formulario “no hace nada” al pulsar un botón, lo primero que hay que mirar es si el <script> que lo movía fue eliminado por el saneador.
Los <input type="file" name="..."> del formulario se detectan por XPath y definen los assets subibles del tema: los ficheros que suba el administrador se guardan en themes/<tema>/assets/ y se listan con getUploadedAssets(). Para eso, assets/ tiene que ser escribible por el usuario del servidor web, y isAssetsPathWritable() es la comprobación que hace el core antes de aceptar la subida.
# el usuario del proceso PHP-FPM debe poder escribir en assets/
chown -R www-data:www-data /var/www/fossbilling/themes/mitema/assets
chmod -R u+w /var/www/fossbilling/themes/mitema/assets
Advertencia de seguridad: ese directorio es escribible y está bajo la raíz web. La configuración de nginx y .htaccess del capítulo 2 bloquea /themes/*/config/ precisamente porque ahí viven los .twig y el JSON de ajustes; el endurecimiento completo de estas rutas está en el capítulo 16.
settings_data.json, presets y dónde viven de verdad los valores
Aquí es donde más gente se pierde, porque el fichero JSON que editas deja de mandar en cuanto la instalación arranca una vez. config/settings_data.json contiene los presets; el repositorio trae settings_data.json.example y el fichero real hay que crearlo copiándolo. Estructura real de huraga, recortada:
{
"presets": {
"Default": {
"show_page_header": "1", "show_company_logo": "1",
"sidebar_balance_enabled": "1", "top_menu_dashboard": "1",
"login_page_show_logo": "1", "show_password_reset_link": "1",
"show_signup_link": "1", "require_login": "0",
"showcase_enabled": "1", "showcase_text": "## Welcome to FOSSBilling!",
"side_menu_dashboard": "1", "meta_title": "", "meta_description": "",
"theme": "light", "footer_enabled": "1",
"footer_link_1_title": "Terms of Service", "footer_link_1_page": "",
"inject_javascript": "", "checkout_tos": "0", "signup_tos": "0"
}
},
"current": "Default"
}
flowchart TD
EX["config settings_data.json.example"] --> CP["copia manual a settings_data.json"]
CP --> FIRST{"hay filas en extension_meta para este tema"}
FIRST -- no --> IMP["getPresetsFromSettingsDataFile mas updateSettings"]
IMP --> DB["extension_meta rel_type settings rel_id tema"]
FIRST -- si --> DB
DB --> CUR["extension_meta rel_type preset rel_id current meta_key tema"]
CUR --> TWIG["global settings en el area de cliente"]
TWIG --> SAVE["guardar desde el panel"]
SAVE --> EV["evento onBeforeThemeSettingsSave"]
EV --> DB
En la primera lectura, si no hay filas para ese tema, se importan los presets del fichero con getPresetsFromSettingsDataFile() y updateSettings(). A partir de ahí las filas exactas en la tabla extension_meta son estas:
| Qué guarda | extension | rel_type | rel_id | meta_key | meta_value |
|---|---|---|---|---|---|
| Valores de un preset | mod_theme | settings | nombre del tema | nombre del preset | JSON con los ajustes |
| Preset activo | mod_theme | preset | current | nombre del tema | nombre del preset |
SELECT rel_type, rel_id, meta_key, LEFT(meta_value, 60) AS valor
FROM extension_meta
WHERE extension = 'mod_theme'
ORDER BY rel_type, rel_id;
Gotcha número uno: tras esa primera lectura, editar settings_data.json no cambia nada, porque los valores viven en base de datos. Para reimportar los presets del fichero tienes que borrar las filas correspondientes de extension_meta o gestionarlo desde el panel.
Gotcha número dos: los valores se guardan como cadenas "1" y "0", no como booleanos, así que en Twig hay que comparar explícitamente. Un {% if settings.footer_enabled %} a secas evalúa "0" como cadena no vacía y por tanto como verdadero: la sección que querías ocultar sigue apareciendo y no hay ningún error que te avise.
{% if settings.footer_enabled == '1' %}
{% include 'partials/footer.html.twig' %}
{% endif %}
Al guardar los ajustes se dispara el evento onBeforeThemeSettingsSave, que puedes enganchar desde un módulo con la mecánica de hooks del capítulo 14 si necesitas validar o normalizar valores. Los métodos públicos de Theme\Service que te sirven desde código son getTheme($name), getCurrentThemePreset(Theme), setCurrentThemePreset(Theme, $preset), deletePreset(Theme, $preset), getThemePresets(Theme), getThemeSettings(Theme, $preset = null), updateSettings(Theme, $preset, array $params), getCurrentClientAreaTheme(), getCurrentClientAreaThemeCode(), getCurrentAdminAreaTheme() y clearThemeCache().
Los cinco entornos Twig y las globals que expone cada uno
La documentación habla de tres entornos Twig. src/library/FOSSBilling/Twig/TwigFactory.php construye cinco, y la diferencia importa porque no todos usan el mismo loader ni tienen acceso a las mismas variables.
| Factoría | Loader | Sandbox | Globals extra |
|---|---|---|---|
createAdminEnvironment(StandardDebugBar) | TwigLoader(AppArea::ADMIN, themes/<tema admin>) | no | theme, current_theme, app_area='admin', admin, client |
createClientEnvironment(StandardDebugBar) | TwigLoader(AppArea::CLIENT, themes/<tema cliente>) | no | current_theme, settings, app_area='client', client, admin |
createEmailEnvironment(?string $timezone) | ArrayLoader | sí, EmailPolicy | guest restringido, default_currency, FOSSBillingVersion |
createAdapterEnvironment() | ArrayLoader | sí, AdapterPolicy | guest.system_company |
createThemeSettingsEnvironment() | ArrayLoader | sí, AdapterPolicy | ninguna |
graph LR
ADMIN["createAdminEnvironment"] --> LA["TwigLoader area admin sin sandbox"]
ADMIN --> GA["globals theme y current_theme y admin y client"]
CLIENT["createClientEnvironment"] --> LC["TwigLoader area cliente sin sandbox"]
CLIENT --> GC["globals settings y current_theme y client y admin"]
EMAIL["createEmailEnvironment"] --> LE["ArrayLoader"]
LE --> SE["sandbox EmailPolicy"]
ADAPTER["createAdapterEnvironment"] --> LAD["ArrayLoader"]
LAD --> SAD["sandbox AdapterPolicy"]
THEMESET["createThemeSettingsEnvironment"] --> LTS["ArrayLoader"]
LTS --> STS["sandbox AdapterPolicy"]
Los tres entornos con ArrayLoader no leen ficheros: reciben la plantilla como cadena. Por eso una plantilla de email no puede hacer {% include %} de un fichero del tema, y por eso el formulario de ajustes de un tema tampoco puede incluir parciales. Las globals comunes las define configureGlobals():
| Global | Contenido |
|---|---|
CSRFToken | token CSRF de la sesión, generado con bin2hex(random_bytes(32)) |
flashes | mensajes flash de sesión |
request | RequestDataView con la query sin _url, más ajax: true si es XHR |
request_query | array crudo de la query |
request_path | ruta normalizada con Box_Url::normalizeLinkPath |
request_has_filters | booleano: hay parámetros distintos de page y search |
default_currency | código de la moneda por defecto |
guest | proxy de la API guest |
FOSSBillingVersion | versión de la instalación |
redirect_uri | solo si hay uno en sesión |
app_area | 'admin' o 'client' |
current_theme | código del tema activo |
settings | ajustes del tema, solo en el área de cliente |
theme | array del tema, solo en el área de administración |
Las dos últimas filas son la fuente de la mitad de los errores al portar plantillas: settings no existe en administración y theme no existe en cliente, así que una plantilla compartida que use cualquiera de las dos revienta en el área equivocada. El número de decimales de los importes sale de Symfony\Component\Intl\Currencies::getFractionDigits() para la moneda por defecto, con 2 como respaldo, y se aplica con setNumberFormat($decimalDigits, '.', ''); la zona horaria se resuelve en cascada cliente, administrador, cookie, i18n.timezone y UTC.
strict_variables = true: la causa número uno de la página en blanco
La configuración de Twig vive en config.php, y ya la viste en el capítulo 3:
'twig' => [
'debug' => false,
'auto_reload' => true,
'cache' => __DIR__ . '/data/cache',
'strict_variables' => true,
],
strict_variables está en true por defecto. Acceder a una variable que no existe no devuelve una cadena vacía: lanza Twig\Error\RuntimeError con el mensaje Variable "x" does not exist. Es la causa más frecuente de página en blanco o 500 al portar un tema. Desde 0.8.x eso además se nota: los errores de Twig en get_custom_page (LoaderError, RuntimeError, SyntaxError) producen un 500 en vez del antiguo 404 y se registran en el canal routing de Monolog, así que una página de cliente que antes “no existía” ahora te grita y el detalle está en los logs.
{# mal: revienta si el ajuste no está definido en el preset #}
{{ settings.footer_link_1_title }}
{# bien: valor por defecto #}
{{ settings.footer_link_1_title|default('Términos') }}
{# bien: comprobación explícita antes de usar un bloque entero #}
{% if settings.showcase_enabled is defined and settings.showcase_enabled == '1' %}
{{ settings.showcase_text|markdown_to_html }}
{% endif %}
Para depurar de verdad, activa el DebugBar temporalmente con debug_and_monitoring.debug = true: te muestra la traza completa, las consultas de RedBeanPHP y de Doctrine por separado y los tiempos de cada fase. Desactívalo antes de volver a producción, porque expone la configuración de la instancia aunque enmascare el salt y las credenciales de base de datos.
El sandbox de las plantillas de email: qué está permitido y qué bloquea el envío
Las plantillas de email que gestionaste en el capítulo 9 se renderizan en un entorno con sandbox, con la política FOSSBilling\Twig\EmailPolicy, activa desde 0.8.0. Lo permitido es exactamente esto:
| Categoría | Permitido |
|---|---|
| Tags | if, for, block, apply |
| Funciones | country_names |
| Globals | guest limitado a system_company y system_email, default_currency, FOSSBillingVersion |
Y los filtros, que conviene tener a mano porque cualquier otro rompe el envío: escape (alias e), default, title, length, date, format_currency, format_date, format_datetime, format_number, format_time, currency_name, currency_symbol, country_name, url, daysleft, trans, period_title y markdown_to_html.
Lo que no está permitido y suele aparecer en las plantillas que la gente copia de internet: {% set %} no existe en el sandbox, porque no está en la lista de tags; api_admin y api_client no están disponibles; llamar a cualquier método de guest distinto de las dos propiedades permitidas falla, de modo que guest.system_company funciona pero guest.currency_get_pairs() no; y {% include %} o {% extends %} tampoco, porque el loader es un ArrayLoader sin ficheros.
Advertencia: una plantilla de email que use algo prohibido falla al renderizar y bloquea el envío de ese correo. No se manda una versión degradada: no se manda nada. Desde 0.8.2 las plantillas se validan sintácticamente al guardarlas y el panel marca las rotas con un badge, además de ofrecer restaurar la plantilla original de fichero. Si un cliente te dice que no recibió el correo de activación y el resto de correos sí llegan, mira ese badge antes de tocar el SMTP. Sustituir un {% set %} suele ser trivial: se repite la expresión donde haga falta, o se calcula en el módulo que dispara el email y se pasa como parámetro.
Filtros y funciones propias: asset_url, api_url, has_permission, avatar y wysiwyg
FOSSBilling registra sus extensiones Twig con atributos de PHP 8 (#[AsTwigFilter], #[AsTwigFunction]). Conviene tener el catálogo delante porque muchos nombres cambiaron en 0.8.0 y las plantillas viejas usan filtros que ya no existen. Todo lo que sigue lo registra FOSSBilling\Twig\Extension\FOSSBillingExtension:
| Nombre | Tipo | Firma y notas |
|---|---|---|
render_widgets | función | render_widgets(string $slot, array $context = []) |
svg_sprite | función | inyecta assets/build/symbol/icons-sprite.svg del tema activo |
has_permission | función | has_permission(string $module, ?string $permission = null): bool |
antispam_honeypot | función | devuelve {enabled, field} para pintar el campo trampa |
avatar | función | avatar(?string $email, int $size = 40, string $classes = 'avatar'), con DiceBear |
wysiwyg | función | wysiwyg('.selector'), carga CKEditor 5 desde public/assets/editor |
asset_url | filtro | asset del tema actual |
public_asset_url | filtro | asset compartido de src/public/assets |
daysleft | filtro | días restantes hasta una fecha |
file_size | filtro | bytes a formato legible |
format_currency | filtro | format_currency(mixed $amount, string $currency, array $attrs = []) |
hash | filtro | hash(mixed $value, string $algo = 'xxh128') |
script_tag | filtro | <script> con cache-busting y sin duplicados |
stylesheet_tag | filtro | <link rel="stylesheet"> con cache-busting |
timeago | filtro | tiempo relativo |
trans | filtro | traducción gettext |
truncate | filtro | recorta una cadena |
url | filtro | url(area) con 'admin' o 'client' |
Las otras tres extensiones son ApiExtension, con el filtro api_url y las funciones fb_api, fb_api_form y fb_api_link; LegacyExtension, con los filtros ip_country_name, ip_country_code, mod_asset_url y period_title; y DebugBarExtension, con debug_bar_render_head(), que antes se llamaba DebugBar_renderHead(). Además se cargan las extensiones estándar DebugExtension, MarkdownExtension (que aporta |markdown_to_html), StringLoaderExtension e IntlExtension (format_date, format_datetime, format_number, country_name, currency_name, currency_symbol).
{# ocultar un bloque si el staff no tiene el permiso #}
{% if has_permission('client', 'manage_client') %}
<a href="{{ 'client/manage'|url('admin') }}">{{ 'Gestionar clientes'|trans }}</a>
{% endif %}
{# formulario que llama a la API con CSRF automático #}
<form action="{{ 'profile/update'|api_url }}" {{ fb_api_form({ message: 'Guardado'|trans }) }}>
<input type="text" name="first_name">
<button type="submit">{{ 'Guardar'|trans }}</button>
</form>
fb_api_form() añade method="post", serializa el formulario, adjunta el token CSRF, deshabilita los botones mientras la petición está en vuelo y muestra los errores con FOSSBilling.message(). Los detalles del wrapper y de los modales están en el capítulo 14.
Widgets: WidgetProviderInterface, slots y prioridades
Los widgets llegaron en 0.8.0 y son el mecanismo oficial para que un módulo inyecte HTML en un punto concreto de una plantilla sin sobrescribirla. Es lo que quieres cuando tu extensión debe aparecer en el dashboard del cliente y no puedes asumir qué tema hay instalado. El contrato es una interfaz de un solo método:
namespace FOSSBilling\Interfaces;
interface WidgetProviderInterface
{
/** @return array<int, array{slot: string, template: string, priority?: int}> */
public function getWidgets(): array;
}
El Service.php de tu módulo la implementa y devuelve la lista de widgets:
namespace Box\Mod\Miextension;
class Service implements \FOSSBilling\InjectionAwareInterface, \FOSSBilling\Interfaces\WidgetProviderInterface
{
public function getWidgets(): array
{
return [
['slot' => 'client.theme.body.start', 'template' => 'mod_miextension_banner', 'priority' => 10],
['slot' => 'client.index.dashboard.client.before', 'template' => 'mod_miextension_saldo', 'priority' => 5],
];
}
}
Las plantillas van en la ruta que el renderizador espera, que es 'widgets/' . $widget['template'] . '.html.twig' dentro del directorio de plantillas del área, y el punto de inyección en el tema se declara con {{ render_widgets('client.theme.body.start') }}:
src/modules/Miextension/
manifest.json
Service.php
templates/
client/
widgets/
mod_miextension_banner.html.twig
mod_miextension_saldo.html.twig
Uno. Los widgets de un mismo slot se ordenan por priority ascendente, con 10 por defecto: prioridad más baja significa que se pinta antes. Dos. El registro se cachea en Widgets\Service::getRegistry() sin expiración y solo se invalida al activar o desactivar módulos, así que si añades getWidgets() a un módulo que ya estaba activo, el widget no aparece hasta que lo desactivas y reactivas o limpias la caché.
Tres. Si un widget lanza una excepción al renderizarse no tumba la página: se pinta en su lugar widgets/mod_widgets_error.html.twig, que vive en src/modules/Widgets/templates/admin/widgets/, y el mensaje de error concreto solo se muestra en entorno de desarrollo. Cuatro. El área importa: un widget en un slot client.* necesita su plantilla en templates/client/widgets/ y uno en un slot admin.* en templates/admin/widgets/; el renderizador no busca en la otra área.
flowchart TD
ACT["activar o desactivar un modulo"] --> INV["invalida la cache del registro"]
INV --> SCAN["recorre los Service que implementan WidgetProviderInterface"]
SCAN --> REG["registro con slot y template y priority"]
REG --> CACHE["cache sin expiracion"]
TPL["render_widgets slot en la plantilla"] --> CACHE
CACHE --> SORT["ordena por priority ascendente"]
SORT --> RENDER["renderiza widgets template html twig"]
RENDER --> OK["HTML inyectado"]
RENDER --> FAIL["excepcion al renderizar"]
FAIL --> ERRTPL["mod_widgets_error html twig"]
Los slots existentes en el área de administración y en la de cliente
La lista se obtiene del propio árbol, sin fiarse de la documentación, con grep -rho "render_widgets('[^']*'" src/themes src/modules | sort -u. En 0.8.5, con solo los módulos y temas del core, salen estos nombres:
| Zona | Slots |
|---|---|
| Login de administración | admin.staff.login.form.before, admin.staff.login.form.after |
| Layout de administración | admin.theme.content.before, admin.theme.content.after, admin.theme.footer.start, admin.theme.footer.end |
| Layout de cliente | client.theme.body.start, client.theme.body.end, client.theme.header.start, client.theme.header.end, client.theme.content.before, client.theme.content.after, client.theme.footer.start, client.theme.footer.end |
| Dashboard de cliente | client.index.dashboard.client.before, client.index.dashboard.client.after, client.index.dashboard.content.before, client.index.dashboard.content.after, client.index.dashboard.guest.before, client.index.dashboard.guest.after |
| Gestión de pedido | client.order.manage.content.start, client.order.manage.actions.end, client.order.manage.details.after, client.order.manage.service.before, client.order.manage.service.after |
| Login de cliente | client.page.login.form.before, client.page.login.form.after |
Son 27 nombres distintos en esa versión, pero la cifra depende de la versión y de los módulos instalados: cualquier extensión de terceros puede declarar los suyos y un tema propio puede añadir los que quiera llamando a render_widgets con un nombre nuevo, así que el grep de arriba es la única respuesta fiable para tu instalación. Los más usados en la práctica son client.theme.body.start para scripts o banners globales, client.index.dashboard.client.before para tarjetas en el panel del cliente autenticado, y client.order.manage.details.after para añadir información del servicio en la ficha del pedido, que encaja con lo que viste en el capítulo 7.
Crear tu propio tema: copiar, registrar el workspace npm y compilar
No existe un mecanismo formal de tema hijo con herencia de directorios. Hay dos estrategias y la elección depende del alcance del cambio.
A) Solo html_custom/ | B) Copiar el tema | |
|---|---|---|
| Esfuerzo inicial | mínimo | copia, manifiesto, workspace npm, build |
| Sobrevive a actualizaciones del core | sí | sí |
| Sobrevive a cambios de la plantilla original | no automáticamente | no aplica |
| Puedes cambiar CSS y JS compilado | no cómodamente | sí |
| Convive con el tema original activado | no, es el mismo tema | sí |
| Recomendado para | retoques y overrides puntuales | un tema real de marca |
La estrategia A es la de la sección de overrides: creas src/themes/huraga/html_custom/ y metes ahí los .html.twig que quieras cambiar, y todo lo demás sigue viniendo de html/ y de los módulos. La estrategia B, paso a paso:
cd src/themes
cp -r huraga mitema
cd mitema
cp config/settings_data.json.example config/settings_data.json
mkdir -p html_custom
Después hay que tocar dos ficheros. En manifest.json cambias name, description y author, y compruebas que icon apunta a una captura que exista. En el package.json del tema cambias la clave "name" a "mitema", porque es el identificador del workspace npm; el de huraga declara "scripts": { "dev": "node ./esbuild.mts", "build": "node ./esbuild.mts" }, "engines": { "node": ">=24" } y como dependencias bootstrap ^5.3.3, flag-icons ^7.2.3, intl-tel-input ^29.1.0, tom-select ^2.3.1, más @tabler/icons 3.46.0 como devDependency.
El paso que casi todo el mundo olvida: registrar el tema como workspace en el package.json de la raíz del repositorio. Sin eso, npm run build -w mitema no encuentra el workspace.
"workspaces": [
"src/themes/huraga",
"src/themes/admin_default",
"src/themes/mitema"
]
npm install
npm run build -w mitema
# alternativa equivalente:
cd src/themes/mitema && npm run build
Los scripts de build de la raíz, por si necesitas recompilar solo una parte: build encadena check, build-core y build-themes; build:production es lo mismo con NODE_ENV=production; build-core ejecuta node ./frontend/esbuild.mts; build-themes compila admin_default y huraga; y check agrupa check-types (tsc --noEmit) con check-icons (node ./frontend/tools/check-icons.mts). Durante el desarrollo, npm run dev dentro de la carpeta del tema arranca el modo watch. Requisitos de la cadena de build: Node >= 24 y npm >= 11, declarados en engines.
Para activarlo: Settings → Themes en el panel, seleccionar mitema y activar. También existe un comando de listado en src/modules/Theme/Commands/Listing.php; el nombre exacto que expone depende del atributo #[AsCommand] de esa clase, así que compruébalo con php src/console.php list.
Cargar el JS de la API y los iconos SVG en un layout propio
Un tema copiado hereda las cabeceras correctas; uno escrito desde cero no, y el síntoma es que todos los formularios dejan de funcionar sin error visible en el HTML. Estas son las líneas mínimas que debe cargar cualquier layout:
{{ 'js/fossbilling.js'|public_asset_url|script_tag }}
{{ 'js/api.js'|public_asset_url|script_tag }}
<script src="{{ 'build/js/mitema.js'|asset_url }}"></script>
api.js se compila desde frontend/core/api.ts a src/public/assets/js/api.js y expone window.FOSSBilling.api, con el alias API. La firma del wrapper es API.{admin|client|guest}.{get|post|put|delete|patch}(endpoint, params, onSuccess, onError, showSpinner), y el timeout por defecto es de 30000 ms, definido en makeRequest(..., timeoutMs = 30000, ...).
Gotcha: si tu tema no reutiliza el JavaScript de los temas incluidos, los enlaces y formularios con data-fb-api no se enganchan solos y hay que inicializar los bindings a mano. Sin esas dos llamadas, fb_api_form() y fb_api_link() generan los atributos pero nadie los escucha: el formulario hace un POST normal del navegador y acabas en una página de API en crudo.
document.addEventListener('DOMContentLoaded', function () {
API._apiForm();
API._apiLink();
});
Para los iconos, el sprite SVG lo genera el build en themes/<tema>/assets/build/symbol/icons-sprite.svg y TwigLoader lo registra con el espacio de nombres symbol mediante prependPath($symbolPath, 'symbol'). Se inyecta con la función svg_sprite y se referencia así:
{{ svg_sprite() }}
<svg class="icon"><use href="#tabler-user"></use></svg>
Desde 0.8.3 se referencia con href, no con xlink:href. Si copias fragmentos de un tema de 0.7.x los iconos no aparecen y no hay error de consola que lo explique con claridad. Si el sprite no existe en absoluto, es que falta el npm run build del tema.
i18n: gettext sin la extensión gettext, __trans y __pluralTrans
FOSSBilling usa el formato GNU gettext (.pot, .po, .mo) pero no requiere la extensión ext-gettext de PHP: resuelve la lectura de los .mo con phpmyadmin/motranslator ^6.0.0, una implementación en PHP puro. No hay que compilar ni habilitar nada en el servidor. El arranque está en src/library/Box/Translate.php:
public function setup(): void
{
PhpMyAdmin\MoTranslator\Loader::loadFunctions();
$locale = $this->getLocale();
if (empty($locale)) {
throw new Exception('Unable to set up FOSSBilling translation functionality, locale was undefined.');
}
Locale::setDefault($locale);
$codeset = 'UTF-8';
if (!defined('LC_MESSAGES')) { define('LC_MESSAGES', 5); }
if (!defined('LC_TIME')) { define('LC_TIME', 2); }
_setlocale(LC_MESSAGES, $locale . '.' . $codeset);
_setlocale(LC_TIME, $locale . '.' . $codeset);
_bindtextdomain($this->domain, PATH_LANGS); // PATH_LANGS = src/locale
_bind_textdomain_codeset($this->domain, $codeset);
_textdomain($this->domain);
}
El dominio es único y se llama messages. Las dos funciones de traducción están definidas en el mismo fichero:
function __trans(string $msgid, ?array $values = null): string
{
$translated = _gettext($msgid);
if (is_array($values)) { $translated = strtr($translated, $values); }
return $translated;
}
function __pluralTrans(string $msgid, string $msgidPlural, int $number, ?array $values = null): string
{
$translated = _ngettext($msgid, $msgidPlural, $number);
if (is_array($values)) { $translated = strtr($translated, $values); }
return $translated;
}
La sustitución es strtr(), no sprintf(). No es un detalle de implementación: cambia la sintaxis de los placeholders, que por convención del proyecto llevan dos puntos delante. Un %s en el msgid no lo sustituye nadie y sale literal.
__trans('Delete something');
__trans('Hello :name, you have :count messages', [':name' => $nombre, ':count' => $numero]);
__pluralTrans('One invoice', ':count invoices', $numero, [':count' => $numero]);
// la misma convención en las excepciones del core
throw new Exception('FOSSBilling module :mod is not installed/activated', [':mod' => $mod], 715);
En Twig el filtro es trans, como en {{ 'Support Tickets'|trans }}. La extracción del catálogo .pot es el procedimiento oficial del repositorio FOSSBilling/locale:
xgettext -L PHP \
--keyword=__trans \
--keyword=__pluralTrans:1,2 \
--keyword=InformationException \
--keyword=Exception \
--keyword=Server_Exception \
--keyword=Registrar_Exception \
--keyword=Payment_Exception \
--add-comments=TRANSLATORS: --force-po -o %o %C %F
Fíjate en los cinco --keyword de excepciones: los mensajes que pasas a Exception, InformationException, Server_Exception, Registrar_Exception y Payment_Exception también son traducibles y se extraen al catálogo. Los textos de error de tus adaptadores de pasarela del capítulo 10 y de registrador del capítulo 12 entran en el mismo sistema de traducción que el resto.
Sobre traducir un módulo propio, la realidad es limitada: no existe soporte de dominios gettext por módulo, todo va al dominio único messages con _bindtextdomain('messages', PATH_LANGS). Las opciones reales son contribuir tus cadenas al core, si tu módulo se integra en FOSSBilling, o mantener tu propio .po/.mo que el administrador fusione con el messages.mo del idioma. Existe el tipo ExtensionManager::TYPE_TRANSLATION = 'translation' en el directorio de extensiones, pero su flujo concreto de instalación no está documentado: si lo necesitas, compruébalo en el código de ExtensionManager de tu versión antes de apoyarte en él. La recomendación práctica es envolver todas tus cadenas en __trans() desde el primer día y generar tu .po con el mismo xgettext apuntando a la carpeta de tu módulo.
Instalar y activar es_ES, y el estado real de la traducción al español
src/locale/ es un submódulo git que apunta a github.com/FOSSBilling/locale. En un clon limpio del repositorio principal solo contiene en_US/LC_MESSAGES/messages.mo y un readme.md que dice literalmente: “Looking for the FOSSBilling translations? The base repo doesn’t include them, you should manually download and install them from the locale repo or install a pre-made FOSSBilling release which will already include the correct translations.” Los ZIP de release oficiales sí traen las traducciones; si compilaste desde el repositorio, tienes que instalarlas tú. Estructura de un idioma ya instalado:
src/locale/
es_ES/
LC_MESSAGES/
messages.mo
messages.po
.disabled
locales.php
completion.php
El .mo es el único fichero requerido, porque es el catálogo compilado que lee motranslator; el .po es la fuente y es opcional en producción. El fichero .disabled, si existe, desactiva el idioma.
cd /var/www/fossbilling
# 1) descargar el paquete de traducciones. Los tags de release del repo locale son
# el hash MD5 del messages.pot de cada version, y existe el tag movil "latest".
# Comprueba el nombre exacto del asset en la pagina de releases de ese repo.
curl -L -o locale.zip https://github.com/FOSSBilling/locale/releases/download/latest/locale.zip
unzip -o locale.zip -d locale/ # 2) extraer sobre locale/
rm -rf data/cache/* # 3) limpiar cache
Después, en el panel: Extensions → Languages (existe desde 0.6.0), donde ves cada idioma con su porcentaje de completitud y un interruptor. Por API de administración el endpoint es extension_toggle_language; internamente i18n::toggleLocale(string $locale): bool crea o borra el fichero .disabled en la carpeta del idioma, y getLocaleList(bool $disabled = false) recorre los directorios de primer nivel de PATH_LANGS con Finder. El porcentaje sale de getLocaleCompletionPercent(string $locale): int, que lee locale/completion.php: en_US siempre devuelve 100 y, si el fichero no existe, devuelve 0, así que un idioma marcado al 0 % no está necesariamente vacío.
'i18n' => [
'locale' => 'es_ES',
'auto_detect_locale' => true,
'timezone' => 'America/Santiago',
'date_format' => 'medium', // none | short | medium | long
'time_format' => 'short',
'datetime_pattern' => '', // si se define, sobreescribe date_format y time_format con un patrón ICU
],
Advertencia: locale/locales.php, el mapa de código de idioma a nombre nativo, se sobrescribe en cada actualización. Si añades ahí el nombre de un idioma propio, tendrás que volver a ponerlo después de cada update.
Orden de resolución del locale activo
getActiveLocale(Request, bool $autoDetect = true, ?CookieQueue) en src/library/FOSSBilling/i18n.php resuelve así:
sequenceDiagram
participant R as Request
participant I as i18n getActiveLocale
participant C as Config
R->>I: cabeceras y cookies
I->>I: cookie fossbilling_locale si es un locale habilitado
alt no hay
I->>I: cookie legacy fb_locale y migra a la nueva
end
alt tampoco hay
I->>I: cookie legacy BBLANG heredada de BoxBilling y migra
end
alt sigue sin haber y auto_detect_locale es true
I->>R: lee Accept-Language solo el primer idioma preferido
I->>I: Locale acceptFromHttp mas Locale lookup
end
alt nada casa
I->>C: i18n.locale por defecto en_US
end
I-->>R: locale activo
Uno. En 0.8.5 la cookie primaria es fb_locale; la rama main introduce fossbilling_locale manteniendo fb_locale como legacy, y el código migra automáticamente el valor de la vieja a la nueva. Dos. BBLANG es herencia directa de BoxBilling, sigue soportada y también se migra. Tres. La autodetección solo mira el primer idioma preferido del header Accept-Language, cosa que la documentación advierte de forma explícita: un navegador que envíe en;q=0.9, es;q=0.8 recibe inglés aunque tengas es_ES instalado y activo. Cuatro. Si no hay coincidencia exacta se prueba con los dos primeros caracteres, para que en case con en_US y un navegador configurado en es genérico encuentre es_ES.
El estado real del español
El repositorio de locales tiene 43 idiomas en el commit del 2026-08-07:
ar_EG ar_SA bg_BG bn_BD ca_ES cs_CZ da_DK de_DE el_GR en_AU en_GB en_US es_ES
fa_IR fi_FI fr_FR he_IL hr_HR hu_HU id_ID it_IT ja_JP ko_KR lt_LT nl_NL no_NO
pl_PL pt_BR pt_PT ro_RO ru_RU si_LK sl_SI sr_RS sv_SE ta_IN th_TH tr_TR uk_UA
uz_UZ vi_VN zh_CN zh_TW
Solo existe es_ES. No hay es_MX, ni es_AR, ni es_419, ni ninguna otra variante regional: si buscas un español latinoamericano, hoy la vía es contribuirlo en Crowdin. Medición con msgfmt --statistics sobre los catálogos, hecha el 2026-08-09:
| Locale | Traducidos | Sin traducir | Total | Cobertura |
|---|---|---|---|---|
| es_ES | 2105 | 417 | 2522 | ~83,5 % |
| pt_BR | 2521 | 1 | 2522 | ~100 % |
| fr_FR | 2007 | 515 | 2522 | ~79,6 % |
| de_DE | 1969 | 553 | 2522 | ~78,1 % |
El messages.pot tiene 2527 entradas msgid en total, de las cuales 2522 son cadenas traducibles. En la práctica, con es_ES activo unas 417 cadenas siguen apareciendo en inglés, y no están agrupadas en un rincón: el panel se ve mezclado, sobre todo en pantallas de módulos poco usados y en mensajes de error. Es el cuarto o quinto idioma mejor cubierto, muy por detrás del portugués de Brasil. Para medirlo en tu instalación necesitas el .po, no solo el .mo:
msgfmt --statistics -o /dev/null src/locale/es_ES/LC_MESSAGES/messages.po
2105 translated messages, 417 untranslated messages.
Para colaborar, la plataforma es Crowdin en https://translate.fossbilling.org (alias de fossbilling.crowdin.com/FOSSBilling), proyecto FOSSBilling, idioma es_ES. Los .mo se generan automáticamente con GitHub Actions tras cada merge, con el workflow generate-mo.yml. También se acepta un PR directo al repositorio locale sin incluir el .mo, y solo para idiomas ya presentes en Crowdin.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Diagnóstico o solución |
|---|---|---|
| El tema no aparece en el selector de administración | Falta admin_ en el nombre de la carpeta | isAdminAreaTheme() hace str_contains($this->name, 'admin_'); renombra la carpeta |
| El tema no sale con captura en el selector | Manifiesto con icon_url en vez de icon | En temas la clave es icon; en módulos es icon_url |
| Página en blanco o 500 al portar un tema | twig.strict_variables = true | Busca Variable "x" does not exist en los logs; usa |default(...) o {% if x is defined %} |
| 500 en una página de cliente que antes daba 404 | Error real de Twig en get_custom_page | Desde 0.8.x se registra en el canal routing de Monolog |
| Plantilla del módulo no encontrada | Plantillas en html_admin/ en vez de templates/admin/ | El loader exige que el directorio padre se llame literalmente templates |
El override en html_custom/ no se aplica | El directorio no existía al construirse el loader, o hay caché | Créalo, rm -rf data/cache/* y recarga |
Lo que editaste en settings_data.json no cambia nada | Tras la primera lectura los valores viven en extension_meta | Consulta la tabla con extension='mod_theme'; edita desde el panel o borra las filas |
| Un bloque que debería estar oculto sigue visible | Comparación booleana sobre la cadena "0" | Comparar con == '1', no evaluar la variable a secas |
| El JavaScript de la página de ajustes del tema no hace nada | getSettingsPageHtml() elimina <script>, <style> y style="..." | Mover el comportamiento a un asset compilado |
| No se puede subir un asset desde los ajustes del tema | themes/<tema>/assets/ no es escribible | isAssetsPathWritable() lo comprueba; ajusta propietario y permisos |
| Un email concreto no se envía y el resto sí | La plantilla usa algo prohibido por EmailPolicy, típicamente {% set %} | Panel de emails: badge de plantilla rota y opción de restaurar la de fichero |
| Assets del tema no cargan | No se ejecutó npm run build o falta assets/build/manifest.json | npm run build -w mitema |
npm run build -w mitema no encuentra el workspace | El tema no está en workspaces del package.json raíz | Añadir src/themes/mitema al array y npm install |
node ./esbuild.mjs da fichero no encontrado | La documentación dice .mjs, el fichero real es .mts | Usar esbuild.mts |
| Los iconos SVG no salen | Falta assets/build/symbol/icons-sprite.svg, o se usa xlink:href | Desde 0.8.3 es href; si falta el sprite, recompila el tema |
| Los formularios del tema hacen POST y muestran JSON crudo | Falta API._apiForm() y API._apiLink() en DOMContentLoaded | Cargar js/fossbilling.js y js/api.js e inicializar los bindings |
| Un widget nuevo no aparece | El registro se cachea sin expiración | Desactivar y reactivar el módulo, o limpiar caché |
| Un widget sale como recuadro de error sin detalle | Excepción al renderizar; el mensaje solo se muestra en desarrollo | Revisar widgets/mod_widgets_error.html.twig y los logs |
| Un widget declarado no se pinta en su slot | Plantilla en el área equivocada | Slots client.* requieren templates/client/widgets/ |
| El idioma sale al 0 % en Extensions → Languages | Falta locale/completion.php | getLocaleCompletionPercent() devuelve 0 si el fichero no existe |
El panel sigue en inglés con es_ES activo | La autodetección solo mira el primer idioma de Accept-Language | Fijar i18n.locale = 'es_ES' o desactivar auto_detect_locale |
| Los placeholders salen literales en las traducciones | Se usaron %s en vez de :nombre | La sustitución es strtr(), no sprintf() |
| El nombre nativo de un idioma desaparece tras actualizar | locale/locales.php se sobrescribe en cada actualización | Volver a aplicarlo tras el update |
Cuando nada de lo anterior encaja, mira src/data/log/php_error.log para los error_log() de PHP y los canales de Monolog routing, event y cron que recorriste en el capítulo 3.
Lo que queda operativo
Tienes la capa de presentación bajo control: sabes que TwigLoader busca en html_custom/, luego en html/ y luego en los templates/<área>/ de los módulos, y que esa cadena es lo que te permite reemplazar cualquier vista sin tocar el core. Tienes un tema propio compilando con esbuild.mts y registrado como workspace npm, con sus ajustes en settings_data.json y sus valores viviendo realmente en extension_meta. Sabes que strict_variables no perdona, que las plantillas de email corren en un sandbox que prohíbe {% set %} y que una violación bloquea el envío completo. Tienes widgets propios en slots verificados con grep, y es_ES instalado y activo con una expectativa realista: alrededor de un 16 % del panel seguirá en inglés hasta que alguien lo traduzca en Crowdin.
Falta lo que separa una instalación que funciona de una que puedes dejar sola: cerrar el .htaccess y el bloque de nginx sobre config.php, config.old.php y los directorios config/ de los temas, revisar los CVE conocidos de la rama 0.7, endurecer sesión y cookies, ajustar el rate limiter y montar copias de seguridad y actualizaciones que no te dejen a medias. Eso es el capítulo 16, el último del curso.