Points d'extension Umbraco
Le coeur du template. Tous les enregistrements passent par SiteComposer ; les comportements techniques (404, 500, sitemap, robots, login dev) sont des controllers ou des content finders.
Composer (src/Web/SiteComposer.cs)
IComposer unique, decouvert par AddComposers() dans Program.cs. Il :
builder.SetContentLastChanceFinder<NotFoundContentFinder>();- enregistre le service du dashboard "Système" :
builder.Services.AddSingleton<IDashboardInfoService, DashboardInfoService>(); - enregistre le provider 2FA :
new BackOfficeIdentityBuilder(builder.Services).AddTwoFactorProvider<UmbracoUserAppAuthenticator>(UmbracoUserAppAuthenticator.Name).
C'est le point d'entree pour tout nouveau wire-up (DI, notification handlers, content finders...).
Resolution du site courant (Services/SiteResolver.cs) — multi-sites
ISiteResolver.GetRootAsync(authority, path) : unique point de resolution host (+ path) -> racine du site. Enregistre en singleton dans SiteComposer.
Cette instance heberge plusieurs marques cote a cote (une racine *Home par marque + un domaine). Avant, quatre endroits refaisaient ce lookup a la main et retombaient chacun sur "la premiere racine trouvee" : inoffensif avec un seul site, silencieusement faux avec quatre (une requete pour la marque A pouvait recevoir la 404 / la 500 / le sitemap de la marque B).
Regles de match :
DomainNameaccepte les formesexemple.ch,https://exemple.ch:44360,exemple.ch/fr(path-scoped — deux cultures sur un meme host, prevu pourchevaliers.ch/fr) et/fr(path seul, tous hosts).- Le scheme est retire, la valeur est
Trim()ee (des lignesumbracoDomaincontiennent des espaces parasites). - Priorite : domaine avec host avant un domaine path-seul, puis path le plus long (
chevaliers.ch/frgagne surchevaliers.chpour/fr/contact). - Aucun fallback arbitraire : host inconnu ->
null, l'appelant decide (en pratique 404). Servir le contenu d'une autre marque n'est jamais la bonne reponse.
Consommateurs : NotFoundContentFinder, SitemapController, ErrorController, _MasterLayout.cshtml.
Cote vue : Model.AncestorOrSelf(1), JAMAIS AncestorOrSelf<HomePage>()
⚠️ Piege multi-marques, deja tombe une fois. _MasterLayout resolvait la racine ainsi :
var homePage = Model.AncestorOrSelf<HomePage>()
?? Umbraco.ContentAtRoot().OfType<HomePage>().FirstOrDefault(); // <- BUGSeul Gilliard utilise le doctype homePage. Pour une page de chevaliers/pdn/gilliarday la recherche typee renvoie null, et le repli « premiere racine » attrape la racine Gilliard : les 4 hotes servaient alors le theme, le logo et les menus de Gilliard. Constate le 2026-07-16 des la creation des racines par marque — chevaliers.localtest.me affichait « Maison Gilliard ». Aucune erreur, aucun log : juste la mauvaise marque.
Regle : racine de site = ancetre de niveau 1, resolu sans type :
var siteRoot = Model.AncestorOrSelf(1);
var siteSettings = siteRoot as ISiteSettings; // toutes les *Home composent siteSettings
var marketing = siteRoot as IMarketingSettings;
var theme = siteRoot.GetThemeName();Si siteRoot est null (page hors arbre), on n'affiche pas l'en-tete/le pied plutot que de servir une autre marque — meme principe que SiteResolver : aucun repli arbitraire.
Page 404 par site (ContentFinders/NotFoundContentFinder.cs)
IContentLastChanceFinder (constructeur primaire : ISiteResolver). Logique :
- Resout la racine du site via
ISiteResolver(host + path de la requete). Pas de racine ->false. - Cherche un enfant de la racine dont le content type est
NotFoundPage.ModelTypeAlias. - Si trouve,
SetPublishedContent; retournetruesi un contenu a ete pose.
Permet une page 404 differente par site dans une instance multi-sites.
Erreurs 500 (Controllers/ErrorController.cs)
Route ~/error/ (declaree dans ReservedPaths), branchee via UseExceptionHandler("/error") (Program.cs, hors dev). Si le status est 500, redirige vers le noeud internalServerErrorPage de la marque demandee (racine resolue par ISiteResolver) ; sinon redirige vers /.
Sitemap (Controllers/SitemapController.cs)
Controller simple (PAS RenderController), route /sitemap, ResponseCache 600 s. Construit un sitemap XML unique multilingue :
- resout la racine via
ISiteResolver; la racine resolue est le noeud d'accueil ; - parcourt recursivement les enfants ;
- n'inclut un noeud que s'il a un url segment, un template (vraie page) et n'est pas masque du sitemap (
HideFromSiteMap) ; forcehttps;lastmodau format W3C.
Deux pieges corriges ici (le sitemap etait casse — 404 — avant) :
- Ne jamais heriter de
RenderControllerpour une route custom. Umbraco route lesRenderControllervia son propre pipeline de contenu (UmbracoRouteValues) : une action en attribute-routing dessus n'est jamais atteinte, et/sitemapservait silencieusement la page 404.RobotsControllerest unControllersimple pour la meme raison. Ce controleur retourne unContentResultet ne rend aucune vue : il n'a besoin de rien deRenderController.- Ne pas chercher
HomePage.ModelTypeAliasdans les descendants. Chaque marque a son propre doctype d'accueil (chevaliersHome, ...) : ce filtre renvoyaitnullet 404ait le sitemap de toutes les nouvelles marques.
robots.txt (Controllers/RobotsController.cs)
Route /robots.txt, cache 24 h. En production : sert uniquement la ligne sitemap: (URL absolue https du sitemap). Hors production : user-agent: * + disallow: / (blocage indexation).
Login dev (Controllers/DevLoginController.cs)
#if DEBUG uniquement. Route GET /l. Double garde : compile en DEBUG ET check runtime IWebHostEnvironment.IsDevelopment() (sinon 404). Recupere jusqu'a 500 utilisateurs, prend le premier dont l'email finit par @spektrummedia.com, le connecte (non persistant) et redirige vers /umbraco. Ne jamais retirer une des deux gardes.
Regeneration des modeles dev (Controllers/DevModelsController.cs)
#if DEBUG uniquement. Route GET /generate-models. Meme double garde que le login dev (compile DEBUG + IsDevelopment(), sinon 404). Force ModelsBuilder a (re)generer les umbraco/Models/*.generated.cs a la demande, en appelant le meme generateur que le dashboard ModelsBuilder du backoffice.
Pourquoi c'est necessaire : le mode SourceCodeAuto ne regenere les modeles que sur une notification de sauvegarde d'un content type. uSync supprime ces notifications pendant son import au demarrage : un content type ajoute/modifie uniquement via les fichiers uSync/ ne declenche donc aucune regeneration. Sans cet endpoint, il faudrait ouvrir et re-sauver chaque type dans le backoffice. Le generateur (IModelsGenerator) est internal a Umbraco et son namespace change selon la majeure, donc il est localise par balayage des assemblies Umbraco* chargees (type exposant un GenerateModels() sans parametre) puis resolu via le conteneur DI. Voir le parcours dev dedie dans ../domain/workflows.md.
Seed racines de site dev (Controllers/DevSeedRootsController.cs)
GET /seed-roots (DEBUG + Development uniquement, comme DevLoginController). Met chaque marque sur son doctype de racine (chevaliersHome/pdnHome/gilliardayHome) et lie ses domaines. Idempotent : relancer signale « already ... — untouched ».
Pourquoi supprimer/recreer : le doctype d'un noeud ne peut pas etre change. Les 3 placeholders avaient ete crees en homePage (le doctype de Gilliard).
Garde-fou : refuse de supprimer une racine ayant le moindre enfant (CountChildren, verifie a chaud). Gilliard n'est pas dans la liste Roots et serait stoppe par ce test (18 enfants). Vide ensuite la corbeille pour qu'un placeholder ne soit pas restaure sur le mauvais doctype.
⚠️ Les domaines sont en base uniquement — uSync ne les serialise pas, ils ne partent pas vers staging/prod avec le commit. /seed-roots les recree selon l'environnement (DomainsFor), plus de ressaisie a la main :
| environnement | origines liees |
|---|---|
| Development | https://<marque>.localtest.me:44360 et http://<marque>.localtest.me:12953 (les deux ports), plus un raccourci par chemin sur localhost : / Gilliard, /chevaliers, /novembre, /gilliarday |
| Staging | https://<marque>.staging.spektrum-suisse.ch |
Le label d'hote est le meme partout (gilliard, chevaliers, portedenovembre, gilliarday) ; chaque marque a tete allemande recoit en plus <origine>/de en de. Gilliarday reste FR seul.
Les hostnames sont listes en premier et restent l'origine canonique en dev : c'est ce que mesure tools/fidelity et ce vers quoi les liens seedes doivent resoudre. Le raccourci localhost/<marque> sert a naviguer sans hostname ; ne pas lancer un seeder depuis lui, un lien seede pourrait se figer en /novembre/.... Il utilise IDomainService.UpdateDomainsAsync (l'API courante ; Save/Delete/GetAssignedDomains sont obsoletes en 17) : elle remplace les affectations du noeud, d'ou l'idempotence. DefaultIsoCode remplace l'ancienne ligne joker *<id>.
Plomberie de seed partagee (Seed/)
Trois fichiers, DEBUG uniquement, qui rendent les seeders reutilisables par les 4 marques :
Seed/ElementTypes.cs— les Keys des element types du jeu de blocs partage. Source unique. Elles etaient dupliquees en constantes privees dans chaque controleur de seed : c'est comme ca qu'un bloc finit seede sous deux noms, ou qu'un controleur continue de seeder une Key qui n'existe plus apres un renommage.Seed/SeedContext.cs— quelle marque une execution vise :RootId,RootDocType,ContentPageAlias,Culture,MediaFolder. La tableSeedContext.Brandsest la source unique du cablage par marque (les fabriquesGilliard()/Chevaliers()/… et les tests la lisent, plutot que deux copies tenues a la main).Seed/SeedSession.cs— la mecanique agnostique de marque :FindOrCreate,SetHero,PublishNode,Grid,SeedPage,SeedText,InternalLink. Un controleur de site ne garde que son contenu (les textes, la liste de medias, la structure des blocs) et delegue le reste.
⚠️ Ne JAMAIS resoudre une racine par id de noeud. GilliardHomeId = 1105 etait code en dur. Les ids different par environnement — et ne sont meme pas stables en local : recreer les racines placeholder sur leurs propres doctypes les a fait passer de 1106/1107/1108 a 1382/1383/1384. On resout par alias de doctype de racine (SeedContext.Resolve -> ContentService.GetRootContent()), le seul point d'ancrage stable, et chaque marque n'a qu'une racine donc l'alias identifie le site. Aucun repli : racine introuvable -> on le signale, on ne seede pas par-dessus une autre marque.
Chaque marque a son propre dossier media (Gilliard Seed, Chevaliers Seed, …), sinon l'import d'un seeder ecrase celui d'un autre. Verrouille par test.
Ajouter une marque = 1 ligne dans SeedContext.Brands + un controleur qui ne contient que son contenu.
Reinitialiser et rejouer le seed (DevSeedAllController, DevSeedClearController, Seed/SeedCleaner.cs)
Deux endpoints d'orchestration, memes trois verrous que les autres seeders (#if DEBUG || ALLOW_SEED, 404 hors Development/Staging, login backoffice).
GET /seed-allrejoue les 5 seeders dans l'ordre de dependance :/seed-roots, puis/seed-home,/seed-pages,/seed-chevaliers,/seed-pdn. Parametres :clear=yes(nettoie d'abord),brand=etmedia=(passes a l'etape de nettoyage),only=seed-pdn,seed-pagespour n'en rejouer qu'une partie. Le rapport est stream, un flush par etape : une passe complete telecharge plusieurs centaines d'images et dure des minutes, assez pour qu'un reverse proxy coupe une connexion inactive - et assez pour qu'un seul bloc de texte a la fin n'apprenne rien pendant l'attente. Les sous-seeders sont instancies depuis le conteneur DI (ActivatorUtilities.CreateInstance) et leur action est attendue en direct : leurs constructeurs ne prennent que des services et aucun ne toucheHttpContextniUrl, donc l'appel en process equivaut aux 5 appels HTTP sans les allers-retours. Leur propre garde Development/Staging s'execute quand meme.GET /seed-clear?confirm=yessupprime le contenu seede.confirm=yesest obligatoire : c'est le seul endpoint destructif de la famille, et un GET nu est exactement ce que produisent un favori, un prefetch de navigateur ou une URL autocompletee. Sans le parametre, il n'affiche que son mode d'emploi. Parametres :brand=<gilliard|chevaliers|pdn|gilliarday|all>etmedia=yespour vider aussi le dossier media de la marque (le run suivant re-telecharge tout).
Ce que le nettoyage supprime : tous les enfants de chaque racine de marque, branche comprise (IContentService.Delete est definitif, il ne remplit pas la corbeille), et en option le dossier media <Marque> Seed.
Ce qu'il garde volontairement : les 4 racines de site. Leur doctype, leurs cultures et surtout leurs domaines ne sont pas du contenu de seed - les domaines sont en base uniquement (uSync ne les serialise pas) et c'est /seed-roots qui les recree, par environnement. Supprimer une racine jetterait ce reglage et rendrait le site en 404 jusqu'au prochain /seed-roots. La corbeille non plus n'est pas touchee par le nettoyage - mais /seed-roots la vide a chaque passage, pour sa propre raison.
Pourquoi il existe : les seeders sont idempotents sur (parent, alias, nom). Cela couvre une reexecution, pas un renommage ni une page qui cesse d'etre seedee - l'ancien noeud reste et le site porte les deux (c'est le defaut qui a donne douze cartes de lieux la ou le live en a dix, et qui a motive SeedSession.PruneChildren). En local, la remise a zero etait "supprimer le fichier SQLite et repartir" ; en staging la base est reelle, donc la meme remise a zero passe par un endpoint.
Seed contenu Home dev (Controllers/DevSeedController.cs)
#if DEBUG uniquement. Route GET /seed-home. Meme double garde. Peuple la propriete contentBlocks (Block Grid) de la racine Gilliard (node 1105) avec un contenu representatif puis publie la culture fr, pour permettre le pixel-diff sans saisie manuelle au backoffice. Idempotent (ecrase + republie). Construit la valeur block-editor JSON (format v14+ : contentData / expose / layout.Umbraco.BlockGrid, listes imbriquees en layout.Umbraco.BlockList) et l'affecte via IContentService.SetValue + publication via IContentPublishingService.PublishAsync (l'ancien SaveAndPublish a ete retire en Umbraco 17). Sert aussi de prototype du futur script d'import WordPress. Increment actuel : blocs textuels (TextBox / TextArea / BlockList imbriquee) ; media (MediaPicker3), texte riche (RichText) et liens (MultiUrlPicker) a ajouter ensuite.
Convention de liens CTA (home) : les CTA « vins/boutique » (hors scope e-commerce) pointent vers le live absolu https://gilliard.ch/fr/vins/ ; les CTA vers du contenu dans le scope pointent vers nos pages internes relatives (evenements -> /evenements/, activites -> /activites-oenotouristiques/). Ne jamais seeder un lien interne vers un slug inexistant (/vins/, /agenda/, /oenotourisme/ n'existent pas -> 404).
Seed pages interieures dev (Controllers/DevSeedPagesController.cs)
#if DEBUG uniquement. Route GET /seed-pages. Meme double garde. Seede plusieurs pages contentPage sous la racine Gilliard (node 1105) via un helper SeedPage(name, heroTitle, heroImage, blocks) : Nos valeurs (richIntro + 6 mediaText + ctaBanner), En primeur (richIntro + 2 mediaText + formBlock inquiry + ctaBanner) et Contact (cardCollection de 3 cartes info + map + formBlock contact étroit + ctaBanner). Importe les media du client dans le dossier "Gilliard Seed", renseigne la composition pageHero puis la grille contentBlocks, publie fr. Idempotent. Meme format de valeur block-editor et memes builders que DevSeedController, factorises dans DevSeedSupport (voir plus bas). Prototype d'import page-par-page ; ajouter une page = definir ses blocs + un appel SeedPage.
Depuis les phases B/C-E, /seed-pages seede aussi toutes les pages libres (a-propos, activites x2, ambassadeurs, vinotheque, newsletter, gammes, cochetta) et les pages structures : Team (contentPage avec bloc teamGrid imbrique groupes/personnes), Lieux d'accueil (venueList + 3 venue), CGV/Confidentialite (textPage), Blog (blogList + 3 blogPost), Evenements (eventList + 3 event avec dates promues), Emploi (vacancyList + 3 vacancy). Helpers generiques ajoutes : FindOrCreate(parent, alias, name), SetHero, PublishNode, Grid(blocks), SeedText. Les listes sont publiees avant leurs enfants (contrainte de publication Umbraco). Le block-list imbrique 2 niveaux (teamGrid.groups -> teamGroup.people) est construit via l'helper List(...) recursif.
Depuis le nav wiring, /seed-pages appelle aussi SeedSiteSettings() : peuple la composition siteSettings sur le noeud racine 1105 pour reproduire la nav du live (mega Vins, dropdowns Oenotourisme/Agenda/Nos domaines/A propos, items plats Vinotheque/Contact ; footer nav/socials/liens legaux/contact/newsletter). Helpers locaux : Item(label, link, children), Lnk(label, link, column, style), Social(network, url). Les liens dans le scope sont resolus vers nos pages internes via InternalLink(nodeId, name) (utilise IPublishedUrlProvider.GetUrl(...) et emet une valeur MultiUrlPicker de type externe avec l'URL relative resolue — rendu fiable, sans dependance au format JSON de lien interne) ; les liens hors scope pointent vers les URLs absolues du live (const string live = "https://gilliard.ch/fr/"). Puis PublishNode(1105, fr).
Plomberie partagee (Controllers/DevSeedSupport.cs)
#if DEBUG uniquement. Classe statique + records top-level partages par les deux seeders pour ne pas dupliquer ~130 lignes (nettoyage duplication 2026-07-16). Contient : les records Block(contentTypeKey, values) et PropertyValue(editorAlias, value) ; les constantes GilliardHomeId=1105, Culture="fr", MediaFolderName="Gilliard Seed", Ua ; ImportMediaAsync(...) statique (prend les services Umbraco en parametres — IMediaService, MediaFileManager, MediaUrlGeneratorCollection, IShortStringHelper, IContentTypeBaseServiceProvider, la liste des sources) ; et les builders de valeur par editeur Text/TextArea/Bool/Dropdown/Rich/Link/Media/MediaMultiple/List + l'assemblage BuildBlockEditorValue(blocks, layoutKey, grid) (format block-editor v14+). Les deux controllers font using static Web.Controllers.DevSeedSupport; pour garder des sites d'appel non qualifies (Text(...), Rich(...), List(...)). Chaque controller ne conserve que son orchestration propre (DevSeedController : les blocs home ; DevSeedPagesController : SeedPage, Grid, SeedText, PublishNode, FindOrCreate, SetHero, SeedSiteSettings, ses cles GUID d'element et ses MediaSources).
Ajouts 2026-07-17 : Number(int) (Umbraco.Integer) et ContentPick(Guid) (Umbraco.ContentPicker, valeur = UDI document construite via Udi.Create plutot qu'un "umb://document/{key:N}" formate a la main). ContentPick sert childListing.source — c'est ce qui fait d'une liste une vue des enfants d'une autre page au lieu de contenu duplique (l'accueil de chevaliers pointe ainsi sur le noeud Activites).
Generateur de seed (tools/seed-gen/)
Le seeder de chevaliers n'est pas ecrit a la main : DevSeedChevaliersController.cs est genere. Ne pas l'editer — modifier le generateur et relancer.
| Fichier | Role |
|---|---|
chevaliers-bands.py | Scrape le live par bande, dans l'ordre du DOM -> data/bands.json. Regle porteuse structurelle : une .row contenant une .row-img est un mediaText (le theme ecrit cette meme ligne de 4 facons ; matcher la classe perd des lignes). Ecrit aussi trois cles _-prefixees (metadonnees, pas des pages — tout compteur doit les filtrer sur le prefixe, pas nommer une cle) : _site (nav/pied de page), _listingHeroes + _listingCtas (banniere de titre et banniere de renvoi des pages de listing, dont les lignes sont pilotees par requete et qui n'etaient donc dans aucune liste), _activityDetails (bandeau « En bref… » et formulaire des pages activite). |
chevaliers-gen.py | data/{bands,content,events}.json -> le controller C#. Une bande = un bloc. |
data/ | Le scrape, dans le repo (avant : un scratchpad de session, efface -> generateur injouable). data/html/ = les pages live en cache ; --refresh re-telecharge. |
Ce que le generateur signale a chaque run — jamais en silence : DROPPED (URL non-image : le live n'a pas d'image la), NOT PORTED (bandes mesurees a zero pixel, cf. domain/assets-substitues.md §6), HOTLINKED (icones en ligne pointant encore sur chevaliers.ch).
Lieux d'accueil gilliard (gilliard-venues.mjs -> GilliardVenueData.cs)
Meme principe, cote gilliard, pour les dix pages /fr/<slug>/ de lieux d'accueil :
| Fichier | Role |
|---|---|
gilliard-venues.mjs | Scrape les 10 pages live -> data/gilliard-venues.json (hero, intro, lignes, « En bref… », formulaire, embed, CTA). |
gilliard-venues-gen.mjs | data/gilliard-venues.json -> src/Web/Seed/GilliardVenueData.cs (genere : ne jamais l'editer a la main). |
DevSeedPagesController.SeedVenue() ne fait plus que traduire ces donnees en blocs. Le media detail est concatene a MediaSources ([.. MediaSources, .. GilliardVenueData.Media]), donc il reste un seul passage d'import et un seul dictionnaire media.
Ce que la mesure du 2026-07-23 a corrige (l'ancienne version prenait une ligne et un formulaire en parametres fixes, faux sur 9 lieux sur 10) :
- le live sert les lieux sous deux gabarits —
.page-team>.row.team-info-rowet.single-experience>.row.single-experience-row. Scoper la requete de lignes au premier rendaitsalle-de-seminairevide ; la regle fiable reste structurelle (.rowcontenant directement.row-img), jamais la classe ; - le nombre de lignes va de 1 a 5 ;
pop-up-bar-le-qgn'a pas de formulaire ;salle-de-seminairen'a pas de CTA et ouvre par une bande « En bref… » (icone + une ligne de texte = nos cartesinfo) ;clos-du-montetclos-de-la-cochettaportent un embed Google Maps (873x450 / 873x580) entre les lignes et le formulaire — porte par le bloc partageembeden providerhtml, donc sans changement de schema et toujours sous consentement ; - aucune des 20 lignes live ne porte d'embleme : le
.logodu live contient un GIF 1x1 qui n'est jamais remplace (data-srcvide). L'ancien seeder posaitnv-emblempartout.
⚠️ Piege de sonde : forcer img.src = img.dataset.src casse ces images (data-src vide -> src=""), le navigateur affiche alors le texte alt et la ligne mesure 88px au lieu de 24. Ne forcer b-lazy que si data-src est non vide, sinon la mesure ment.
Pieges du generateur (tous rencontres pour de vrai) :
- La table
MediaSourcesest emise AVANT la section des pages. Toutmedia()appele plus tard n'y arrive jamais. D'ou le pre-calcul (PAGE_BLOCKS/HOME_BLOCKS) avant l'emission. Le symptome etait muet :Img()retombait surText("")— une valeur TextBox vide sur une propriete MediaPicker3 — et l'image disparaissait sans erreur.Img()leve desormais. List(params Block[]): avec un seul element, C# cibleBlock[]pour lenew()et echoue (CS8752). Emettrenew Block(...)explicitement.- Ne jamais taper une URL de media a la main (une banniere devinee depuis un dump tronque = un 404 qui avorte tout l'import). Les images du hero sont lues depuis
bands.json. - Les pages de detail d'activite du live sont sur
/fr/activite/<slug>/— au SINGULIER./fr/activites/<slug>/est un 404, et comparer une page locale a ce 404 fabrique un « le live n'a pas de hero ici » qui n'existe pas.fetch(..., path_prefix="activite"). - Le balisage du live n'est pas toujours bien forme :
/activites/porte sa banniere endata-src="…iStock-1215776606-2600x1095.jpg'"— apostrophe a l'interieur de l'attribut. La regex d'extension echouait en fin de chaine et l'image partait a la poubelle en silence.clean_url()retire les guillemets parasites avant toute comparaison. - ⚠️
contains(@class,"title")matche aussisubTITLE. La banniere de renvoi du live esth3.subtitleau-dessus deh1/h2.title: sans correspondance par jeton (contains(concat(" ", normalize-space(@class), " "), " title ")), le titre de chaque banniere devenait son propre sur-titre. notFoundPagene compose quepageSettings: sa propriete estnotFoundTitle, pasheroTitle.- Ne jamais re-sauver un noeud deja publie dans le meme run : l'instance
IContentest perimee ->InvalidOperationException: Cannot save a non-current version. Le chrome re-charge la racine (ContentService.GetById(ctx.RootId)) parce que la section Accueil l'a deja publiee. - Ordre : les
textPagelegales sont semees avant le chrome, qui pointe sur leurs noeuds.
⚠️ SeedSession.InternalLink : UrlMode.Relative, jamais Default
UrlMode.Default resout l'URL par rapport au site de la REQUETE COURANTE. Or /seed-chevaliers resout sa racine par doctype et peut donc etre appele depuis n'importe quel hote : en l'appelant depuis gilliard.localtest.me, tous les noeuds chevaliers passaient pour cross-site et Default emettait des URLs absolues — https://chevaliers.localtest.me:44360/activites/ grave dans le contenu. Le contenu seme dependait donc de l'hote depuis lequel on lancait le seeder, et un nom d'hote de dev serait parti en prod. Relative est independant de l'hote et correct ici : ces liens sont toujours same-site (tout ce qui est hors perimetre est seme en URL absolue explicite vers le live). Corrige 2026-07-17 ; les deux seeders re-joues, gilliard non regresse.
Extensions de contenu (src/Web/Extensions/)
PageExtensions:GetHeadTitle,GetMetaTitle,GetMetaDescription,GetOgTitle,GetOgDescription(resolution par priorite via l'interface genereeIPageSettings).DateTimeExtensions(ex.FormatAsW3CDateTimepour le sitemap),MediaWithCropsExtensions,PublishedContentExtensions(ex.GetAbsoluteUrl,GetTemplateAlias,HideFromSiteMap),UmbracoContextExtensions(ex.GetCultureFromDomains).

