loading and splitted titles render speed fix
This commit is contained in:
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -61,6 +61,11 @@ gsap.registerPlugin(ScrollTrigger);
|
|||||||
initFsLightbox();
|
initFsLightbox();
|
||||||
initPageFull();
|
initPageFull();
|
||||||
// initVues()
|
// initVues()
|
||||||
|
|
||||||
|
// titles.js traite les titres hors écran par lots, après la première
|
||||||
|
// image : leurs hauteurs peuvent changer après la création des
|
||||||
|
// ScrollTriggers. Lenis suit seul (ResizeObserver) ; ScrollTrigger non.
|
||||||
|
document.addEventListener('leshed:titles-ready', () => ScrollTrigger.refresh(), { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -405,5 +410,10 @@ gsap.registerPlugin(ScrollTrigger);
|
|||||||
init()
|
init()
|
||||||
} // end LeShedTheme()
|
} // end LeShedTheme()
|
||||||
|
|
||||||
LeShedTheme()
|
// Démarrage APRÈS la première image : main.js s'exécute juste après
|
||||||
|
// titles.js (deux defer), et son init (Lenis, GSAP, fslightbox qui injecte
|
||||||
|
// un <style>) forçait restyle + mise en page avant que les titres prêts ne
|
||||||
|
// soient peints (~150 ms mesurés sur /projets). rAF → la frame est
|
||||||
|
// planifiée ; setTimeout → on passe après sa peinture.
|
||||||
|
requestAnimationFrame(() => setTimeout(LeShedTheme, 0))
|
||||||
})(Drupal, drupalSettings)
|
})(Drupal, drupalSettings)
|
||||||
|
|||||||
@@ -2,20 +2,12 @@
|
|||||||
import Splitting from "splitting";
|
import Splitting from "splitting";
|
||||||
import { hyphenateSync } from "hyphen/fr";
|
import { hyphenateSync } from "hyphen/fr";
|
||||||
|
|
||||||
// La classe `variable-title` (posée dans initTitles) masque l'élément via le CSS
|
// Titres traités : la classe `variable-title` est posée côté serveur
|
||||||
// (.variable-title:not(.is-ready)) jusqu'à ce qu'il soit splitté et marqué
|
// (leshed_preprocess_node, block--system-branding-block.html.twig), donc
|
||||||
// `is-ready`. Le CSS ne masque rien que ce fichier ne traite pas : cette liste
|
// présente dès le premier rendu. Le CSS les masque (.variable-title:not(.is-ready))
|
||||||
// peut évoluer librement, aucun contenu ne peut rester invisible par désync.
|
// jusqu'au split : la typo brute n'apparaît jamais. Même classe ici et dans le
|
||||||
const TITLE_SELECTORS = '\
|
// CSS : aucune désynchro possible entre ce qui est masqué et ce qui est traité.
|
||||||
#block-leshed-identitedusite>a, \
|
const TITLE_SELECTOR = '.variable-title';
|
||||||
article.node-type-projet>header>h2,\
|
|
||||||
article.node-type-projet>.cols>.left>h2,\
|
|
||||||
article.node-type-projet.view-mode-full h2.node-title,\
|
|
||||||
article.node-type-evenement>header>h2,\
|
|
||||||
article.node-type-evenement.view-mode-full h2.node-title,\
|
|
||||||
article.node-type-evenement.view-mode-calendrier h2.node-title\
|
|
||||||
';
|
|
||||||
// div.views-archives-evenements > h3 > time\
|
|
||||||
|
|
||||||
// Police Epilogue = axe variable `wght` (100–900) uniquement ; l'italique est
|
// Police Epilogue = axe variable `wght` (100–900) uniquement ; l'italique est
|
||||||
// un fichier séparé (font-style).
|
// un fichier séparé (font-style).
|
||||||
@@ -30,89 +22,164 @@ const TITLE_STYLE = {
|
|||||||
italicOnBold: 0.6, // proba d'italique sur un pic
|
italicOnBold: 0.6, // proba d'italique sur un pic
|
||||||
italicOnMedium: 0.4, // proba d'italique sur un medium
|
italicOnMedium: 0.4, // proba d'italique sur un medium
|
||||||
italicOnThin: 0.30, // proba d'italique sur une fine
|
italicOnThin: 0.30, // proba d'italique sur une fine
|
||||||
|
// Pas des graisses tirées. Chaque valeur distincte de 'wght' coûte une
|
||||||
|
// instance de la police variable (mesuré : ~300 ms de blocage au chargement
|
||||||
|
// avec des entiers 100–900 au hasard, ~30 ms avec quelques paliers). Au pas
|
||||||
|
// de 25 : 18 graisses distinctes (100–400 et 800–900), invisible à l’œil.
|
||||||
|
wghtStep: 25,
|
||||||
};
|
};
|
||||||
|
|
||||||
function randomInt(min, max) {
|
function randomInt(min, max) {
|
||||||
return Math.floor(Math.random() * (max - min)) + min;
|
return Math.floor(Math.random() * (max - min)) + min;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Marge (en hauteurs de viewport) sous l'écran dans laquelle un titre est
|
||||||
|
// traité tout de suite ; au-delà, il l'est par lots pendant les temps morts.
|
||||||
|
const EAGER_VIEWPORTS = 1.5;
|
||||||
|
// Budget d'un lot différé (ms) : on rend la main avant de dépasser.
|
||||||
|
const IDLE_BUDGET = 8;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traite d'abord les titres à l'écran (ou juste dessous) pour qu'ils soient
|
||||||
|
* peints au plus tôt, puis le reste par lots pendant les temps morts.
|
||||||
|
*
|
||||||
|
* Sur une liste longue (/projets : 115 titres, ~1900 lettres), tout traiter
|
||||||
|
* d'un bloc bloquait le thread ~300 ms avant la première image. Les titres
|
||||||
|
* hors écran restent masqués (.variable-title:not(.is-ready)) le temps de
|
||||||
|
* leur lot — personne ne les voit — et le garde-fou CSS de 3 s couvre le
|
||||||
|
* pire cas (requestIdleCallback a un timeout de 1 s).
|
||||||
|
*
|
||||||
|
* Fin du traitement : événement `leshed:titles-ready` sur document (main.js
|
||||||
|
* s'en sert pour recaler ScrollTrigger, les hauteurs ayant pu changer).
|
||||||
|
*/
|
||||||
function initTitles() {
|
function initTitles() {
|
||||||
const titles = document.querySelectorAll(TITLE_SELECTORS);
|
const titles = Array.from(document.querySelectorAll(TITLE_SELECTOR));
|
||||||
|
const limit = window.innerHeight * EAGER_VIEWPORTS;
|
||||||
|
// Lectures seules : une seule mise en page pour tout le tri.
|
||||||
|
const eager = [];
|
||||||
|
const deferred = [];
|
||||||
for (const txt of titles) {
|
for (const txt of titles) {
|
||||||
txt.classList.add("variable-title");
|
const r = txt.getBoundingClientRect();
|
||||||
|
(r.bottom >= 0 && r.top <= limit ? eager : deferred).push(txt);
|
||||||
|
}
|
||||||
|
|
||||||
|
eager.forEach(processTitle);
|
||||||
|
measureOverflow(eager);
|
||||||
|
processDeferred(deferred, () => {
|
||||||
|
document.dispatchEvent(new CustomEvent('leshed:titles-ready'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// La police Epilogue (variable, font-display: block) arrive APRÈS le
|
||||||
|
// premier rendu. Au rechargement, les images sortent du cache instantanément
|
||||||
|
// et la mesure ci-dessus se fait avec les métriques de la police de
|
||||||
|
// substitution : elle conclut « pas de débordement » et plus rien ne la
|
||||||
|
// relance quand la vraie police charge (symptôme : le titre chevauche le
|
||||||
|
// texte sous l'image). On re-mesure les titres déjà traités une fois les
|
||||||
|
// polices prêtes ; ceux des lots suivants sont mesurés à leur traitement.
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
document.fonts.ready.then(() => {
|
||||||
|
measureOverflow(titles.filter((t) => t.classList.contains('is-ready')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split + graisses + césures d'un titre, puis le rend visible. */
|
||||||
|
function processTitle(txt) {
|
||||||
const splitted = Splitting({ target: txt, by: 'chars' });
|
const splitted = Splitting({ target: txt, by: 'chars' });
|
||||||
for (const word of splitted[0].words) {
|
for (const word of splitted[0].words) {
|
||||||
const chars = Array.from(word.getElementsByClassName('char'));
|
const chars = Array.from(word.getElementsByClassName('char'));
|
||||||
styleWordChars(chars);
|
styleWordChars(chars);
|
||||||
hyphenateWord(chars);
|
hyphenateWord(chars);
|
||||||
}
|
}
|
||||||
txt.classList.add("is-ready");
|
txt.classList.add('is-ready');
|
||||||
measureOverflow(txt);
|
}
|
||||||
|
|
||||||
|
/** Traite `queue` par lots de IDLE_BUDGET ms, puis appelle `done`. */
|
||||||
|
function processDeferred(queue, done) {
|
||||||
|
if (!queue.length) {
|
||||||
|
done();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const schedule = window.requestIdleCallback
|
||||||
|
? (fn) => window.requestIdleCallback(fn, { timeout: 1000 })
|
||||||
|
: (fn) => setTimeout(fn, 16);
|
||||||
|
const step = () => {
|
||||||
|
const end = performance.now() + IDLE_BUDGET;
|
||||||
|
const batch = [];
|
||||||
|
while (queue.length && performance.now() < end) {
|
||||||
|
const txt = queue.shift();
|
||||||
|
processTitle(txt);
|
||||||
|
batch.push(txt);
|
||||||
|
}
|
||||||
|
measureOverflow(batch);
|
||||||
|
queue.length ? schedule(step) : done();
|
||||||
|
};
|
||||||
|
schedule(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mesure le débordement des titres sous leur image et pose data-overflow-lines.
|
||||||
|
*
|
||||||
|
* Si un titre déborde de N lignes sous l'image, l'attribut data-overflow-lines
|
||||||
|
* permet au CSS d'augmenter max-height de N lignes pour absorber le débordement.
|
||||||
|
*
|
||||||
|
* Mesures GROUPÉES : toutes les écritures, puis toutes les lectures, puis les
|
||||||
|
* écritures finales — une seule mise en page pour le lot au lieu d'une par
|
||||||
|
* titre (mesuré : ~65 ms de reflows forcés sur /projets en mode intercalé).
|
||||||
|
*/
|
||||||
|
const awaitingLoad = new WeakSet();
|
||||||
|
|
||||||
|
function measureOverflow(h2s) {
|
||||||
|
const items = [];
|
||||||
|
for (const h2 of h2s) {
|
||||||
|
const article = h2.closest('article.has-image');
|
||||||
|
const img = article && article.querySelector('header .field_images img');
|
||||||
|
if (img) items.push({ h2, img });
|
||||||
|
}
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
// 1) Écritures : hauteur totale du titre sans le max-height.
|
||||||
|
for (const { h2 } of items) h2.style.maxHeight = 'none';
|
||||||
|
|
||||||
|
// 2) Lectures (une seule mise en page).
|
||||||
|
for (const item of items) {
|
||||||
|
const { h2, img } = item;
|
||||||
|
item.lineHeight = parseFloat(getComputedStyle(h2).lineHeight);
|
||||||
|
// Hauteur réelle de l'image rendue (width:100%, height:auto).
|
||||||
|
item.imgHeight = img.naturalWidth && img.complete
|
||||||
|
? img.clientWidth * (img.naturalHeight / img.naturalWidth)
|
||||||
|
: img.clientHeight;
|
||||||
|
item.titleHeight = h2.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Écritures finales.
|
||||||
|
for (const { h2, img, lineHeight, imgHeight, titleHeight } of items) {
|
||||||
|
h2.style.maxHeight = '';
|
||||||
|
// Lignes du titre qui dépassent sous l'image.
|
||||||
|
// 1 ligne est déjà dans le flux (max-height: 1em par défaut).
|
||||||
|
if (lineHeight && imgHeight) {
|
||||||
|
const overflowPx = titleHeight - imgHeight;
|
||||||
|
if (overflowPx > 0) {
|
||||||
|
h2.setAttribute('data-overflow-lines', Math.ceil(overflowPx / lineHeight));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hors écran, une image lazy ne déclenche jamais 'load', mais sa boîte de
|
||||||
|
// layout existe dès le départ (attributs width/height de Drupal →
|
||||||
|
// aspect-ratio) : la mesure ci-dessus vaut. Au 'load' réel, on re-mesure.
|
||||||
|
if ((!img.complete || !img.naturalWidth) && !awaitingLoad.has(img)) {
|
||||||
|
awaitingLoad.add(img);
|
||||||
|
img.addEventListener('load', () => measureOverflow([h2]), { once: true });
|
||||||
}
|
}
|
||||||
// La police Epilogue (variable, font-display: block) arrive APRÈS le
|
|
||||||
// premier rendu. Au rechargement, les images sortent du cache instantanément
|
|
||||||
// et la mesure ci-dessus se fait avec les métriques de la police de
|
|
||||||
// substitution : elle conclut « pas de débordement » et plus rien ne la
|
|
||||||
// relance quand la vraie police charge (symptôme : le titre chevauche le
|
|
||||||
// texte sous l'image). On re-mesure tout une fois les polices prêtes —
|
|
||||||
// measureOverflow est idempotent, il écrase juste l'attribut.
|
|
||||||
if (document.fonts && document.fonts.ready) {
|
|
||||||
document.fonts.ready.then(() => {
|
|
||||||
for (const txt of titles) measureOverflow(txt);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mesure le débordement du titre sous l'image et pose data-overflow-lines.
|
* Graisse au hasard dans [min, max], sur les paliers de TITLE_STYLE.wghtStep
|
||||||
*
|
* (bornes incluses, tirage uniforme entre paliers).
|
||||||
* Si le titre déborde de N lignes sous l'image, l'attribut data-overflow-lines
|
|
||||||
* permet au CSS d'augmenter max-height de N lignes pour absorber le débordement.
|
|
||||||
*/
|
*/
|
||||||
function measureOverflow(h2) {
|
function randomWght({ min, max }) {
|
||||||
const article = h2.closest('article.has-image');
|
const step = TITLE_STYLE.wghtStep;
|
||||||
if (!article) return;
|
return min + step * randomInt(0, Math.floor((max - min) / step) + 1);
|
||||||
|
|
||||||
const img = article.querySelector('header .field_images img');
|
|
||||||
if (!img) return;
|
|
||||||
|
|
||||||
const measure = () => {
|
|
||||||
const lineHeight = parseFloat(getComputedStyle(h2).lineHeight);
|
|
||||||
if (!lineHeight) return;
|
|
||||||
|
|
||||||
// Hauteur réelle de l'image rendue (width:100%, height:auto).
|
|
||||||
let imgHeight;
|
|
||||||
if (img.naturalWidth && img.complete) {
|
|
||||||
imgHeight = img.clientWidth * (img.naturalHeight / img.naturalWidth);
|
|
||||||
} else {
|
|
||||||
imgHeight = img.clientHeight;
|
|
||||||
}
|
|
||||||
if (!imgHeight) return;
|
|
||||||
|
|
||||||
// scrollHeight = hauteur totale du titre sans le max-height.
|
|
||||||
h2.style.maxHeight = 'none';
|
|
||||||
const titleHeight = h2.scrollHeight;
|
|
||||||
h2.style.maxHeight = '';
|
|
||||||
|
|
||||||
// Lignes du titre qui dépassent sous l'image.
|
|
||||||
// 1 ligne est déjà dans le flux (max-height: 1em par défaut).
|
|
||||||
const overflowPx = titleHeight - imgHeight;
|
|
||||||
if (overflowPx <= 0) return;
|
|
||||||
|
|
||||||
const overflowLines = Math.ceil(overflowPx / lineHeight);
|
|
||||||
if (overflowLines > 0) {
|
|
||||||
h2.setAttribute('data-overflow-lines', overflowLines);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Toujours mesurer immédiatement : hors écran, une image lazy ne déclenche
|
|
||||||
// jamais 'load', mais sa boîte de layout existe dès le départ (attributs
|
|
||||||
// width/height de Drupal → aspect-ratio). Les re-mesures ultérieures
|
|
||||||
// (load réel ci-dessous, fonts.ready dans initTitles) écrasent l'attribut.
|
|
||||||
measure();
|
|
||||||
if (!img.complete || !img.naturalWidth) {
|
|
||||||
img.addEventListener('load', measure, { once: true });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -169,7 +236,7 @@ function styleWordChars(chars) {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
char,
|
char,
|
||||||
wght: randomInt(range.min, range.max + 1),
|
wght: randomWght(range),
|
||||||
ital: Math.random() < pItal,
|
ital: Math.random() < pItal,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -41,12 +41,13 @@
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// La classe `variable-title` est posée par assets/js/titles.js au moment où
|
// La classe `variable-title` est posée côté serveur (leshed_preprocess_node,
|
||||||
// il prend en charge un titre. Le CSS ne masque donc QUE des éléments que le
|
// block--system-branding-block.html.twig) : présente dès le premier rendu,
|
||||||
// JS traite : une désynchronisation de listes de sélecteurs ne peut plus
|
// elle permet de masquer le titre AVANT que titles.js ne s'exécute — pas de
|
||||||
// rendre de contenu invisible (c'est ce qui cachait les dates des teasers
|
// typo brute à l'écran. titles.js sélectionne cette même classe : le CSS ne
|
||||||
// pendant 3 s — le garde-fou). Un titre non traité par le JS n'est jamais
|
// masque QUE des éléments que le JS traite (pas de liste de sélecteurs à
|
||||||
// masqué : il reste affiché tel quel.
|
// tenir synchronisée — c'est ce qui cachait les dates des teasers pendant
|
||||||
|
// 3 s, le garde-fou).
|
||||||
|
|
||||||
// Avant le split : même métrique qu'après, pour éviter un saut de mise en page.
|
// Avant le split : même métrique qu'après, pour éviter un saut de mise en page.
|
||||||
.variable-title:not(.splitting){
|
.variable-title:not(.splitting){
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ global:
|
|||||||
assets/dist/main.css: {}
|
assets/dist/main.css: {}
|
||||||
# assets/scss/main.css: {}
|
# assets/scss/main.css: {}
|
||||||
|
|
||||||
|
# CSS compilée seule. En dev, on l'attache AUX CÔTÉS du JS HMR (voir
|
||||||
|
# leshed_page_attachments_alter) : servie en <link> render-blocking, elle
|
||||||
|
# élimine le flash de contenu sans styles pendant que les modules Vite
|
||||||
|
# chargent (eux injectent ensuite les styles à jour par-dessus — plus tard
|
||||||
|
# dans la cascade, ils gagnent les égalités).
|
||||||
|
base_css:
|
||||||
|
css:
|
||||||
|
theme:
|
||||||
|
assets/dist/main.css: {}
|
||||||
|
|
||||||
# Rendu typographique des titres, au plus tôt (voir assets/js/titles.js).
|
# Rendu typographique des titres, au plus tôt (voir assets/js/titles.js).
|
||||||
titles:
|
titles:
|
||||||
header: true
|
header: true
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ function leshed_page_attachments_alter(&$page) {
|
|||||||
// $page['#attached']['library'][] = 'http://localhost:5173/assets/js/main.js';
|
// $page['#attached']['library'][] = 'http://localhost:5173/assets/js/main.js';
|
||||||
$page['#attached']['library'][] = 'leshed/vitehmr';
|
$page['#attached']['library'][] = 'leshed/vitehmr';
|
||||||
$page['#attached']['library'][] = 'leshed/titles_hmr';
|
$page['#attached']['library'][] = 'leshed/titles_hmr';
|
||||||
|
// CSS compilée en <link> render-blocking : sans elle, la page se peint
|
||||||
|
// sans styles pendant le chargement des modules Vite (qui portent la CSS
|
||||||
|
// en dev). Le build --watch tient assets/dist/main.css frais ; les styles
|
||||||
|
// injectés ensuite par Vite, plus tardifs dans la cascade, prennent le
|
||||||
|
// dessus sur les égalités.
|
||||||
|
$page['#attached']['library'][] = 'leshed/base_css';
|
||||||
}else{
|
}else{
|
||||||
$page['#attached']['library'][] = 'leshed/global';
|
$page['#attached']['library'][] = 'leshed/global';
|
||||||
$page['#attached']['library'][] = 'leshed/titles';
|
$page['#attached']['library'][] = 'leshed/titles';
|
||||||
@@ -144,6 +150,19 @@ function leshed_preprocess_node(&$variables) {
|
|||||||
|
|
||||||
$variables["attributes"]['class'][] = "view-mode-{$variables['view_mode']}";
|
$variables["attributes"]['class'][] = "view-mode-{$variables['view_mode']}";
|
||||||
|
|
||||||
|
// Titres typographiques (assets/js/titles.js) : la classe est posée ICI,
|
||||||
|
// dans le HTML servi, et non par le JS. Le CSS peut ainsi masquer le titre
|
||||||
|
// dès le premier rendu (.variable-title:not(.is-ready)) au lieu de laisser
|
||||||
|
// voir la typo brute le temps que le module s'exécute ; et le JS
|
||||||
|
// sélectionne cette même classe : une seule liste, aucune désynchro.
|
||||||
|
$variable_titles = [
|
||||||
|
'projet' => ['teaser', 'tableau_liste', 'lie', 'full'],
|
||||||
|
'evenement' => ['teaser', 'tableau_liste', 'calendrier', 'full'],
|
||||||
|
];
|
||||||
|
if (in_array($variables['view_mode'], $variable_titles[$node_type] ?? [], TRUE)) {
|
||||||
|
$variables['title_attributes']['class'][] = 'variable-title';
|
||||||
|
}
|
||||||
|
|
||||||
if ($node->hasField('field_images')) {
|
if ($node->hasField('field_images')) {
|
||||||
/** @var \Drupal\file\Plugin\\Field\FieldType\FileFieldItemList $field_images */
|
/** @var \Drupal\file\Plugin\\Field\FieldType\FileFieldItemList $field_images */
|
||||||
$field_images = $node->get('field_images');
|
$field_images = $node->get('field_images');
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "block.html.twig" %}
|
||||||
|
{#
|
||||||
|
/**
|
||||||
|
* @file
|
||||||
|
* Bloc identité du site. Surcharge du gabarit core uniquement pour poser
|
||||||
|
* `variable-title` sur le nom du site, dans le HTML servi (voir
|
||||||
|
* leshed_preprocess_node() et assets/js/titles.js).
|
||||||
|
*/
|
||||||
|
#}
|
||||||
|
{% block content %}
|
||||||
|
{% if site_logo %}
|
||||||
|
<a href="{{ path('<front>') }}" rel="home">
|
||||||
|
<img src="{{ site_logo }}" alt="{{ 'Home'|t }}" fetchpriority="high" />
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if site_name %}
|
||||||
|
<a href="{{ path('<front>') }}" rel="home" class="variable-title">{{ site_name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{{ site_slogan }}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user