Files
Forge-Engine/static/js/play/offline/render-element.js
T
williamandClaude Sonnet 5 c57420c8c9 Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:06:15 +02:00

363 lines
16 KiB
JavaScript

// Export Web/SCORM (voir le plan "port complet du runtime jouable côté
// navigateur") — port de screens/rendering/render_element_html.py et de
// ses petites briques (attr_string.py, apply_ctx.py, style_string.py,
// visible_attrs.py, parent_flex_direction.py, auto_id.py), regroupées ici
// (un seul fichier JS plutôt qu'un par brique Python : elles sont trop
// petites individuellement pour justifier un fichier chacune côté client).
//
// Widgets à balise simple (texte, titre, bouton, lien, image, vidéo,
// champs de saisie, listes, séparateur, conteneur) rendus directement
// dans ce fichier ; les 10 widgets à "special_render" (case à cocher/
// radio, liste déroulante, groupe de champs, tableau, répéteur, jauge,
// superposition, onglets, icône, personnage) vivent chacun dans leur
// propre fichier (render-jauge.js, render-overlay.js, render-repeater.js,
// render-special-widgets.js, chargés avant celui-ci dans templates/
// play.html) et sont routés depuis forgeRenderElementHtml() ci-dessous
// via forgeSpecialRenderer().
const FORGE_LAYOUT_CAPABLE_WIDGETS = ['conteneur', 'repeteur_donnees', 'liste', 'liste_numerotee', 'groupe_champs', 'tableau'];
// Widgets à balise simple portés à l'étape 3 — {tag, hasContent,
// selfClosing, fixedAttrs} — port direct de screens/widgets/registry.py
// (seuls les champs utiles au RENDU, pas au panneau de propriétés de
// l'éditeur, qui ne concerne que le port serveur).
const FORGE_SIMPLE_WIDGETS = {
texte: { tag: 'p', hasContent: true },
titre: { tag: null, hasContent: true, fixedAttrs: { class: 'title' } },
bouton: { tag: 'button', hasContent: true, fixedAttrs: { class: 'button' } },
lien: { tag: 'a', hasContent: true },
image: { tag: 'img', hasContent: false, selfClosing: true },
video: { tag: 'video', hasContent: false },
champ_texte: {
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'text', class: 'input' },
},
champ_email: {
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'email', class: 'input' },
},
champ_mot_de_passe: {
tag: 'input', hasContent: false, selfClosing: true, fixedAttrs: { type: 'password', class: 'input' },
},
zone_texte: { tag: 'textarea', hasContent: false, fixedAttrs: { class: 'textarea' } },
liste: { tag: 'ul', hasContent: true },
liste_numerotee: { tag: 'ol', hasContent: true },
separateur: { tag: 'hr', hasContent: false, selfClosing: true },
conteneur: { tag: 'div', hasContent: true, fixedAttrs: { class: 'box' } },
};
// widget -> clé de special_render (voir screens/widgets/registry.py).
const FORGE_SPECIAL_RENDER_BY_WIDGET = {
case_a_cocher: 'checkbox_label',
bouton_radio: 'checkbox_label',
liste_deroulante: 'select_options',
groupe_champs: 'fieldset_legend',
tableau: 'table',
repeteur_donnees: 'repeater',
jauge: 'jauge',
superposition: 'overlay',
onglets: 'onglets',
icone: 'icone',
personnage: 'personnage',
};
// fixed_attrs des widgets à special_render (voir screens/widgets/registry.py)
// — seul "tableau" en a un, tous les autres n'en ont aucun (contrairement
// à "conteneur", qui ne doit JAMAIS servir de repli pour eux).
const FORGE_SPECIAL_WIDGET_FIXED_ATTRS = { tableau: { class: 'table' } };
function forgeHtmlEscape(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
// Port de apply_ctx.py — {{champ}} remplacé par la valeur de la ligne en
// cours (contenu ET attributs), à l'intérieur d'un Répéteur.
function forgeApplyCtx(text, ctx) {
if (!ctx || !text) return text;
let result = text;
Object.keys(ctx).forEach((k) => {
if (k === '_forge_play_mode') return; // indicateur interne, pas un {{champ}}
result = result.split(`{{${k}}}`).join(ctx[k]);
});
return result;
}
function forgeAutoId(el) {
return `elt-${el.id}`;
}
// Port de parent_flex_direction.py.
function forgeParentFlexDirection(parentEl) {
const style = (parentEl && parentEl.style) || {};
if (style.display !== 'flex') return null;
return style['flex-direction'] || 'row';
}
// Port de style_string.py — même logique de taille par défaut (100% hors
// répéteur/conteneur, adaptée au contenu + à l'axe flex du parent pour un
// enfant), même garde-fous (transform réservé au cadre extérieur pour un
// élément posé directement sur l'écran, 0px ignoré = pas de surcharge).
function forgeStyleString(el, parentFlexDirection, widgetKey) {
const style = { ...el.style || {} };
if (FORGE_LAYOUT_CAPABLE_WIDGETS.indexOf(widgetKey) !== -1 && !('display' in style)) {
style.display = 'flex';
if (!('flex-direction' in style)) style['flex-direction'] = 'column';
}
const isChild = !!el.parent_id;
let baseStyle;
if (isChild) {
const widthDefault = (parentFlexDirection === 'row' || parentFlexDirection === 'row-reverse') ? 'auto' : '100%';
baseStyle = `width:${widthDefault}; height:auto; box-sizing:border-box; margin:0;`;
} else {
baseStyle = 'width:100%; height:100%; box-sizing:border-box; margin:0;';
}
let hasSizeOverride = false;
const parts = [];
Object.keys(style).forEach((k) => {
const v = style[k];
if (v === null || v === undefined || v === '') return;
if (typeof v === 'object') return; // filet de sécurité, voir style_string.py
if (k === 'transform' && !isChild) return;
const strV = String(v).trim();
if ((k === 'width' || k === 'height') && (strV === '0px' || strV === '0')) return;
if (k === 'width' || k === 'height') hasSizeOverride = true;
parts.push(`${k}:${v}`);
});
if (hasSizeOverride) parts.push('flex-shrink:0');
const userStyle = parts.join('; ');
return baseStyle + (userStyle ? ` ${userStyle};` : '');
}
// Port de attr_string.py. "alt" est le seul attribut dont la chaîne VIDE
// a un sens propre (RGAA/WCAG 1.1.1 : alt="" marque explicitement une
// image comme décorative, ce que "pas d'alt du tout" ne fait pas pour un
// lecteur d'écran) — tous les autres attributs vides sont omis comme
// avant (une valeur vide y équivaut à "non réglé").
function forgeAttrString(attrs, extraStyle) {
const parts = [`style="${forgeHtmlEscape(extraStyle)}"`];
Object.keys(attrs).forEach((k) => {
const v = attrs[k];
if (v === null || v === undefined || v === false) return;
if (v === '' && k !== 'alt') return;
parts.push(v === true ? forgeHtmlEscape(k) : `${forgeHtmlEscape(k)}="${forgeHtmlEscape(v)}"`);
});
return parts.join(' ');
}
// Port de visible_attrs.py.
function forgeVisibleAttrs(el, meta, ctx) {
const rawAttrs = el.attributes || {};
const attrs = {};
Object.keys(rawAttrs).forEach((k) => {
if (k.indexOf('_') === 0) return;
const v = rawAttrs[k];
attrs[k] = typeof v === 'string' ? forgeApplyCtx(v, ctx) : v;
});
Object.assign(attrs, meta.fixedAttrs || {});
const extraClasses = Object.keys(rawAttrs)
.filter((k) => k.indexOf('_class_') === 0 && rawAttrs[k])
.map((k) => rawAttrs[k]);
if (extraClasses.length) {
const baseClass = attrs.class || '';
attrs.class = (baseClass ? [baseClass] : []).concat(extraClasses).join(' ');
}
if (!('id' in attrs)) attrs.id = forgeAutoId(el);
attrs['data-element-id'] = el.id;
if (el.parent_id) attrs['data-anim-target'] = el.id;
return attrs;
}
// Port de render_children.py.
function forgeRenderChildren(el, gameData, childrenMap, ctx) {
const kids = (childrenMap && childrenMap[el.id]) || [];
const flexDir = forgeParentFlexDirection(el);
return kids.map((c) => forgeRenderElementHtml(c, gameData, childrenMap, ctx, flexDir)).join('');
}
// Port de resolve_bound_row.py (_resolve_bound_row_ctx) — "Donnée liée"
// d'un Texte/Titre : retrouve LA ligne qui correspond aux conditions
// réglées (moteur partagé avec le Répéteur, voir filter-repeater-rows.js).
function forgeResolveBoundRowCtx(gameData, attrs) {
let definitionId = attrs._data_definition_id;
if (!definitionId) return null;
definitionId = String(definitionId);
const fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId];
if (!fieldsMetaForDef) return null;
let rows = (gameData.data && gameData.data[definitionId]) || [];
const decoded = forgeDecodeClauses(attrs, '_data_filtres_json', '_data_filtre_combinateur', '_data_filtre');
rows = forgeFilterRowsByClauses(gameData, definitionId, rows, decoded.clauses, decoded.combinator);
if (!rows.length) return null;
const row = rows[0];
const ctx = {};
fieldsMetaForDef.forEach((f) => {
// row[f.name] : gameData.data est déjà indexé par le nom D'AFFICHAGE
// du champ (voir full_game_payload.py), jamais par sa colonne SQL —
// voir filter-repeater-rows.js pour le détail de ce piège.
const v = row[f.name];
ctx[f.name] = (v === null || v === undefined || v === '') ? '' : String(v);
});
return ctx;
}
// Port de render_element_html.py:_render_element_type_children — contenu
// d'un exemplaire d'élément de jeu réutilisable posé sur un écran, rejoué
// à chaque affichage depuis gameData.element_type_templates (précalculé
// une fois à l'export, voir full_game_payload.py) plutôt que depuis
// d'éventuels enfants copiés en base.
function forgeRenderElementTypeChildren(el, gameData, ctx) {
const templateTree = gameData.element_type_templates && gameData.element_type_templates[String(el.element_type_id)];
if (!templateTree || !templateTree.top.length) return [null, false];
const isOverlayOnly = templateTree.top.every((c) => c.widget === 'superposition');
let { top } = templateTree;
if (isOverlayOnly) {
const overlayRoot = { ...top[0] };
const newStyle = {};
Object.keys(overlayRoot.style || {}).forEach((k) => { if (k !== 'display') newStyle[k] = overlayRoot.style[k]; });
overlayRoot.style = newStyle;
top = top.slice();
top[0] = overlayRoot;
}
const html = top.map((c) => forgeRenderElementHtml(c, gameData, templateTree.children_map, ctx)).join('');
return [html, isOverlayOnly];
}
// Les 10 widgets à special_render (voir render-jauge.js, render-overlay.js,
// render-repeater.js, render-special-widgets.js — chargés avant ce
// dispatcher dans templates/play.html) sont tous portés (étape 4 du plan
// terminée) ; ce registre reste indirection unique nécessaire pour éviter
// une dépendance circulaire fichier-à-fichier (chacun de ces fichiers a
// besoin, en retour, de forgeRenderElementHtml ci-dessous pour ses propres
// enfants/onglets/superposition).
function forgeSpecialRenderer(specialKey) {
switch (specialKey) {
case 'checkbox_label': return forgeRenderCheckboxOrRadio;
case 'select_options': return forgeRenderSelect;
case 'fieldset_legend': return forgeRenderFieldset;
case 'table': return forgeRenderTable;
case 'repeater': return forgeRenderRepeater;
case 'jauge': return forgeRenderJauge;
case 'overlay': return forgeRenderOverlay;
case 'onglets': return forgeRenderOnglets;
case 'icone': return forgeRenderIcone;
case 'personnage': return forgeRenderPersonnage;
default: return null;
}
}
// Port de render_element_html.py — dispatcher principal. `slug` n'existe
// pas hors ligne (une seule partie en mémoire, pas de base de données) :
// tout ce qui en dépendait côté serveur lit désormais `gameData`.
function forgeRenderElementHtml(el, gameData, childrenMap, ctx, parentFlexDirection) {
const widgetKey = el.widget;
const simple = FORGE_SIMPLE_WIDGETS[widgetKey];
const specialKey = FORGE_SPECIAL_RENDER_BY_WIDGET[widgetKey];
const attrsRaw = el.attributes || {};
const hasVisibilityCondition = attrsRaw._visibilite_cond_mode === 'variable' || attrsRaw._visibilite_cond_mode === 'objet';
const playMode = !!((ctx || {})._forge_play_mode);
if (playMode && hasVisibilityCondition && !forgeIsElementVisibleOffline(gameData, attrsRaw, ctx)) {
return `<div class="visibilityGated" data-element-id="${el.id}" style="display:none;"></div>`;
}
const hasDataBinding = !!attrsRaw._data_definition_id;
const mark = function (htmlStr) {
let markers = '';
if (hasVisibilityCondition) markers += '<!--visibilityGated-->';
if (hasDataBinding) markers += '<!--dataBound-->';
return htmlStr + markers;
};
const boundCtx = forgeResolveBoundRowCtx(gameData, attrsRaw);
if (boundCtx) ctx = { ...ctx || {}, ...boundCtx };
// meta d'un widget à special_render : fixedAttrs propre (voir
// screens/widgets/registry.py — seul "tableau" en a un, "class":"table"),
// jamais celui de "conteneur" ("class":"box" polluerait tout le reste).
const meta = simple || { fixedAttrs: FORGE_SPECIAL_WIDGET_FIXED_ATTRS[widgetKey] || {} };
if (specialKey) {
const renderer = forgeSpecialRenderer(specialKey);
return mark(renderer(el, meta, gameData, childrenMap, ctx, parentFlexDirection));
}
let { tag } = meta;
if (tag === null) { // widget à balise dynamique (ex: "titre" -> h1/h2/h3)
const niveau = attrsRaw._niveau || '2';
tag = `h${niveau}`;
}
const attrs = forgeVisibleAttrs(el, meta, ctx);
const style = forgeStyleString(el, parentFlexDirection, widgetKey);
const hoverText = attrsRaw._survol_texte;
if (hoverText) attrs['data-hover-text'] = forgeApplyCtx(hoverText, ctx);
// alt (RGAA/WCAG 1.1.1) : le widget "image" n'a pas de champ de
// description dédié dans l'éditeur — alt="" (décoratif) par défaut
// plutôt qu'aucun attribut du tout, jamais le nom de fichier brut par
// défaut du navigateur. Ne touche pas à un alt déjà posé (pass-through
// via forgeVisibleAttrs, si l'auteur en règle un un jour).
if (tag === 'img' && !('alt' in attrs)) attrs.alt = '';
if (meta.selfClosing) {
return mark(`<${tag} ${forgeAttrString(attrs, style)}>`);
}
const content = forgeApplyCtx(el.content || '', ctx);
const childResult = el.element_type_id ? forgeRenderElementTypeChildren(el, gameData, ctx) : [null, false];
let childHtml = childResult[0]; const
isOverlayTemplate = childResult[1];
if (childHtml === null) {
childHtml = forgeRenderChildren(el, gameData, childrenMap, ctx);
} else if (isOverlayTemplate) {
delete attrs.class;
return mark(`<div ${forgeAttrString(attrs, style)}>${childHtml}</div>`);
}
if (tag === 'ul' || tag === 'ol') {
const items = content.split('\n').map((l) => l.trim()).filter(Boolean);
const inner = items.map((item) => `<li>${forgeHtmlEscape(item)}</li>`).join('') + childHtml;
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${inner}</${tag}>`);
}
const safeContent = content ? forgeHtmlEscape(content).replace(/\n/g, '<br>') : '';
return mark(`<${tag} ${forgeAttrString(attrs, style)}>${safeContent}${childHtml}</${tag}>`);
}
// NOTE (node:test uniquement) : forgeRenderElementHtml() référence, pour
// les 10 widgets à special_render, des fonctions définies dans D'AUTRES
// fichiers (render-jauge.js, render-overlay.js, render-repeater.js,
// render-special-widgets.js) — de vrais globaux dans le navigateur (tous
// chargés ensemble par templates/play.html), mais qui n'existent PAS
// automatiquement ici : un test qui exerce le dispatcher sur un widget à
// special_render doit passer par __tests__/_load_offline_runtime.js
// (charge tout, dans l'ordre, comme le fait play.html) plutôt que
// require('../render-element.js') seul — sans quoi requérir ces fichiers
// D'ICI créerait un cycle (ils ont eux-mêmes besoin, en retour, des
// fonctions ci-dessus pour leurs propres enfants/onglets/superposition).
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
forgeHtmlEscape,
forgeApplyCtx,
forgeAutoId,
forgeParentFlexDirection,
forgeStyleString,
forgeAttrString,
forgeVisibleAttrs,
forgeRenderChildren,
forgeRenderElementHtml,
forgeResolveBoundRowCtx,
forgeRenderElementTypeChildren,
forgeSpecialRenderer,
FORGE_SIMPLE_WIDGETS,
FORGE_SPECIAL_RENDER_BY_WIDGET,
};
global.forgeIsElementVisibleOffline = global.forgeIsElementVisibleOffline
|| require('./visibility-condition.js').forgeIsElementVisibleOffline;
const filterMod = require('./filter-repeater-rows.js');
global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue;
global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath;
global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses;
global.forgeFilterRowsByClauses = global.forgeFilterRowsByClauses || filterMod.forgeFilterRowsByClauses;
global.compareValues = global.compareValues || require('../conditions.js').compareValues;
}